"use client"; import React, { useMemo, useState } from "react"; type Counts = Record; type DiffRow = { productId: number; name: string; platform: string | null; rawCategoryKey: string | null; resolvedMerchantId: number | null; existingPartRole: string | null; existingSource: string | null; partRoleLocked: boolean | null; platformLocked: boolean | null; resolvedPartRole: string | null; resolvedSource: string | null; resolvedConfidence: number | null; resolvedReason: string | null; status: string; meta?: Record; }; type ReconcileResponse = { dryRun: boolean; scanned: number; counts: Counts; samples: DiffRow[]; }; const DEFAULT_LIMIT = 500; const DEFAULT_PLATFORM = "AR-15"; // Update these if your admin mapping UI lives elsewhere. const MAPPING_UI_PATH = "/admin/mapping"; // <- change if needed const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; const url = `${API_BASE}/admin/classification/reconcile?dryRun=true&limit=500&platform=AR-15&merchantId=4`; export default function AdminClassificationReconcilePage() { const [merchantId, setMerchantId] = useState("4"); // your current test merchant const [platform, setPlatform] = useState(DEFAULT_PLATFORM); const [limit, setLimit] = useState(DEFAULT_LIMIT); const [includeLocked, setIncludeLocked] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState(null); const [statusFilter, setStatusFilter] = useState("ALL"); const filteredSamples = useMemo(() => { if (!data?.samples) return []; if (statusFilter === "ALL") return data.samples; return data.samples.filter((r) => r.status === statusFilter); }, [data, statusFilter]); const uniqueStatuses = useMemo(() => { const set = new Set(); (data?.samples ?? []).forEach((r) => set.add(r.status)); return ["ALL", ...Array.from(set.values()).sort()]; }, [data]); async function runReconcile() { setLoading(true); setError(null); try { const params = new URLSearchParams(); params.set("dryRun", "true"); params.set("limit", String(limit)); if (platform?.trim()) params.set("platform", platform.trim()); if (merchantId?.trim()) params.set("merchantId", merchantId.trim()); if (includeLocked) params.set("includeLocked", "true"); const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; const url = `${API_BASE}/admin/classification/reconcile?${params.toString()}`; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), cache: "no-store", }); // Helpful error body (also avoids the "

Classification Reconcile

Dry-run inspector for part-role classification. Finds UNMAPPED,{" "} IGNORED, rule hits, and drift. (No writes. No regrets.)

{/* Controls */}
{error && (
{error}
)}
{/* Results */} {data && (
Scanned: {data.scanned} • DryRun:{" "} {String(data.dryRun)}
Filter
{/* Counts */}
{Object.entries(data.counts ?? {}).map(([k, v]) => (
{k}
{v}
))}
{/* Samples table */}
Samples
Showing up to 50 interesting rows (UNMAPPED / IGNORED / CONFLICT / WOULD_UPDATE + rule hits).
Go to mappings →
{filteredSamples.length === 0 ? ( ) : ( filteredSamples.map((r) => ( )) )}
Status Product Raw Category Existing Role Resolved Why
No rows match this filter.
{r.status} {r.resolvedSource?.startsWith("rules_") && (
rule: {r.resolvedSource}
)}
{r.name}
#{r.productId} • {r.platform ?? "—"} • merchantUsed: {r.resolvedMerchantId ?? "—"}
{r.rawCategoryKey ?? "—"}
{r.existingPartRole ?? "—"}
src: {r.existingSource ?? "—"} {r.partRoleLocked ? " • locked" : ""} {r.platformLocked ? " • platformLocked" : ""}
{r.resolvedPartRole ?? "—"}
src: {r.resolvedSource ?? "—"} • conf:{" "} {typeof r.resolvedConfidence === "number" ? r.resolvedConfidence.toFixed(2) : "—"}
{r.resolvedReason ?? "—"}
{r.status === "UNMAPPED" && r.rawCategoryKey ? ( Map category → ) : ( )}
)} ); }