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

356 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState, useMemo } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type Merchant = {
id: number;
name: string;
};
type Mapping = {
id: number | null; // might be null if just created client-side
merchantId: number;
merchantName: string;
rawCategory: string;
mappedPartRole: string | null;
};
type UpsertRequest = {
merchantId: number;
rawCategory: string;
mappedPartRole: string | null;
};
// Keep this in sync with your backend `partRole` values
const PART_ROLE_OPTIONS = [
{ value: "", label: "— Unmapped —" },
// Lower / fire control
{ value: "lower-receiver", label: "Lower Receiver" },
{ value: "lower-parts-kit", label: "Lower Parts Kit" },
{ value: "trigger", label: "Trigger / Trigger Kit" },
{ value: "pistol-grip", label: "Pistol Grip" },
{ value: "safety-selector", label: "Safety Selector" },
// Upper
{ value: "upper-receiver", label: "Upper Receiver" },
{ value: "barrel", label: "Barrel" },
{ value: "handguard", label: "Handguard" },
{ value: "gas-system", label: "Gas System / Gas Block" },
{ value: "muzzle-device", label: "Muzzle Device" },
{ value: "suppressor", label: "Suppressor" },
// Furniture / sights / optics
{ value: "stock", label: "Stock" },
{ value: "buffer-kit", label: "Buffer Kit" },
{ value: "charging-handle", label: "Charging Handle" },
{ value: "sight", label: "Iron / Backup Sight" },
{ value: "optic", label: "Optic" },
{ value: "other", label: "Other / Ignore" },
];
export default function CategoryMappingsPage() {
const [merchants, setMerchants] = useState<Merchant[]>([]);
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
null,
);
const [mappings, setMappings] = useState<Mapping[]>([]);
const [loadingMappings, setLoadingMappings] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showOnlyUnmapped, setShowOnlyUnmapped] = useState(false);
const sortedMappings = useMemo(() => {
// Mapped rows first, then unmapped; within each group, alpha by rawCategory
return [...mappings].sort((a, b) => {
const aMapped = !!a.mappedPartRole;
const bMapped = !!b.mappedPartRole;
if (aMapped !== bMapped) {
return aMapped ? -1 : 1; // mapped first
}
return a.rawCategory.localeCompare(b.rawCategory);
});
}, [mappings]);
const visibleMappings = useMemo(
() =>
showOnlyUnmapped
? sortedMappings.filter((m) => !m.mappedPartRole)
: sortedMappings,
[sortedMappings, showOnlyUnmapped],
);
// Load merchants
useEffect(() => {
async function fetchMerchants() {
try {
const res = await fetch(`${API_BASE_URL}/admin/merchants`);
if (!res.ok) {
throw new Error(`Failed to load merchants (${res.status})`);
}
const data: Merchant[] = await res.json();
setMerchants(data);
// Auto-select the first merchant if you want
if (data.length > 0 && selectedMerchantId === null) {
setSelectedMerchantId(data[0].id);
}
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to load merchants");
}
}
fetchMerchants();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load mappings for selected merchant
useEffect(() => {
if (!selectedMerchantId) return;
const controller = new AbortController();
async function fetchMappings() {
try {
setLoadingMappings(true);
setError(null);
setSuccessMessage(null);
const url = `${API_BASE_URL}/admin/merchant-category-mappings?merchantId=${selectedMerchantId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load mappings (${res.status})`);
}
const data = (await res.json()) as Mapping[];
setMappings(data);
} catch (err: any) {
if (err.name === "AbortError") return;
console.error(err);
setError(err.message ?? "Failed to load mappings");
} finally {
setLoadingMappings(false);
}
}
fetchMappings();
return () => controller.abort();
}, [selectedMerchantId]);
const handleChangePartRole = (rawCategory: string, newRole: string) => {
setMappings((prev) =>
prev.map((m) =>
m.rawCategory === rawCategory
? { ...m, mappedPartRole: newRole || null }
: m,
),
);
};
const handleSave = async () => {
if (!selectedMerchantId) return;
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
// Save each mapping in parallel
const requests: Promise<Response>[] = mappings.map((m) => {
const payload: UpsertRequest = {
merchantId: selectedMerchantId,
rawCategory: m.rawCategory,
mappedPartRole: m.mappedPartRole,
};
return fetch(`${API_BASE_URL}/admin/merchant-category-mappings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
});
const responses = await Promise.all(requests);
const failed = responses.filter((r) => !r.ok);
if (failed.length > 0) {
throw new Error(`Some mappings failed to save (${failed.length})`);
}
setSuccessMessage("Mappings saved. Re-run the import to apply changes.");
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to save mappings");
} finally {
setSaving(false);
}
};
const selectedMerchantName =
merchants.find((m) => m.id === selectedMerchantId)?.name ?? "—";
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
<header className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard / Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Merchant Category Mapping
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Normalize each merchant&apos;s category names into your internal
part roles (upper, barrel, handguard, etc.). Unmapped categories
will be skipped during import until they are mapped here.
</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-zinc-400 mb-1">
Select Merchant
</label>
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
value={selectedMerchantId ?? ""}
onChange={(e) =>
setSelectedMerchantId(
e.target.value ? Number(e.target.value) : null,
)
}
>
<option value=""> Choose merchant </option>
{merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{error && (
<div className="mb-3 rounded border border-red-500/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{successMessage && (
<div className="mb-3 rounded border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{successMessage}
</div>
)}
{!selectedMerchantId ? (
<p className="text-sm text-zinc-500">
Choose a merchant above to view category mappings.
</p>
) : loadingMappings ? (
<p className="text-sm text-zinc-500">
Loading mappings for <span className="text-zinc-300">{selectedMerchantName}</span>
</p>
) : mappings.length === 0 ? (
<p className="text-sm text-zinc-500">
No category rows yet for this merchant. Once you run an import,
discovered categories will appear here automatically.
</p>
) : (
<>
<div className="mb-3 flex items-center justify-between gap-3 text-xs text-zinc-500">
<div>
Mappings for{" "}
<span className="text-zinc-300">{selectedMerchantName}</span>.{" "}
Set a part role for each raw category. Unmapped categories will
be skipped by the importer.
</div>
<label className="inline-flex items-center gap-1 text-[0.7rem] text-zinc-400">
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900"
checked={showOnlyUnmapped}
onChange={(e) => setShowOnlyUnmapped(e.target.checked)}
/>
<span>Show only unmapped</span>
</label>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-2 text-left pr-4">Raw Category</th>
<th className="py-2 text-left pr-4">Mapped Part Role</th>
<th className="py-2 text-left pr-4">Status</th>
</tr>
</thead>
<tbody>
{visibleMappings.map((m) => (
<tr
key={m.rawCategory}
className="border-b border-zinc-900 last:border-b-0"
>
<td className="py-2 pr-4 align-top text-zinc-200">
{m.rawCategory}
</td>
<td className="py-2 pr-4 align-top">
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
value={m.mappedPartRole ?? ""}
onChange={(e) =>
handleChangePartRole(
m.rawCategory,
e.target.value,
)
}
>
{PART_ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</td>
<td className="py-2 pr-4 align-top text-sm">
{m.mappedPartRole ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[0.7rem] font-medium text-emerald-300">
Mapped
</span>
) : (
<span className="inline-flex items-center rounded-full bg-zinc-700/40 px-2 py-0.5 text-[0.7rem] font-medium text-zinc-300">
Unmapped / Ignored
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
>
{saving ? "Saving…" : "Save Mappings"}
</button>
</div>
</>
)}
</section>
</div>
</main>
);
}