Files
shadow-gunbuilder-ai-proto/app/admin/mapping/page.tsx
T
2025-12-29 13:59:17 -05:00

754 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
rawCategoryKey: string;
mappedPartRole: string | null;
productCount: number;
};
/**
* New “raw categories” row (for the combined UI).
* If your backend returns fewer fields at first, its 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
"upper-receiver",
"complete-upper",
"lower-receiver",
"complete-lower",
// Upper sub-parts
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
// Lower sub-parts
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
// Sights / optics
"sights",
"optic",
// Accessories / gear
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
] as const;
function toLabel(role: string) {
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 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<TabKey>(initialTab);
const [merchantId, setMerchantId] = useState<string>(initialMerchantId);
const [platform, setPlatform] = useState<string>(initialPlatform);
const [q, setQ] = useState<string>(initialQ);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Options (for catalog tab + merchant dropdown)
const [optionsLoading, setOptionsLoading] = useState<boolean>(false);
const [merchants, setMerchants] = useState<MerchantOption[]>([]);
const [canonicalCategories, setCanonicalCategories] = useState<
CanonicalCategoryOption[]
>([]);
// Rows
const [roleBuckets, setRoleBuckets] = useState<PendingBucket[]>([]);
const [rawRows, setRawRows] = useState<RawCategoryRow[]>([]);
// Saving state
const [savingKey, setSavingKey] = useState<string | null>(null);
// Part role dropdown options
const roleOptions = useMemo(
() =>
PART_ROLE_OPTIONS.map((value) => ({
value,
label: toLabel(value),
})),
[]
);
// 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 loadOptions() {
setOptionsLoading(true);
try {
setError(null);
// 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) {
// Dont hard-fail the whole page if options arent 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(() => "");
throw new Error(
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
);
}
const data: PendingBucket[] = await res.json();
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) => {
const normalized = value ? normalizeRole(value) : "";
setRoleBuckets((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next;
});
};
const handleSaveRole = async (row: PendingBucket) => {
if (!row.mappedPartRole) return;
const mapped = normalizeRole(row.mappedPartRole);
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
if (!allowed.has(mapped)) {
setError(
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
);
return;
}
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" },
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
mappedPartRole: mapped,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
);
}
setRoleBuckets((prev) =>
prev.filter(
(r) =>
!(
r.merchantId === row.merchantId &&
r.rawCategoryKey === row.rawCategoryKey
)
)
);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to save mapping");
} finally {
setSavingKey(null);
}
};
// ===== Catalog tab actions =====
const updateRawRow = (idx: number, patch: Partial<RawCategoryRow>) => {
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 servers 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 (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Admin Mapping</h1>
<p className="mt-1 text-sm text-zinc-400">
Manage <span className="text-zinc-200">builder part roles</span> and{" "}
<span className="text-zinc-200">catalog categories</span> without
blending worlds.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => load()}
className="rounded-md border border-zinc-700 bg-zinc-950/60 px-3 py-1.5 text-xs font-semibold text-zinc-100 hover:bg-zinc-900/60"
>
Refresh
</button>
</div>
</div>
{/* Tabs */}
<div className="mt-4 flex items-center gap-2">
<button
onClick={() => {
setTab("roles");
syncUrl({ tab: "roles" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "roles"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Builder Part Roles
</button>
<button
onClick={() => {
setTab("catalog");
syncUrl({ tab: "catalog" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "catalog"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Catalog Categories
</button>
<span className="ml-auto text-xs text-zinc-400">
{totals.buckets} rows {totals.products} products
</span>
</div>
{/* Filters (catalog tab only) */}
{tab === "catalog" && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Merchant
</label>
<select
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={merchantId}
onChange={(e) => {
setMerchantId(e.target.value);
syncUrl({ merchantId: e.target.value });
}}
>
<option value="">Select merchant</option>
{merchants.map((m) => (
<option key={m.id} value={String(m.id)}>
{m.name} (#{m.id})
</option>
))}
</select>
{optionsLoading && (
<p className="mt-1 text-[0.7rem] text-zinc-500">Loading merchants</p>
)}
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Platform
</label>
<input
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={platform}
onChange={(e) => {
setPlatform(e.target.value);
syncUrl({ platform: e.target.value });
}}
placeholder="AR-15"
/>
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Search raw categories
</label>
<div className="mt-1 flex gap-2">
<input
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl({ q: e.target.value });
}}
placeholder="e.g. Charging Handles"
/>
<button
onClick={() => load()}
className="rounded-md bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Search
</button>
</div>
</div>
</div>
</section>
)}
{loading && (
<p className="mt-4 text-sm text-zinc-400">Loading</p>
)}
{error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
)}
{/* ROLES TAB TABLE */}
{!loading && tab === "roles" && !error && roleBuckets.length === 0 && (
<p className="mt-4 text-sm text-emerald-400">
No pending role buckets 🎉
</p>
)}
{!loading && tab === "roles" && roleBuckets.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Pending Part Role Buckets
</h2>
<span className="text-xs text-zinc-400">
{totals.buckets} buckets {totals.products} products
</span>
</div>
<div className="mt-1 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">Raw Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Products</th>
<th className="px-2 py-1 text-left text-zinc-400">Part Role</th>
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
</tr>
</thead>
<tbody>
{roleBuckets.map((row, idx) => {
const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<td className="px-2 py-1 text-zinc-100">{row.merchantName}</td>
<td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={row.mappedPartRole ?? ""}
onChange={(e) => handleChangeRole(idx, e.target.value)}
>
<option value="">Select part role</option>
{roleOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} ({opt.value})
</option>
))}
</select>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSaveRole(row)}
disabled={!row.mappedPartRole || isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
>
{isSaving ? "Saving…" : "Save"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{/* CATALOG TAB TABLE */}
{!loading && tab === "catalog" && !error && rawRows.length === 0 && (
<p className="mt-4 text-sm text-zinc-400">
No rows loaded. Pick a merchant + click Search.
</p>
)}
{!loading && tab === "catalog" && rawRows.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Catalog Category Mapping
</h2>
<span className="text-xs text-zinc-400">
{totals.buckets} rows {totals.products} products
</span>
</div>
<div className="mt-1 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">Raw Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Products</th>
<th className="px-2 py-1 text-left text-zinc-400">Canonical Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Enabled</th>
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
</tr>
</thead>
<tbody>
{rawRows.map((row, idx) => {
const rowKey = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
const currentCategoryId =
row.canonicalCategoryId != null ? String(row.canonicalCategoryId) : "";
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={currentCategoryId}
onChange={(e) => {
const id = safeNum(e.target.value);
const opt = canonicalCategories.find((c) => c.id === id);
updateRawRow(idx, {
canonicalCategoryId: id,
canonicalCategoryName: opt?.name ?? null,
});
}}
>
<option value="">Select canonical category</option>
{canonicalCategories.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
{canonicalCategories.length === 0 && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
No canonical categories loaded (options endpoint may not be wired yet).
</p>
)}
</td>
<td className="px-2 py-1 text-zinc-100">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={row.enabled ?? true}
onChange={(e) => updateRawRow(idx, { enabled: e.target.checked })}
/>
<span className="text-zinc-300">Enabled</span>
</label>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSaveCatalog(row)}
disabled={isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
>
{isSaving ? "Saving…" : "Save"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
</main>
);
}