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:
@@ -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;
|
||||
try {
|
||||
if (!deleteTarget || busyAction || deleting) return;
|
||||
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;
|
||||
}
|
||||
setDeleting(true);
|
||||
setBusyAction("delete");
|
||||
try {
|
||||
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;
|
||||
try {
|
||||
if (addMode === "location") {
|
||||
if (!addCountry.trim() || !addCity.trim()) {
|
||||
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") {
|
||||
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")}
|
||||
>
|
||||
{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")}
|
||||
>
|
||||
{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")}
|
||||
>
|
||||
{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")}
|
||||
>
|
||||
{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")}
|
||||
>
|
||||
{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")}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user