new admin dashbioard and mapping page

This commit is contained in:
2025-12-07 16:18:28 -05:00
parent 6cf3ea7c94
commit 9e5b86967b
2 changed files with 401 additions and 142 deletions
+235 -87
View File
@@ -1,111 +1,259 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
type ImportStatus = "RAW" | "PENDING_MAPPING" | "MAPPED";
type SummaryRow = {
status: ImportStatus;
count: number;
};
type ByMerchantRow = {
merchantName: string;
platform: string;
status: ImportStatus;
count: number;
};
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const formatCount = (value: number | null | undefined) =>
(typeof value === "number" ? value : 0).toLocaleString();
type ImportSummary = {
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
};
type RawByMerchantRow = {
merchantId: number;
merchantName: string;
platform: string;
status: string;
count: number;
};
type MerchantImportRow = {
merchantId: number;
merchantName: string;
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
lastImportAt?: string | null;
};
export default function ImportStatusPage() { export default function ImportStatusPage() {
const [summary, setSummary] = useState<SummaryRow[]>([]); const [summary, setSummary] = useState<ImportSummary | null>(null);
const [byMerchant, setByMerchant] = useState<ByMerchantRow[]>([]); const [rows, setRows] = useState<MerchantImportRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [importingId, setImportingId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const [summaryRes, byMerchantRes] = await Promise.all([ try {
fetch(`${API_BASE_URL}/api/admin/import-status/summary`), setLoading(true);
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), setError(null);
]);
setSummary(await summaryRes.json()); const [summaryRes, byMerchantRes] = await Promise.all([
setByMerchant(await byMerchantRes.json()); fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
]);
if (!summaryRes.ok || !byMerchantRes.ok) {
throw new Error("Failed to load import status");
}
const summaryJson: ImportSummary = await summaryRes.json();
const byMerchantRaw: RawByMerchantRow[] = await byMerchantRes.json();
// Aggregate per merchant using merchantId as the key
const merchantMap = new Map<number, MerchantImportRow>();
for (const row of byMerchantRaw) {
const id = row.merchantId;
const name = row.merchantName;
const status = row.status;
const count = row.count ?? 0;
if (!merchantMap.has(id)) {
merchantMap.set(id, {
merchantId: id,
merchantName: name,
totalProducts: 0,
mappedProducts: 0,
pendingProducts: 0,
lastImportAt: null, // not provided by API yet
});
}
const agg = merchantMap.get(id)!;
agg.totalProducts += count;
if (status === "MAPPED") {
agg.mappedProducts += count;
} else if (status === "PENDING_MAPPING") {
agg.pendingProducts += count;
}
}
const byMerchantJson: MerchantImportRow[] = Array.from(
merchantMap.values()
);
setSummary(summaryJson);
setRows(byMerchantJson);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load import status");
} finally {
setLoading(false);
}
} }
load(); load();
}, []); }, []);
const total = summary.reduce((sum, row) => sum + row.count, 0); async function handleReimport(merchantId: number) {
try {
setImportingId(merchantId);
const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
{
method: "POST",
}
);
if (!res.ok) {
throw new Error(`Re-import failed (${res.status})`);
}
// Optionally re-load data here if you want the table to refresh automatically
} catch (e: any) {
console.error(e);
alert(e.message ?? "Failed to trigger import");
} finally {
setImportingId(null);
}
}
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-8">
<h1 className="text-xl font-semibold tracking-tight"> <header className="flex items-center justify-between gap-4">
Import Status Dashboard <div>
</h1> <h1 className="text-2xl font-semibold tracking-tight">
<p className="mt-1 text-sm text-zinc-400"> Import Status
Quick sanity check on how clean (or not) your catalog is. </h1>
</p> <p className="mt-1 text-sm text-zinc-400">
Per-merchant feed health and mapping coverage.
{/* Status cards */} </p>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{summary.map((row) => (
<div
key={row.status}
className="rounded-lg border border-zinc-800 bg-zinc-950/80 p-3"
>
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
{row.status}
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{row.count}
</div>
<div className="mt-1 text-[0.7rem] text-zinc-500">
{total > 0
? `${((row.count / total) * 100).toFixed(1)}% of catalog`
: "—"}
</div>
</div>
))}
</div>
{/* Merchant breakdown */}
<section className="mt-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
By Merchant & Platform
</h2>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400">Merchant</th>
<th className="px-2 py-1 text-left text-zinc-400">Platform</th>
<th className="px-2 py-1 text-left text-zinc-400">Status</th>
<th className="px-2 py-1 text-right text-zinc-400">Count</th>
</tr>
</thead>
<tbody>
{byMerchant.map((row, i) => (
<tr
key={`${row.merchantName}-${row.platform}-${row.status}-${i}`}
className="border-b border-zinc-900"
>
<td className="px-2 py-1 text-zinc-100">
{row.merchantName}
</td>
<td className="px-2 py-1 text-zinc-300">{row.platform}</td>
<td className="px-2 py-1 text-zinc-400">{row.status}</td>
<td className="px-2 py-1 text-right text-zinc-100">
{row.count}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</section> <Link
href="/admin"
className="text-xs text-zinc-400 hover:text-zinc-200"
>
Back to Admin Home
</Link>
</header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading import status</p>
)}
{error && <p className="mt-6 text-sm text-red-400">Error: {error}</p>}
{summary && !loading && !error && (
<>
{/* Summary cards */}
<section className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-xl font-semibold">
{formatCount(summary.totalProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Mapped
</p>
<p className="mt-2 text-xl font-semibold text-emerald-400">
{formatCount(summary.mappedProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Pending Mapping
</p>
<p className="mt-2 text-xl font-semibold text-amber-300">
{formatCount(summary.pendingProducts)}
</p>
</div>
</section>
<section className="mt-8">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
By Merchant
</h2>
<div className="mt-3 overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/70">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-800 bg-zinc-950/80 text-xs uppercase text-zinc-500">
<tr>
<th className="px-4 py-3">Merchant</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">Mapped</th>
<th className="px-4 py-3 text-right">Pending</th>
<th className="px-4 py-3">Last Import</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.merchantId}
className="border-t border-zinc-900/80"
>
<td className="px-4 py-3">
<span className="font-medium text-zinc-50">
{row.merchantName}
</span>
</td>
<td className="px-4 py-3 text-right text-zinc-300">
{formatCount(row.totalProducts)}
</td>
<td className="px-4 py-3 text-right text-emerald-400">
{formatCount(row.mappedProducts)}
</td>
<td className="px-4 py-3 text-right text-amber-300">
{formatCount(row.pendingProducts)}
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{row.lastImportAt
? new Date(row.lastImportAt).toLocaleString()
: "—"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Link
href={`/admin/mapping?merchantId=${row.merchantId}`}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800"
>
Mapping
</Link>
<button
type="button"
onClick={() => handleReimport(row.merchantId)}
disabled={importingId === row.merchantId}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{importingId === row.merchantId
? "Re-importing…"
: "Re-import"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div> </div>
</main> </main>
); );
+166 -55
View File
@@ -1,67 +1,178 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function AdminHomePage() { const API_BASE_URL =
const { token } = useAuth(); process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
if (!token) { type AdminOverview = {
return ( totalProducts: number;
<div className="p-6 text-sm text-red-500"> mappedProducts: number;
You must be logged in to view this page. unmappedProducts: number;
</div> merchantCount: number;
); categoryMappingCount: number;
} };
const cards = [ export default function AdminLandingPage() {
{ const [overview, setOverview] = useState<AdminOverview | null>(null);
title: "Canonical categories", const [loading, setLoading] = useState(true);
href: "admin/categories", const [error, setError] = useState<string | null>(null);
description:
"Manage the core builder categories and their group/grouping + sort order.", useEffect(() => {
}, async function load() {
{ try {
title: "Part role mappings", setLoading(true);
href: "/admin/part-role-mappings", setError(null);
description: const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
"Advanced: map logical builder part roles to canonical categories per platform.", if (!res.ok) {
}, throw new Error(`Failed to load dashboard (${res.status})`);
{ }
title: "Merchant category mappings", const data: AdminOverview = await res.json();
href: "/admin/merchant-category-mappings", setOverview(data);
description: } catch (e: any) {
"Map raw merchant feed categories to your canonical categories.", console.error(e);
}, setError(e.message ?? "Failed to load admin dashboard");
]; } finally {
setLoading(false);
}
}
load();
}, []);
return ( return (
<div className="mx-auto max-w-5xl p-6 space-y-6"> <main className="min-h-screen bg-black text-zinc-50">
<div> <div className="mx-auto max-w-6xl px-4 py-8">
<h1 className="text-lg font-semibold tracking-tight text-zinc-100"> <header className="flex items-center justify-between gap-4">
Admin <div>
</h1> <h1 className="text-2xl font-semibold tracking-tight">
<p className="mt-1 text-xs text-zinc-400"> Battl Builder Admin
Internal tools to keep your builder data clean and consistent. </h1>
</p> <p className="mt-1 text-sm text-zinc-400">
</div> Control room for feeds, mappings, and catalog health.
<div className="grid gap-4 sm:grid-cols-3">
{cards.map((card) => (
<Link
key={card.href}
href={card.href}
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-left hover:border-emerald-500/70 hover:bg-zinc-900/80"
>
<h2 className="text-sm font-medium text-zinc-100 group-hover:text-white">
{card.title}
</h2>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
{card.description}
</p> </p>
</Link> </div>
))} </header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading dashboard</p>
)}
{error && (
<p className="mt-6 text-sm text-red-400">Error: {error}</p>
)}
{overview && !loading && !error && (
<>
{/* Stats row */}
<section className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-2xl font-semibold">
{overview.totalProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Mapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-emerald-400">
{overview.mappedProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Unmapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-amber-400">
{overview.unmappedProducts.toLocaleString()}
</p>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Products still in <code>PENDING_MAPPING</code>.
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Merchants / Mappings
</p>
<p className="mt-2 text-lg font-semibold">
{overview.merchantCount}{" "}
<span className="text-xs font-normal text-zinc-400">
merchants
</span>
</p>
<p className="text-sm text-zinc-400">
{overview.categoryMappingCount.toLocaleString()} mappings
</p>
</div>
</section>
{/* Nav cards */}
<section className="mt-10">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Admin Tools
</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Bulk Mapping */}
<Link
href="/admin/mapping"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Bulk Category Mapping
</p>
<p className="mt-1 text-xs text-zinc-400">
Bucket raw merchant categories and map them to part roles to
keep the catalog clean.
</p>
<p className="mt-3 text-xs text-amber-300">
{overview.unmappedProducts.toLocaleString()} products
pending mapping
</p>
</Link>
{/* Import Status */}
<Link
href="/admin/import-status"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Import Status
</p>
<p className="mt-1 text-xs text-zinc-400">
Inspect last feed runs, per-merchant health, and errors.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
View import summaries &nbsp;
</p>
</Link>
{/* Placeholder for future admin tools */}
<Link
href="/admin/products"
className="group rounded-lg border border-zinc-900 bg-zinc-950/40 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Catalog Explorer
</p>
<p className="mt-1 text-xs text-zinc-400">
(Future) Browse normalized products, offers, and compatibility.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
Coming soon
</p>
</Link>
</div>
</section>
</>
)}
</div> </div>
</div> </main>
); );
} }