admin page for category mappings. fixed some bugs and other tweaks
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } 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 —" },
|
||||
{ value: "upper-receiver", label: "Upper Receiver" },
|
||||
{ value: "barrel", label: "Barrel" },
|
||||
{ value: "handguard", label: "Handguard" },
|
||||
{ value: "charging-handle", label: "Charging Handle" },
|
||||
{ value: "buffer-kit", label: "Buffer Kit" },
|
||||
{ value: "lower-parts-kit", label: "Lower Parts Kit" },
|
||||
{ value: "sight", label: "Sight / Optic" },
|
||||
{ value: "lower-receiver", label: "Lower Receiver" },
|
||||
{ value: "optic", label: "Optic" },
|
||||
{ value: "stock", label: "Stock" },
|
||||
{ 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);
|
||||
|
||||
// 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 text-xs text-zinc-500">
|
||||
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>
|
||||
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user