feat(admin): issue #148 — button lock + loaders on all Catalog Tree ops (double-click protection)

- busyAction global mutex guard on toggle/sort/rename/delete/add/clone
- Per-op states: togglingId, sortingId, deleting, adding, cloningId
- Switch/sort arrows/Pencil/Trash/Add/Clone disabled during ops + Loader2 spinners
- Delete dialog: Delete disabled + 'Deleting...'
- tsc clean
This commit is contained in:
NW
2026-08-10 13:24:56 +01:00
parent cef8fbbee4
commit 33ec420fc0

View File

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