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,
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user