diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 6288fe6..16ac22b 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -29,7 +29,7 @@ import { useAuth } from "@/context/AuthContext"; import { CATEGORIES } from "@/data/gunbuilderParts"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; -import type { CategoryId, Part } from "@/types/gunbuilder"; +import type { BuilderSlotKey, Part } from "@/types/builderSlots"; import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings"; // ✅ Centralized overlap rules + helpers @@ -64,7 +64,7 @@ const API_BASE_URL = const STORAGE_KEY = "gunbuilder-build-state"; // Categories that should NOT be filtered by platform (universal accessories / gear) -const UNIVERSAL_CATEGORIES = new Set([ +const UNIVERSAL_CATEGORIES = new Set([ "magazine", "weapon-light", "foregrip", @@ -82,7 +82,7 @@ const CATEGORY_GROUPS: { id: string; label: string; description?: string; - categoryIds: CategoryId[]; + categoryIds: BuilderSlotKey[]; }[] = [ { id: "lower-group", @@ -92,10 +92,10 @@ const CATEGORY_GROUPS: { categoryIds: [ "lower-receiver", "complete-lower", - "lower-parts", + "lower-parts-kit", // ✅ was lower-parts "trigger", "grip", - "safety", + "safety-selector", // ✅ was safety "buffer", "stock", ], @@ -219,8 +219,8 @@ export default function GunbuilderPage() { // ----------------------------- // Derived collections // ----------------------------- - const partsByCategory: Record = useMemo(() => { - const grouped = {} as Record; + const partsByCategory: Record = useMemo(() => { + const grouped = {} as Record; for (const category of CATEGORIES) { grouped[category.id] = parts.filter((p) => p.categoryId === category.id); } @@ -242,14 +242,14 @@ export default function GunbuilderPage() { }); }, [build, parts]); - const selectedByCategory: Record = useMemo( + const selectedByCategory: Record = useMemo( () => Object.fromEntries( Object.entries(build).map(([categoryId, partId]) => [ - categoryId as CategoryId, + categoryId as BuilderSlotKey, partId ? true : false, ]) - ) as Record, + ) as Record, [build] ); @@ -262,7 +262,7 @@ export default function GunbuilderPage() { // Role -> Category resolver // NOTE: defensive while backend roles/mappings evolve // ----------------------------- - const resolveCategoryId = (normalizedRole: string): CategoryId | null => { + const resolveCategoryId = (normalizedRole: string): BuilderSlotKey | null => { if (normalizedRole === "upper-receiver") return "upper-receiver"; if (normalizedRole.includes("complete-upper")) return "complete-upper"; @@ -311,15 +311,23 @@ export default function GunbuilderPage() { if (normalizedRole.includes("suppress")) return "suppressor"; + // ✅ FIX: Lower Parts Kit canonical key if ( normalizedRole.includes("lower-parts") || normalizedRole.includes("lpk") ) - return "lower-parts"; + return "lower-parts-kit"; if (normalizedRole.includes("trigger")) return "trigger"; if (normalizedRole.includes("grip")) return "grip"; - if (normalizedRole.includes("safety")) return "safety"; + + // ✅ FIX: Safety canonical key + if ( + normalizedRole.includes("safety") || + normalizedRole.includes("selector") + ) + return "safety-selector"; + if (normalizedRole.includes("buffer")) return "buffer"; if (normalizedRole.includes("stock")) return "stock"; @@ -328,6 +336,7 @@ export default function GunbuilderPage() { if (normalizedRole.includes("sight")) return "sights"; if (normalizedRole.includes("mag")) return "magazine"; + if ( normalizedRole.includes("weapon-light") || (normalizedRole.includes("light") && !normalizedRole.includes("flight")) @@ -354,27 +363,6 @@ export default function GunbuilderPage() { return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; }; - // ----------------------------- - // Selection handler (with hint warnings) - // ----------------------------- - const handleSelectPart = useCallback( - (categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => { - const warn = (msg: string) => { - setShareStatus(msg); - window.setTimeout(() => setShareStatus(null), 4000); - }; - - const hints = getSelectionHints(categoryId, build); - if (hints.length > 0) warn(hints[0]); - - setBuild((prev) => ({ - ...prev, - [categoryId]: partId, - })); - }, - [build] - ); - // ----------------------------- // Fetch products (scoped + universal merge) // ----------------------------- @@ -501,7 +489,7 @@ export default function GunbuilderPage() { setBuild((prevBuild) => { const next: BuildState = {}; for (const [categoryId, partId] of Object.entries(prevBuild)) { - const cid = categoryId as CategoryId; + const cid = categoryId as BuilderSlotKey; if (UNIVERSAL_CATEGORIES.has(cid)) { next[cid] = partId; } @@ -661,6 +649,31 @@ export default function GunbuilderPage() { } }; + + // ----------------------------- +// Selection handler (with hint warnings) +// ----------------------------- +const handleSelectPart = useCallback( + ( + categoryId: BuilderSlotKey, + partId: string, + _opts?: { confirm?: boolean } + ) => { + const warn = (msg: string) => { + setShareStatus(msg); + window.setTimeout(() => setShareStatus(null), 4000); + }; + + const hints = getSelectionHints(categoryId, build); + if (hints.length > 0) warn(hints[0]); + + setBuild((prev) => ({ + ...prev, + [categoryId]: partId, + })); + }, + [build] +); // ----------------------------- // Handle URL query parameters: // - ?select=categoryId:partId @@ -685,7 +698,7 @@ export default function GunbuilderPage() { if (selectParam) { const [categoryIdRaw, partId] = selectParam.split(":"); - const requestedCategoryId = categoryIdRaw as CategoryId; + const requestedCategoryId = categoryIdRaw as BuilderSlotKey; if (requestedCategoryId && partId) { handleSelectPart(requestedCategoryId, partId, { confirm: false }); @@ -696,7 +709,7 @@ export default function GunbuilderPage() { if (CATEGORIES.some((c) => c.id === removeParam)) { setBuild((prev) => { const next: BuildState = { ...prev }; - delete next[removeParam as CategoryId]; + delete next[removeParam as BuilderSlotKey]; return next; }); } @@ -744,7 +757,7 @@ export default function GunbuilderPage() { } }, [build]); - const summaryCategoryOrder: CategoryId[] = useMemo( + const summaryCategoryOrder: BuilderSlotKey[] = useMemo( () => CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) => CATEGORIES.some((c) => c.id === id) @@ -1164,7 +1177,7 @@ export default function GunbuilderPage() {
{CATEGORY_GROUPS.map((group) => { const groupCategories = CATEGORIES.filter((c) => - group.categoryIds.includes(c.id as CategoryId) + group.categoryIds.includes(c.id as BuilderSlotKey) ); if (groupCategories.length === 0) return null; @@ -1260,7 +1273,7 @@ export default function GunbuilderPage() { {/* Overlap chips help explain conflicts / dependencies */}
{getOverlapChipsForCategory( - category.id as CategoryId, + category.id as BuilderSlotKey, build ).map((c) => ( ; + +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 ?? "http://localhost:8080"; + +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 ?? "http://localhost:8080"; + 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) => ( + + + + + + + + + + + + + + + + )) + )} + +
StatusProductRaw CategoryExisting RoleResolvedWhy
+ 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 → + + ) : ( + + )} +
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/admin/mapping/page.tsx b/app/admin/mapping/page.tsx index 34384d2..b24f7dd 100644 --- a/app/admin/mapping/page.tsx +++ b/app/admin/mapping/page.tsx @@ -1,10 +1,21 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; +/** + * Tabs: + * - roles: map raw category -> canonical_part_role (builder slot) + * - catalog: map raw category -> canonical_category_id (FK to canonical_categories) + */ +type TabKey = "roles" | "catalog"; + +/** + * Existing (you already have this working) + */ type PendingBucket = { merchantId: number; merchantName: string; @@ -14,8 +25,47 @@ type PendingBucket = { }; /** - * Canonical part roles (kebab-case) — should match what the importer normalizes to. - * Keep this list tight + intentional so mappings don't create junk roles in the DB. + * New “raw categories” row (for the combined UI). + * If your backend returns fewer fields at first, it’s fine—just keep the ones you have. + */ +type RawCategoryRow = { + merchantId: number; + merchantName?: string | null; + platform?: string | null; + + rawCategoryKey: string; + productCount: number; + + // current mapping state (merchant_category_map row) + mcmId?: number | null; + enabled?: boolean | null; + + canonicalPartRole?: string | null; + + canonicalCategoryId?: number | null; + canonicalCategoryName?: string | null; +}; + +type CanonicalCategoryOption = { + id: number; + name: string; + slug?: string | null; +}; + +type MerchantOption = { + id: number; + name: string; +}; + +type MappingOptionsResponse = { + merchants: MerchantOption[]; + canonicalCategories: CanonicalCategoryOption[]; +}; + +const DEFAULT_PLATFORM = "AR-15"; + +/** + * Canonical part roles (kebab-case) */ const PART_ROLE_OPTIONS = [ // Assemblies / receivers @@ -56,23 +106,62 @@ const PART_ROLE_OPTIONS = [ "tools", ] as const; -type PartRole = (typeof PART_ROLE_OPTIONS)[number]; - function toLabel(role: string) { - // "complete-upper" -> "Complete Upper" return role .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } +function normalizeRole(role: string) { + return role.trim().toLowerCase().replace(/_/g, "-"); +} + +function safeNum(v: any): number | null { + if (v == null) return null; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + if (typeof v === "string") { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + export default function MappingAdminPage() { - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(true); - const [savingKey, setSavingKey] = useState(null); + const searchParams = useSearchParams(); + const router = useRouter(); + + // URL-driven state + const initialTab = (searchParams.get("tab") as TabKey) || "roles"; + const initialMerchantId = searchParams.get("merchantId") || ""; + const initialPlatform = searchParams.get("platform") || DEFAULT_PLATFORM; + const initialQ = searchParams.get("q") || ""; + + const [tab, setTab] = useState(initialTab); + + const [merchantId, setMerchantId] = useState(initialMerchantId); + const [platform, setPlatform] = useState(initialPlatform); + const [q, setQ] = useState(initialQ); + + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const options = useMemo( + // Options (for catalog tab + merchant dropdown) + const [optionsLoading, setOptionsLoading] = useState(false); + const [merchants, setMerchants] = useState([]); + const [canonicalCategories, setCanonicalCategories] = useState< + CanonicalCategoryOption[] + >([]); + + // Rows + const [roleBuckets, setRoleBuckets] = useState([]); + const [rawRows, setRawRows] = useState([]); + + // Saving state + const [savingKey, setSavingKey] = useState(null); + + // Part role dropdown options + const roleOptions = useMemo( () => PART_ROLE_OPTIONS.map((value) => ({ value, @@ -81,16 +170,83 @@ export default function MappingAdminPage() { [] ); + // Keep URL in sync (so reconcile can deep-link) + function syncUrl(next: Partial<{ tab: TabKey; merchantId: string; platform: string; q: string }>) { + const params = new URLSearchParams(searchParams.toString()); + if (next.tab) params.set("tab", next.tab); + if (next.merchantId !== undefined) { + if (next.merchantId) params.set("merchantId", next.merchantId); + else params.delete("merchantId"); + } + if (next.platform !== undefined) { + if (next.platform) params.set("platform", next.platform); + else params.delete("platform"); + } + if (next.q !== undefined) { + if (next.q) params.set("q", next.q); + else params.delete("q"); + } + router.replace(`/admin/mapping?${params.toString()}`); + } + + // Load merchants + canonical categories (needed for catalog tab, but also nice for filtering everywhere) useEffect(() => { - async function load() { + async function loadOptions() { + setOptionsLoading(true); try { - setLoading(true); setError(null); - const res = await fetch( - `${API_BASE_URL}/api/admin/mapping/pending-buckets`, - { headers: { Accept: "application/json" } } - ); + // EXPECTED backend endpoint (new): + // GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] } + const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!res.ok) { + // Don’t hard-fail the whole page if options aren’t wired yet. + // Roles tab can still function using pending-buckets. + const txt = await res.text().catch(() => ""); + console.warn("options endpoint not ready:", res.status, txt); + return; + } + + const json = (await res.json()) as MappingOptionsResponse; + setMerchants(json.merchants ?? []); + setCanonicalCategories(json.canonicalCategories ?? []); + } catch (e) { + console.warn("Failed to load mapping options:", e); + } finally { + setOptionsLoading(false); + } + } + + loadOptions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Default merchant if not provided and merchants list exists + useEffect(() => { + if (!merchantId && merchants.length > 0) { + setMerchantId(String(merchants[0].id)); + syncUrl({ merchantId: String(merchants[0].id) }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [merchants]); + + // Core loader: depends on tab + async function load() { + setLoading(true); + setError(null); + + try { + if (tab === "roles") { + // Existing endpoint you already have: + // GET /api/admin/mapping/pending-buckets + const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); if (!res.ok) { const text = await res.text().catch(() => ""); @@ -100,40 +256,71 @@ export default function MappingAdminPage() { } const data: PendingBucket[] = await res.json(); - setRows(data); - } catch (e: any) { - console.error(e); - setError(e.message ?? "Failed to load pending buckets"); - } finally { - setLoading(false); + setRoleBuckets(data); + setRawRows([]); + return; } - } + // tab === "catalog" + // EXPECTED new endpoint: + // GET /api/admin/mapping/raw-categories?merchantId=4&platform=AR-15&q=... + // returns rows with productCount + mapping state including canonicalCategoryId + if (!merchantId) { + setRawRows([]); + setError("Pick a merchant to view catalog mappings."); + return; + } + + const params = new URLSearchParams(); + params.set("merchantId", merchantId); + if (platform?.trim()) params.set("platform", platform.trim()); + if (q?.trim()) params.set("q", q.trim()); + + const res = await fetch( + `${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`, + { headers: { Accept: "application/json" }, cache: "no-store" } + ); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Catalog mapping endpoint not ready (${res.status})${text ? `: ${text}` : ""}` + ); + } + + const data: RawCategoryRow[] = await res.json(); + setRawRows(data); + setRoleBuckets([]); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to load mapping data"); + } finally { + setLoading(false); + } + } + + // Load on mount + when tab changes + useEffect(() => { load(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab]); + + // ===== Roles tab actions ===== const handleChangeRole = (idx: number, value: string) => { - // Always store normalized kebab-case (defensive) - const normalized = value - ? value.trim().toLowerCase().replace(/_/g, "-") - : ""; - - setRows((prev) => { + const normalized = value ? normalizeRole(value) : ""; + setRoleBuckets((prev) => { const next = [...prev]; next[idx] = { ...next[idx], mappedPartRole: normalized || null }; return next; }); }; - const handleSave = async (row: PendingBucket) => { + const handleSaveRole = async (row: PendingBucket) => { if (!row.mappedPartRole) return; - const mapped = row.mappedPartRole - .trim() - .toLowerCase() - .replace(/_/g, "-"); + const mapped = normalizeRole(row.mappedPartRole); - // Basic guard: prevent saving random roles from stale data const allowed = new Set(PART_ROLE_OPTIONS as readonly string[]); if (!allowed.has(mapped)) { setError( @@ -142,11 +329,13 @@ export default function MappingAdminPage() { return; } - const key = `${row.merchantId}-${row.rawCategoryKey}`; + const key = `roles-${row.merchantId}-${row.rawCategoryKey}`; try { setSavingKey(key); setError(null); + // Existing endpoint you already have: + // POST /api/admin/mapping/apply const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -164,8 +353,7 @@ export default function MappingAdminPage() { ); } - // After save, remove this bucket from the list - setRows((prev) => + setRoleBuckets((prev) => prev.filter( (r) => !( @@ -176,46 +364,228 @@ export default function MappingAdminPage() { ); } catch (e: any) { console.error(e); - setError(e.message ?? "Failed to save mapping"); + setError(e?.message ?? "Failed to save mapping"); } finally { setSavingKey(null); } }; + // ===== Catalog tab actions ===== + + const updateRawRow = (idx: number, patch: Partial) => { + setRawRows((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], ...patch }; + return next; + }); + }; + + const handleSaveCatalog = async (row: RawCategoryRow) => { + if (!merchantId) return; + + const key = `catalog-${row.merchantId}-${row.rawCategoryKey}`; + try { + setSavingKey(key); + setError(null); + + // EXPECTED new endpoint: + // POST /api/admin/mapping/upsert + const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + merchantId: row.merchantId, + platform: row.platform ?? platform ?? null, + rawCategory: row.rawCategoryKey, + enabled: row.enabled ?? true, + canonicalCategoryId: row.canonicalCategoryId ?? null, + // do NOT overwrite part role here; backend should merge/partial update + canonicalPartRole: null, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Failed to save catalog mapping (${res.status})${text ? `: ${text}` : ""}` + ); + } + + // Reload after save so we reflect the server’s truth. + await load(); + } catch (e: any) { + console.error(e); + setError(e?.message ?? "Failed to save catalog mapping"); + } finally { + setSavingKey(null); + } + }; + + const totals = useMemo(() => { + if (tab === "roles") { + return { + buckets: roleBuckets.length, + products: roleBuckets.reduce((s, r) => s + (r.productCount ?? 0), 0), + }; + } + return { + buckets: rawRows.length, + products: rawRows.reduce((s, r) => s + (r.productCount ?? 0), 0), + }; + }, [tab, roleBuckets, rawRows]); + return (
-

- Bulk Mapping – Pending Categories -

-

- Bucketed by merchant + raw category. Map each bucket to a part role to - clean up the builder catalog. -

+
+
+

Admin Mapping

+

+ Manage builder part roles and{" "} + catalog categories without + blending worlds. +

+
+ +
+ +
+
+ + {/* Tabs */} +
+ + + + + + {totals.buckets} rows • {totals.products} products + +
+ + {/* Filters (catalog tab only) */} + {tab === "catalog" && ( +
+
+
+ + + {optionsLoading && ( +

Loading merchants…

+ )} +
+ +
+ + { + setPlatform(e.target.value); + syncUrl({ platform: e.target.value }); + }} + placeholder="AR-15" + /> +
+ +
+ +
+ { + setQ(e.target.value); + syncUrl({ q: e.target.value }); + }} + placeholder="e.g. Charging Handles" + /> + +
+
+
+
+ )} {loading && ( -

Loading pending buckets…

+

Loading…

)} {error && (

Error: {error}

)} - {!loading && rows.length === 0 && !error && ( + {/* ROLES TAB TABLE */} + {!loading && tab === "roles" && !error && roleBuckets.length === 0 && (

- No pending mapping buckets 🎉 + No pending role buckets 🎉

)} - {!loading && rows.length > 0 && ( + {!loading && tab === "roles" && roleBuckets.length > 0 && (

- Pending Buckets + Pending Part Role Buckets

- {rows.length} buckets •{" "} - {rows.reduce((s, r) => s + r.productCount, 0)} products + {totals.buckets} buckets • {totals.products} products
@@ -223,26 +593,17 @@ export default function MappingAdminPage() { - - - - - + + + + + + - {rows.map((row, idx) => { - const rowKey = `${row.merchantId}-${row.rawCategoryKey}`; + {roleBuckets.map((row, idx) => { + const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`; const isSaving = savingKey === rowKey; return ( @@ -250,25 +611,17 @@ export default function MappingAdminPage() { key={rowKey} className="border-b border-zinc-900 hover:bg-zinc-900/40" > - - - + + +
- Merchant - - Raw Category - - Products - - Part Role - - Action - MerchantRaw CategoryProductsPart RoleAction
- {row.merchantName} - - {row.rawCategoryKey} - - {row.productCount} - {row.merchantName}{row.rawCategoryKey}{row.productCount}