diff --git a/admin-next/src/components/catalog/catalog-page.tsx b/admin-next/src/components/catalog/catalog-page.tsx index c36928b..66d522a 100755 --- a/admin-next/src/components/catalog/catalog-page.tsx +++ b/admin-next/src/components/catalog/catalog-page.tsx @@ -70,6 +70,7 @@ import { X, BarChart3, Copy, + Loader2, } from "lucide-react"; // ─── Types ─────────────────────────────────────── @@ -208,6 +209,12 @@ export function CatalogPage() { const [saving, setSaving] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingHidden, setUploadingHidden] = useState(false); + const [busyAction, setBusyAction] = useState(null); + const [deleting, setDeleting] = useState(false); + const [adding, setAdding] = useState(false); + const [sortingId, setSortingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + const [cloningId, setCloningId] = useState(null); // Delete confirm const [deleteTarget, setDeleteTarget] = useState<{ @@ -351,6 +358,10 @@ export function CatalogPage() { }; const handleToggleActive = async (type: string, id: number) => { + const key = `toggle-${type}-${id}`; + if (busyAction) return; + setBusyAction(key); + setTogglingId(key); try { const res = await fetch(`/api/${typeToPath(type)}/${id}`, { method: "PATCH" }); if (!res.ok) throw new Error(); @@ -358,10 +369,16 @@ export function CatalogPage() { fetchTree(); } catch { toast.error(`Failed to toggle ${type}`); + } finally { + setBusyAction(null); + setTogglingId(null); } }; const handleCityDelete = async (country: string, city: string) => { + if (busyAction || deleting) return; + setDeleting(true); + setBusyAction("delete"); try { const res = await fetch(`/api/locations/by-city?country=${encodeURIComponent(country)}&city=${encodeURIComponent(city)}`, { method: "DELETE", @@ -381,20 +398,24 @@ export function CatalogPage() { const msg = e instanceof Error ? e.message : "Failed to delete city"; setDeleteError(msg); toast.error(msg); + } finally { + setDeleting(false); + setBusyAction(null); } }; const handleDelete = async () => { - if (!deleteTarget) return; + if (!deleteTarget || busyAction || deleting) return; + if (deleteTarget.type === "city") { + const parts = deleteTarget.name.split(" > "); + const country = parts[0]; + const city = parts[1]; + await handleCityDelete(country, city); + return; + } + setDeleting(true); + setBusyAction("delete"); try { - if (deleteTarget.type === "city") { - // City delete is handled by handleCityDelete - const parts = deleteTarget.name.split(" > "); - const country = parts[0]; - const city = parts[1]; - await handleCityDelete(country, city); - return; - } const typePath = deleteTarget.type === "product" ? "products" : typeToPath(deleteTarget.type); const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" }); if (!res.ok) { @@ -413,6 +434,9 @@ export function CatalogPage() { const msg = e instanceof Error ? e.message : "Failed to delete"; setDeleteError(msg); toast.error(msg); + } finally { + setDeleting(false); + setBusyAction(null); } }; @@ -435,6 +459,9 @@ export function CatalogPage() { }; const handleCloneProduct = async (product: Product) => { + if (busyAction) return; + setCloningId(product.id); + setBusyAction(`clone-${product.id}`); try { const res = await fetch(`/api/products/${product.id}/clone`, { method: "POST" }); if (!res.ok) { @@ -446,6 +473,9 @@ export function CatalogPage() { fetchProducts(); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to clone product"); + } finally { + setCloningId(null); + setBusyAction(null); } }; @@ -478,10 +508,12 @@ export function CatalogPage() { setRenamingId(null); return; } + if (busyAction) return; + setBusyAction("rename"); try { if (type === "location") { const loc = tree?.locations.find((l) => l.id === id); - if (!loc) { setRenamingId(null); return; } + if (!loc) { setRenamingId(null); setBusyAction(null); return; } if (loc.district && loc.district !== "") { // Rename district const body = { country: loc.country, city: loc.city, district: renameValue.trim() }; @@ -510,10 +542,16 @@ export function CatalogPage() { fetchTree(); } catch { toast.error("Failed to rename"); + } finally { + setBusyAction(null); } }; const handleSort = async (type: string, id: number, direction: "up" | "down") => { + const key = `sort-${type}-${id}-${direction}`; + if (busyAction) return; + setBusyAction(key); + setSortingId(key); try { const res = await fetch(`/api/${typeToPath(type)}/${id}/sort`, { method: "PATCH", @@ -524,17 +562,27 @@ export function CatalogPage() { fetchTree(); } catch { toast.error("Failed to reorder"); + } finally { + setBusyAction(null); + setSortingId(null); } }; const handleAdd = async () => { - if (!addMode) return; + if (!addMode || adding) return; + // Validate before setting busy state + if (addMode === "location" && (!addCountry.trim() || !addCity.trim())) { + toast.error("Country and city are required"); + return; + } + if ((addMode === "category" || addMode === "subcategory") && (!addInput.trim() || !addParentId)) { + toast.error("Name is required"); + return; + } + setAdding(true); + setBusyAction("add"); try { if (addMode === "location") { - if (!addCountry.trim() || !addCity.trim()) { - toast.error("Country and city are required"); - return; - } const res = await fetch("/api/locations/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -545,10 +593,6 @@ export function CatalogPage() { throw new Error(data.error || "Failed to add"); } } else if (addMode === "category") { - if (!addInput.trim() || !addParentId) { - toast.error("Name is required"); - return; - } const res = await fetch("/api/categories/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -559,10 +603,6 @@ export function CatalogPage() { throw new Error(data.error || "Failed to add"); } } else if (addMode === "subcategory") { - if (!addInput.trim() || !addParentId) { - toast.error("Name is required"); - return; - } const res = await fetch("/api/subcategories/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -583,6 +623,9 @@ export function CatalogPage() { fetchTree(); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to add"); + } finally { + setAdding(false); + setBusyAction(null); } }; @@ -793,6 +836,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-7 text-xs" + disabled={adding} onClick={() => { setAddMode("location"); setAddCountry(""); @@ -835,8 +879,8 @@ export function CatalogPage() { />
- handleToggleActive("location", location.id)} className="scale-75" /> @@ -950,6 +1006,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-6 w-6 p-0" + disabled={busyAction !== null} onClick={() => { setRenamingId(`loc-${location.id}`); setRenameValue(isDistrict ? district : city); @@ -961,6 +1018,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive hover:text-destructive" + disabled={busyAction !== null} onClick={() => setDeleteTarget({ type: "location", @@ -1017,7 +1075,7 @@ export function CatalogPage() { if (e.key === "Escape") setAddMode(null); }} /> - +
)} @@ -1061,20 +1119,31 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-5 w-5 p-0" + disabled={sortingId !== null || busyAction !== null} onClick={() => handleSort("category", cat.id, "up")} > - + {sortingId === `sort-category-${cat.id}-up` ? ( + + ) : ( + + )} handleToggleActive("category", cat.id)} className="scale-75" /> @@ -1082,6 +1151,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-6 w-6 p-0" + disabled={busyAction !== null} onClick={() => { setRenamingId(`cat-${cat.id}`); setRenameValue(cat.name); @@ -1093,6 +1163,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive hover:text-destructive" + disabled={busyAction !== null} onClick={() => setDeleteTarget({ type: "category", @@ -1149,7 +1220,7 @@ export function CatalogPage() { if (e.key === "Escape") setAddMode(null); }} /> - + )} @@ -1201,20 +1272,31 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-5 w-5 p-0" + disabled={sortingId !== null || busyAction !== null} onClick={() => handleSort("subcategory", sub.id, "up")} > - + {sortingId === `sort-subcategory-${sub.id}-up` ? ( + + ) : ( + + )} handleToggleActive("subcategory", sub.id)} className="scale-50" /> @@ -1224,6 +1306,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-5 w-5 p-0" + disabled={busyAction !== null} onClick={() => { setRenamingId(`sub-${sub.id}`); setRenameValue(sub.name); @@ -1235,6 +1318,7 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-5 w-5 p-0 text-destructive hover:text-destructive" + disabled={busyAction !== null} onClick={() => setDeleteTarget({ type: "subcategory", @@ -1406,15 +1490,21 @@ export function CatalogPage() { variant="ghost" size="sm" className="h-7 w-7 p-0 text-orange-500 hover:text-orange-400 hover:bg-orange-500/10" + disabled={cloningId === p.id} onClick={() => handleCloneProduct(p)} title="Duplicate product" > - + {cloningId === p.id ? ( + + ) : ( + + )} )} {deleteError && deleteTarget && deleteTarget.type === "product" && ( - )} - Delete + {deleting ? "Deleting..." : "Delete"}