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,
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<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
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() {
/>
</div>
<div className="flex gap-2">
<Button size="sm" className="h-7 text-xs" onClick={handleAdd}>
Add
<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)}>
Cancel
@@ -883,6 +927,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: "city",
@@ -929,20 +974,31 @@ export function CatalogPage() {
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
disabled={sortingId !== null || busyAction !== null}
onClick={() => handleSort("location", location.id, "up")}
>
<ChevronUp className="h-3 w-3" />
{sortingId === `sort-location-${location.id}-up` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
disabled={sortingId !== null || busyAction !== null}
onClick={() => handleSort("location", location.id, "down")}
>
<ChevronDown className="h-3 w-3" />
{sortingId === `sort-location-${location.id}-down` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
<Switch
checked={location.isActive === 1}
disabled={togglingId === `toggle-location-${location.id}` || busyAction !== null}
onCheckedChange={() => 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);
}}
/>
<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>
</div>
)}
@@ -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")}
>
<ChevronUp className="h-3 w-3" />
{sortingId === `sort-category-${cat.id}-up` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
disabled={sortingId !== null || busyAction !== null}
onClick={() => handleSort("category", cat.id, "down")}
>
<ChevronDown className="h-3 w-3" />
{sortingId === `sort-category-${cat.id}-down` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
<Switch
checked={cat.isActive === 1}
disabled={togglingId === `toggle-category-${cat.id}` || busyAction !== null}
onCheckedChange={() => 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);
}}
/>
<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>
</div>
)}
@@ -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")}
>
<ChevronUp className="h-2.5 w-2.5" />
{sortingId === `sort-subcategory-${sub.id}-up` ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<ChevronUp className="h-2.5 w-2.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
disabled={sortingId !== null || busyAction !== null}
onClick={() => handleSort("subcategory", sub.id, "down")}
>
<ChevronDown className="h-2.5 w-2.5" />
{sortingId === `sort-subcategory-${sub.id}-down` ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<ChevronDown className="h-2.5 w-2.5" />
)}
</Button>
<Switch
checked={sub.isActive === 1}
disabled={togglingId === `toggle-subcategory-${sub.id}` || busyAction !== null}
onCheckedChange={() => 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"
>
<Copy className="h-3.5 w-3.5" />
{cloningId === p.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
disabled={busyAction !== null}
onClick={() =>
setDeleteTarget({
type: "product",
@@ -1739,22 +1829,23 @@ export function CatalogPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
{deleteError && deleteTarget && deleteTarget.type !== "product" && deleteTarget.type !== "city" && (
<Button variant="outline" onClick={handleDeactivateFromCatalog}>
<Button variant="outline" onClick={handleDeactivateFromCatalog} disabled={deleting}>
🔕 Deactivate
</Button>
)}
{deleteError && deleteTarget && deleteTarget.type === "product" && (
<Button variant="outline" onClick={handleEditFromCatalog}>
<Button variant="outline" onClick={handleEditFromCatalog} disabled={deleting}>
Edit
</Button>
)}
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>