356 lines
12 KiB
TypeScript
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'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>
|
|
);
|
|
} |