261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
|
|
const API_BASE_URL =
|
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
|
|
|
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<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/admin/import-status/summary', { credentials: 'include' }),
|
|
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
|
|
]);
|
|
|
|
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();
|
|
}, []);
|
|
|
|
async function handleReimport(merchantId: number) {
|
|
try {
|
|
setImportingId(merchantId);
|
|
const res = await fetch(
|
|
`/api/admin/merchants/${merchantId}/import`,
|
|
{
|
|
method: "POST",
|
|
credentials: 'include',
|
|
}
|
|
);
|
|
|
|
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-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">
|
|
Per-merchant feed health and mapping coverage.
|
|
</p>
|
|
</div>
|
|
<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>
|
|
</main>
|
|
);
|
|
} |