Files
shadow-gunbuilder-ai-proto/app/admin/mapping/page.tsx
T

298 lines
9.0 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";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PendingBucket = {
merchantId: number;
merchantName: string;
rawCategoryKey: string;
mappedPartRole: string | null;
productCount: number;
};
/**
* 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.
*/
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;
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(" ");
}
export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
() =>
PART_ROLE_OPTIONS.map((value) => ({
value,
label: toLabel(value),
})),
[]
);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
{ headers: { Accept: "application/json" } }
);
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();
setRows(data);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load pending buckets");
} finally {
setLoading(false);
}
}
load();
}, []);
const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive)
const normalized = value
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next;
});
};
const handleSave = async (row: PendingBucket) => {
if (!row.mappedPartRole) return;
const mapped = row.mappedPartRole
.trim()
.toLowerCase()
.replace(/_/g, "-");
// Basic guard: prevent saving random roles from stale data
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 = `${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
setError(null);
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}` : ""}`
);
}
// After save, remove this bucket from the list
setRows((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);
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight">
Bulk Mapping Pending Categories
</h1>
<p className="mt-1 text-sm text-zinc-400">
Bucketed by merchant + raw category. Map each bucket to a part role to
clean up the builder catalog.
</p>
{loading && (
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets</p>
)}
{error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
)}
{!loading && rows.length === 0 && !error && (
<p className="mt-4 text-sm text-emerald-400">
No pending mapping buckets 🎉
</p>
)}
{!loading && rows.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 Buckets
</h2>
<span className="text-xs text-zinc-400">
{rows.length} buckets {" "}
{rows.reduce((s, r) => s + r.productCount, 0)} 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>
{rows.map((row, idx) => {
const rowKey = `${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>
{options.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={() => handleSave(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>
)}
</div>
</main>
);
}