365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useApi } from "@/lib/useApi";
|
|
|
|
type Merchant = {
|
|
id: number;
|
|
name: string;
|
|
};
|
|
|
|
type CategoryMapping = {
|
|
id: number;
|
|
merchantId: number;
|
|
merchantName: string;
|
|
rawCategoryPath: string;
|
|
partCategoryId: number | null;
|
|
partCategoryName?: string | null;
|
|
};
|
|
|
|
// Matches the Spring MerchantCategoryMappingDto record
|
|
type MerchantCategoryMappingDto = {
|
|
id: number;
|
|
merchantId: number;
|
|
merchantName: string;
|
|
rawCategoryPath: string;
|
|
partCategoryId: number | null;
|
|
partCategoryName?: string | null;
|
|
};
|
|
|
|
type AdminCategory = {
|
|
id: number;
|
|
name: string;
|
|
groupName?: string | null;
|
|
};
|
|
|
|
export default function MerchantCategoryMappingPage() {
|
|
const { get, post } = useApi();
|
|
|
|
const [merchants, setMerchants] = useState<Merchant[]>([]);
|
|
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
|
|
null
|
|
);
|
|
const [mappings, setMappings] = useState<CategoryMapping[]>([]);
|
|
const [loadingMerchants, setLoadingMerchants] = useState(false);
|
|
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(() => {
|
|
setLoadingMerchants(true);
|
|
setError(null);
|
|
|
|
get<Merchant[]>("/api/admin/category-mappings/merchants")
|
|
.then((data) => {
|
|
setMerchants(data);
|
|
if (data.length > 0 && selectedMerchantId == null) {
|
|
setSelectedMerchantId(data[0].id);
|
|
}
|
|
})
|
|
.catch((err: any) => {
|
|
console.error("Failed to load merchants", err);
|
|
setError(
|
|
err instanceof Error ? err.message : "Failed to load merchants"
|
|
);
|
|
})
|
|
.finally(() => setLoadingMerchants(false));
|
|
// 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)
|
|
const loadMappings = useCallback(() => {
|
|
if (selectedMerchantId == null) return;
|
|
|
|
setLoadingMappings(true);
|
|
setError(null);
|
|
|
|
get<CategoryMapping[]>(
|
|
`/api/admin/category-mappings?merchantId=${selectedMerchantId}`
|
|
)
|
|
.then((data) => {
|
|
setMappings(data);
|
|
})
|
|
.catch((err: any) => {
|
|
console.error("Failed to load category mappings", err);
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to load category mappings"
|
|
);
|
|
})
|
|
.finally(() => setLoadingMappings(false));
|
|
}, [selectedMerchantId, get]);
|
|
|
|
useEffect(() => {
|
|
loadMappings();
|
|
}, [loadMappings]);
|
|
|
|
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
|
const handleSaveMapping = async (
|
|
mappingId: number,
|
|
updated: { partCategoryId: number | null }
|
|
) => {
|
|
setSavingId(mappingId);
|
|
setError(null);
|
|
try {
|
|
const updatedMapping = await post<MerchantCategoryMappingDto>(
|
|
`/api/admin/category-mappings/${mappingId}`,
|
|
{
|
|
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
|
|
)
|
|
);
|
|
} 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 all mappings"
|
|
);
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold text-zinc-50">
|
|
Merchant Category Mapping
|
|
</h1>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-zinc-400">Merchant:</span>
|
|
<select
|
|
className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-sm"
|
|
value={selectedMerchantId ?? ""}
|
|
onChange={(e) =>
|
|
setSelectedMerchantId(
|
|
e.target.value ? Number(e.target.value) : null
|
|
)
|
|
}
|
|
>
|
|
{loadingMerchants && <option value="">Loading merchants…</option>}
|
|
{!loadingMerchants && merchants.length === 0 && (
|
|
<option value="">No merchants</option>
|
|
)}
|
|
{merchants.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded bg-red-900/40 border border-red-700 px-3 py-2 text-sm text-red-200">
|
|
{error}
|
|
</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
|
|
</div>
|
|
|
|
{loadingMappings ? (
|
|
<div className="p-4 text-sm text-zinc-400">Loading mappings…</div>
|
|
) : mappings.length === 0 ? (
|
|
<div className="p-4 text-sm text-zinc-400">
|
|
No mappings found for this merchant.
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-zinc-950/60 border-b border-zinc-800">
|
|
<tr>
|
|
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
|
Merchant Category
|
|
</th>
|
|
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
|
Global Category
|
|
</th>
|
|
<th className="px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mappings.map((m) => (
|
|
<tr key={m.id} className="border-t border-zinc-800">
|
|
<td className="px-4 py-2 text-zinc-100">
|
|
{m.rawCategoryPath}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{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">
|
|
Current: {m.partCategoryName}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-right">
|
|
<button
|
|
onClick={() =>
|
|
handleSaveMapping(m.id, {
|
|
partCategoryId: m.partCategoryId ?? null,
|
|
})
|
|
}
|
|
disabled={savingId === m.id}
|
|
className="text-xs px-3 py-1 rounded border border-zinc-600 hover:bg-zinc-800 disabled:opacity-50"
|
|
>
|
|
{savingId === m.id ? "Saving…" : "Save"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|