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
+212 -64
View File
@@ -1,104 +1,250 @@
"use client";
import { useEffect, useState } from "react";
type ImportStatus = "RAW" | "PENDING_MAPPING" | "MAPPED";
type SummaryRow = {
status: ImportStatus;
count: number;
};
type ByMerchantRow = {
merchantName: string;
platform: string;
status: ImportStatus;
count: number;
};
import Link from "next/link";
const API_BASE_URL =
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() {
const [summary, setSummary] = useState<SummaryRow[]>([]);
const [byMerchant, setByMerchant] = useState<ByMerchantRow[]>([]);
const [summary, setSummary] = useState<ImportSummary | null>(null);
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(() => {
async function load() {
try {
setLoading(true);
setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
]);
setSummary(await summaryRes.json());
setByMerchant(await byMerchantRes.json());
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();
}, []);
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 (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight">
Import Status Dashboard
<div className="mx-auto max-w-6xl px-4 py-8">
<header className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Import Status
</h1>
<p className="mt-1 text-sm text-zinc-400">
Quick sanity check on how clean (or not) your catalog is.
Per-merchant feed health and mapping coverage.
</p>
{/* Status cards */}
<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>
<Link
href="/admin"
className="text-xs text-zinc-400 hover:text-zinc-200"
>
<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>
Back to Admin Home
</Link>
</header>
{/* 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
{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-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>
<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>
{byMerchant.map((row, i) => (
{rows.map((row) => (
<tr
key={`${row.merchantName}-${row.platform}-${row.status}-${i}`}
className="border-b border-zinc-900"
key={row.merchantId}
className="border-t border-zinc-900/80"
>
<td className="px-2 py-1 text-zinc-100">
<td className="px-4 py-3">
<span className="font-medium text-zinc-50">
{row.merchantName}
</span>
</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 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>
))}
@@ -106,6 +252,8 @@ export default function ImportStatusPage() {
</table>
</div>
</section>
</>
)}
</div>
</main>
);
+157 -46
View File
@@ -1,67 +1,178 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function AdminHomePage() {
const { token } = useAuth();
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
type AdminOverview = {
totalProducts: number;
mappedProducts: number;
unmappedProducts: number;
merchantCount: number;
categoryMappingCount: number;
};
export default function AdminLandingPage() {
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`);
}
const data: AdminOverview = await res.json();
setOverview(data);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load admin dashboard");
} finally {
setLoading(false);
}
}
const cards = [
{
title: "Canonical categories",
href: "admin/categories",
description:
"Manage the core builder categories and their group/grouping + sort order.",
},
{
title: "Part role mappings",
href: "/admin/part-role-mappings",
description:
"Advanced: map logical builder part roles to canonical categories per platform.",
},
{
title: "Merchant category mappings",
href: "/admin/merchant-category-mappings",
description:
"Map raw merchant feed categories to your canonical categories.",
},
];
load();
}, []);
return (
<div className="mx-auto max-w-5xl p-6 space-y-6">
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<header className="flex items-center justify-between gap-4">
<div>
<h1 className="text-lg font-semibold tracking-tight text-zinc-100">
Admin
<h1 className="text-2xl font-semibold tracking-tight">
Battl Builder Admin
</h1>
<p className="mt-1 text-xs text-zinc-400">
Internal tools to keep your builder data clean and consistent.
<p className="mt-1 text-sm text-zinc-400">
Control room for feeds, mappings, and catalog health.
</p>
</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="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}
<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>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
{card.description}
<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>
</main>
);
}