new builder layout and filters. pagination

This commit is contained in:
2025-12-05 07:26:27 -05:00
parent cb16698940
commit 2fec870302
42 changed files with 786 additions and 238 deletions
@@ -27,6 +27,12 @@ type MerchantCategoryMappingDto = {
partCategoryName?: string | null;
};
type AdminCategory = {
id: number;
name: string;
groupName?: string | null;
};
export default function MerchantCategoryMappingPage() {
const { get, post } = useApi();
@@ -39,6 +45,9 @@ export default function MerchantCategoryMappingPage() {
const [loadingMappings, setLoadingMappings] = useState(false);
const [savingId, setSavingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [loadingCategories, setLoadingCategories] = useState(false);
const [dirty, setDirty] = useState<Record<number, CategoryMapping>>({});
// 1) Load merchants for the dropdown
useEffect(() => {
@@ -62,6 +71,25 @@ export default function MerchantCategoryMappingPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load global (canonical) part categories for the dropdown
// Load global (canonical) part categories for the dropdown
useEffect(() => {
setLoadingCategories(true);
setError(null);
get<AdminCategory[]>("/api/admin/categories")
.then((data) => {
setCategories(data);
})
.catch((err: any) => {
console.error("Failed to load categories", err);
setError(
err instanceof Error ? err.message : "Failed to load categories"
);
})
.finally(() => setLoadingCategories(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 2) Load mappings when merchant changes (THIS sends merchantId)
useEffect(() => {
if (selectedMerchantId == null) return;
@@ -78,7 +106,9 @@ export default function MerchantCategoryMappingPage() {
.catch((err: any) => {
console.error("Failed to load category mappings", err);
setError(
err instanceof Error ? err.message : "Failed to load category mappings"
err instanceof Error
? err.message
: "Failed to load category mappings"
);
})
.finally(() => setLoadingMappings(false));
@@ -98,22 +128,75 @@ export default function MerchantCategoryMappingPage() {
partCategoryId: updated.partCategoryId,
}
);
setMappings((prev) =>
prev.map((m) => (m.id === mappingId ? {
...m,
// map Java DTO fields back into your UI model
merchantId: updatedMapping.merchantId,
merchantName: updatedMapping.merchantName,
rawCategoryPath: updatedMapping.rawCategoryPath,
partCategoryId: updatedMapping.partCategoryId,
partCategoryName: updatedMapping.partCategoryName,
} : m))
prev.map((m) =>
m.id === mappingId
? {
...m,
// map Java DTO fields back into your UI model
merchantId: updatedMapping.merchantId,
merchantName: updatedMapping.merchantName,
rawCategoryPath: updatedMapping.rawCategoryPath,
partCategoryId: updatedMapping.partCategoryId,
partCategoryName: updatedMapping.partCategoryName,
}
: m
)
);
} catch (err: any) {
console.error("Failed to save mapping", err);
setError(err instanceof Error ? err.message : "Failed to save mapping");
} finally {
setSavingId(null);
}
};
const handleSaveAll = async () => {
if (Object.keys(dirty).length === 0) return;
// special flag to indicate a global save is in progress
setSavingId(-1);
setError(null);
try {
const dirtyRows = Object.values(dirty);
const updatedMappings = await Promise.all(
dirtyRows.map((row) =>
post<MerchantCategoryMappingDto>(
`/api/admin/category-mappings/${row.id}`,
{
partCategoryId: row.partCategoryId ?? null,
}
)
)
);
setMappings((prev) =>
prev.map((m) => {
const updated = updatedMappings.find((u) => u.id === m.id);
if (!updated) return m;
return {
...m,
merchantId: updated.merchantId,
merchantName: updated.merchantName,
rawCategoryPath: updated.rawCategoryPath,
partCategoryId: updated.partCategoryId,
partCategoryName: updated.partCategoryName,
};
})
);
// Clear dirty state after successful batch save
setDirty({});
} catch (err: any) {
console.error("Failed to save all mappings", err);
setError(
err instanceof Error ? err.message : "Failed to save mapping"
err instanceof Error
? err.message
: "Failed to save all mappings"
);
} finally {
setSavingId(null);
@@ -138,9 +221,7 @@ export default function MerchantCategoryMappingPage() {
)
}
>
{loadingMerchants && (
<option value="">Loading merchants</option>
)}
{loadingMerchants && <option value="">Loading merchants</option>}
{!loadingMerchants && merchants.length === 0 && (
<option value="">No merchants</option>
)}
@@ -159,6 +240,18 @@ export default function MerchantCategoryMappingPage() {
</div>
)}
{Object.keys(dirty).length > 0 && (
<div className="flex justify-end">
<button
onClick={handleSaveAll}
disabled={savingId !== null}
className="mb-3 rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
>
Save All Changes ({Object.keys(dirty).length})
</button>
</div>
)}
<div className="border border-zinc-800 rounded-lg overflow-hidden">
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
Category Mappings
@@ -192,28 +285,54 @@ export default function MerchantCategoryMappingPage() {
{m.rawCategoryPath}
</td>
<td className="px-4 py-2">
{/* For now, simple text input for partCategoryId.
You can swap this for a select populated from /api/admin/categories */}
<input
type="number"
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-28"
value={m.partCategoryId ?? ""}
onChange={(e) => {
const value = e.target.value
? Number(e.target.value)
: null;
setMappings((prev) =>
prev.map((row) =>
row.id === m.id
? { ...row, partCategoryId: value }
: row
)
);
}}
/>
{loadingCategories ? (
<span className="text-xs text-zinc-500">
Loading categories
</span>
) : categories.length === 0 ? (
<span className="text-xs text-zinc-500">
No categories
</span>
) : (
<select
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-64"
value={m.partCategoryId ?? ""}
onChange={(e) => {
const value = e.target.value
? Number(e.target.value)
: null;
const updatedRow: CategoryMapping = {
...m,
partCategoryId: value,
};
setMappings((prev) =>
prev.map((row) =>
row.id === m.id ? updatedRow : row
)
);
// mark this row as dirty so it can be batch-saved
setDirty((prev) => ({
...prev,
[m.id]: updatedRow,
}));
}}
>
<option value=""> Unmapped </option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.groupName ? `[${cat.groupName}] ` : ""}
{cat.name}
</option>
))}
</select>
)}
{m.partCategoryName && (
<div className="text-xs text-zinc-500 mt-1">
{m.partCategoryName}
Current: {m.partCategoryName}
</div>
)}
</td>
@@ -238,4 +357,4 @@ export default function MerchantCategoryMappingPage() {
</div>
</div>
);
}
}
+3 -3
View File
@@ -17,19 +17,19 @@ export default function AdminHomePage() {
const cards = [
{
title: "Canonical categories",
href: "admin/gunbuilder/categories",
href: "admin/categories",
description:
"Manage the core builder categories and their group/grouping + sort order.",
},
{
title: "Part role mappings",
href: "/admin/gunbuilder/part-role-mappings",
href: "/admin/part-role-mappings",
description:
"Advanced: map logical builder part roles to canonical categories per platform.",
},
{
title: "Merchant category mappings",
href: "/admin/gunbuilder/merchant-category-mappings",
href: "/admin/merchant-category-mappings",
description:
"Map raw merchant feed categories to your canonical categories.",
},