Просмотр исходного кода

Add bulk delete to inventory page (#119)

* bulk delete inventory lines

* translations
Bernt Christian Egeland 6 месяцев назад
Родитель
Сommit
ad54547e3c

+ 9 - 0
messages/de/inventory.json

@@ -29,6 +29,15 @@
     "description": "\"{name}\" aus dem Lager löschen? Dies kann nicht rückgängig gemacht werden.",
     "confirm": "Löschen"
   },
+  "bulkDelete": {
+    "title": "Ausgewählte Teile löschen",
+    "description": "{count, plural, one {# Teil} other {# Teile}} aus dem Lager löschen? Dies kann nicht rückgängig gemacht werden.",
+    "confirm": "Löschen",
+    "selected": "{count, plural, one {# Teil} other {# Teile}} ausgewählt",
+    "clearSelection": "Auswahl aufheben",
+    "deleteSelected": "Löschen ({count})",
+    "success": "{count, plural, one {# Teil} other {# Teile}} gelöscht"
+  },
   "markup": {
     "title": "Aufschlag auf alle Teile anwenden",
     "multiplierLabel": "Aufschlagsfaktor",

+ 9 - 0
messages/en/inventory.json

@@ -29,6 +29,15 @@
     "description": "Delete \"{name}\" from inventory? This cannot be undone.",
     "confirm": "Delete"
   },
+  "bulkDelete": {
+    "title": "Delete Selected Parts",
+    "description": "Delete {count, plural, one {# part} other {# parts}} from inventory? This cannot be undone.",
+    "confirm": "Delete",
+    "selected": "{count, plural, one {# part} other {# parts}} selected",
+    "clearSelection": "Clear",
+    "deleteSelected": "Delete ({count})",
+    "success": "Deleted {count, plural, one {# part} other {# parts}}"
+  },
   "markup": {
     "title": "Apply Markup to All Parts",
     "multiplierLabel": "Markup Multiplier",

+ 9 - 0
messages/es/inventory.json

@@ -29,6 +29,15 @@
     "description": "¿Eliminar \"{name}\" del inventario? Esta acción no se puede deshacer.",
     "confirm": "Eliminar"
   },
+  "bulkDelete": {
+    "title": "Eliminar piezas seleccionadas",
+    "description": "¿Eliminar {count, plural, one {# pieza} other {# piezas}} del inventario? Esta acción no se puede deshacer.",
+    "confirm": "Eliminar",
+    "selected": "{count, plural, one {# pieza seleccionada} other {# piezas seleccionadas}}",
+    "clearSelection": "Limpiar",
+    "deleteSelected": "Eliminar ({count})",
+    "success": "{count, plural, one {# pieza eliminada} other {# piezas eliminadas}}"
+  },
   "markup": {
     "title": "Aplicar margen a todas las piezas",
     "multiplierLabel": "Multiplicador de margen",

+ 9 - 0
messages/fr/inventory.json

@@ -29,6 +29,15 @@
     "description": "Supprimer \"{name}\" de l'inventaire ? Cette action est irréversible.",
     "confirm": "Supprimer"
   },
+  "bulkDelete": {
+    "title": "Supprimer les pièces sélectionnées",
+    "description": "Supprimer {count, plural, one {# pièce} other {# pièces}} de l'inventaire ? Cette action est irréversible.",
+    "confirm": "Supprimer",
+    "selected": "{count, plural, one {# pièce sélectionnée} other {# pièces sélectionnées}}",
+    "clearSelection": "Effacer",
+    "deleteSelected": "Supprimer ({count})",
+    "success": "{count, plural, one {# pièce supprimée} other {# pièces supprimées}}"
+  },
   "markup": {
     "title": "Appliquer la marge à toutes les pièces",
     "multiplierLabel": "Multiplicateur de marge",

+ 9 - 0
messages/it/inventory.json

@@ -29,6 +29,15 @@
     "description": "Eliminare \"{name}\" dall'inventario? Questa azione non può essere annullata.",
     "confirm": "Elimina"
   },
+  "bulkDelete": {
+    "title": "Elimina ricambi selezionati",
+    "description": "Eliminare {count, plural, one {# ricambio} other {# ricambi}} dall'inventario? Questa azione non può essere annullata.",
+    "confirm": "Elimina",
+    "selected": "{count, plural, one {# ricambio selezionato} other {# ricambi selezionati}}",
+    "clearSelection": "Annulla selezione",
+    "deleteSelected": "Elimina ({count})",
+    "success": "{count, plural, one {# ricambio eliminato} other {# ricambi eliminati}}"
+  },
   "markup": {
     "title": "Applica ricarico a tutti i ricambi",
     "multiplierLabel": "Moltiplicatore di ricarico",

+ 9 - 0
messages/nb/inventory.json

@@ -29,6 +29,15 @@
     "description": "Slette \"{name}\" fra lageret? Dette kan ikke angres.",
     "confirm": "Slett"
   },
+  "bulkDelete": {
+    "title": "Slett valgte deler",
+    "description": "Slette {count, plural, one {# del} other {# deler}} fra lageret? Dette kan ikke angres.",
+    "confirm": "Slett",
+    "selected": "{count, plural, one {# del} other {# deler}} valgt",
+    "clearSelection": "Fjern valg",
+    "deleteSelected": "Slett ({count})",
+    "success": "{count, plural, one {# del} other {# deler}} slettet"
+  },
   "markup": {
     "title": "Bruk påslag på alle deler",
     "multiplierLabel": "Påslagsmultiplikator",

+ 9 - 0
messages/nl/inventory.json

@@ -29,6 +29,15 @@
     "description": "\"{name}\" uit de voorraad verwijderen? Dit kan niet ongedaan worden gemaakt.",
     "confirm": "Verwijderen"
   },
+  "bulkDelete": {
+    "title": "Geselecteerde onderdelen verwijderen",
+    "description": "{count, plural, one {# onderdeel} other {# onderdelen}} uit de voorraad verwijderen? Dit kan niet ongedaan worden gemaakt.",
+    "confirm": "Verwijderen",
+    "selected": "{count, plural, one {# onderdeel} other {# onderdelen}} geselecteerd",
+    "clearSelection": "Wissen",
+    "deleteSelected": "Verwijderen ({count})",
+    "success": "{count, plural, one {# onderdeel} other {# onderdelen}} verwijderd"
+  },
   "markup": {
     "title": "Opslag toepassen op alle onderdelen",
     "multiplierLabel": "Opslagfactor",

+ 9 - 0
messages/pl/inventory.json

@@ -29,6 +29,15 @@
     "description": "Usunąć \"{name}\" z magazynu? Tej operacji nie można cofnąć.",
     "confirm": "Usuń"
   },
+  "bulkDelete": {
+    "title": "Usuń zaznaczone części",
+    "description": "Usunąć {count, plural, one {# część} few {# części} other {# części}} z magazynu? Tej operacji nie można cofnąć.",
+    "confirm": "Usuń",
+    "selected": "{count, plural, one {# część zaznaczona} few {# części zaznaczone} other {# części zaznaczonych}}",
+    "clearSelection": "Wyczyść",
+    "deleteSelected": "Usuń ({count})",
+    "success": "Usunięto {count, plural, one {# część} few {# części} other {# części}}"
+  },
   "markup": {
     "title": "Zastosuj marżę do wszystkich części",
     "multiplierLabel": "Mnożnik marży",

+ 9 - 0
messages/pt-BR/inventory.json

@@ -29,6 +29,15 @@
     "description": "Excluir \"{name}\" do inventário? Esta ação não pode ser desfeita.",
     "confirm": "Excluir"
   },
+  "bulkDelete": {
+    "title": "Excluir peças selecionadas",
+    "description": "Excluir {count, plural, one {# peça} other {# peças}} do estoque? Esta ação não pode ser desfeita.",
+    "confirm": "Excluir",
+    "selected": "{count, plural, one {# peça selecionada} other {# peças selecionadas}}",
+    "clearSelection": "Limpar",
+    "deleteSelected": "Excluir ({count})",
+    "success": "{count, plural, one {# peça excluída} other {# peças excluídas}}"
+  },
   "markup": {
     "title": "Aplicar margem a todas as peças",
     "multiplierLabel": "Multiplicador de margem",

+ 9 - 0
messages/ru/inventory.json

@@ -29,6 +29,15 @@
     "description": "Удалить \"{name}\" со склада? Это действие необратимо.",
     "confirm": "Удалить"
   },
+  "bulkDelete": {
+    "title": "Удалить выбранные детали",
+    "description": "Удалить {count, plural, one {# деталь} few {# детали} other {# деталей}} со склада? Это действие нельзя отменить.",
+    "confirm": "Удалить",
+    "selected": "{count, plural, one {# деталь выбрана} few {# детали выбраны} other {# деталей выбрано}}",
+    "clearSelection": "Сбросить",
+    "deleteSelected": "Удалить ({count})",
+    "success": "{count, plural, one {# деталь удалена} few {# детали удалены} other {# деталей удалено}}"
+  },
   "markup": {
     "title": "Применить наценку ко всем запчастям",
     "multiplierLabel": "Множитель наценки",

+ 9 - 0
messages/tr/inventory.json

@@ -29,6 +29,15 @@
     "description": "\"{name}\" envanterden silinsin mi? Bu işlem geri alınamaz.",
     "confirm": "Sil"
   },
+  "bulkDelete": {
+    "title": "Seçili parçaları sil",
+    "description": "Envanterden {count, plural, one {# parça} other {# parça}} silinsin mi? Bu işlem geri alınamaz.",
+    "confirm": "Sil",
+    "selected": "{count, plural, one {# parça} other {# parça}} seçili",
+    "clearSelection": "Temizle",
+    "deleteSelected": "Sil ({count})",
+    "success": "{count, plural, one {# parça} other {# parça}} silindi"
+  },
   "markup": {
     "title": "Tüm parçalara kâr marjı uygula",
     "multiplierLabel": "Kâr marjı çarpanı",

+ 125 - 43
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -31,11 +31,12 @@ import {
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
 import { DataTablePagination } from "@/components/data-table-pagination";
 import { useGlassModal } from "@/components/glass-modal";
 import { useConfirm } from "@/components/confirm-dialog";
 import { InventoryPartForm } from "@/features/inventory/Components/InventoryPartForm";
-import { deleteInventoryPart, applyMarkupToAll } from "@/features/inventory/Actions/inventoryActions";
+import { deleteInventoryPart, deleteInventoryParts, applyMarkupToAll } from "@/features/inventory/Actions/inventoryActions";
 import { setSetting } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import {
@@ -64,6 +65,7 @@ import {
   X,
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
+import { toast } from "sonner";
 
 interface InventoryPart {
   id: string;
@@ -137,6 +139,8 @@ export function InventoryClient({
   } | null>(null);
   const [scannedBarcode, setScannedBarcode] = useState('');
   const [showScanActions, setShowScanActions] = useState(false);
+  const [selected, setSelected] = useState<Set<string>>(new Set());
+  const [isBulkDeleting, setIsBulkDeleting] = useState(false);
 
   useEffect(() => {
     if (searchParams.get("create") === "true") {
@@ -222,6 +226,44 @@ export function InventoryClient({
     }
   };
 
+  const toggleSelect = (id: string) => {
+    setSelected((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleSelectAll = () => {
+    if (selected.size === data.parts.length) {
+      setSelected(new Set());
+    } else {
+      setSelected(new Set(data.parts.map((p) => p.id)));
+    }
+  };
+
+  const handleBulkDelete = async () => {
+    if (selected.size === 0) return;
+    const ok = await confirm({
+      title: t('bulkDelete.title'),
+      description: t('bulkDelete.description', { count: selected.size }),
+      confirmLabel: t('bulkDelete.confirm'),
+      destructive: true,
+    });
+    if (!ok) return;
+    setIsBulkDeleting(true);
+    const result = await deleteInventoryParts(Array.from(selected));
+    if (result.success) {
+      toast.success(t('bulkDelete.success', { count: result.data?.deleted ?? selected.size }));
+      setSelected(new Set());
+      router.refresh();
+    } else {
+      modal.open("error", t('errors.error'), result.error || t('errors.deleteFailed'));
+    }
+    setIsBulkDeleting(false);
+  };
+
   const handleApplyMarkup = async () => {
     const multiplier = Number(markupValue);
     if (!multiplier || multiplier <= 0) {
@@ -251,49 +293,70 @@ export function InventoryClient({
     <div className="space-y-4">
       {/* Toolbar */}
       <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
-        <div className="flex flex-1 items-center gap-2">
-          <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
-            <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
-            <Input
-              placeholder={t('searchPlaceholder')}
-              value={searchInput}
-              onChange={(e) => setSearchInput(e.target.value)}
-              className="pl-9"
-            />
-          </form>
-          <Select
-            value={category || "all"}
-            onValueChange={(v) => navigate({ category: v === "all" ? undefined : v })}
-          >
-            <SelectTrigger className="w-[120px] sm:w-[150px]">
-              <SelectValue placeholder={t('categoryPlaceholder')} />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{t('allCategories')}</SelectItem>
-              {categories.map((cat) => (
-                <SelectItem key={cat} value={cat}>
-                  {cat}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
-          {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
-        </div>
-        <div className="flex items-center justify-between gap-2">
-          <Button size="sm" variant="outline" onClick={() => setShowScanner(true)}>
-            <ScanBarcode className="h-3.5 w-3.5 sm:mr-1" />
-            <span className="hidden sm:inline">{t('scanBarcode')}</span>
-          </Button>
-          <div className="flex items-center gap-2">
-            <Button size="sm" variant="outline" onClick={() => setShowMarkup(true)}>
-              <Percent className="h-3.5 w-3.5 sm:mr-1" />
-              <span className="hidden sm:inline">{t('applyMarkup')}</span>
-            </Button>
-            <Button size="sm" onClick={() => setShowForm(true)}>
-              <Plus className="h-3.5 w-3.5 sm:mr-1" />
-              <span className="hidden sm:inline">{t('addPart')}</span>
+        {selected.size > 0 ? (
+          <div className="flex flex-1 items-center gap-2">
+            <span className="text-sm text-muted-foreground">
+              {t('bulkDelete.selected', { count: selected.size })}
+            </span>
+            <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
+              <X className="mr-1 h-3.5 w-3.5" />
+              {t('bulkDelete.clearSelection')}
             </Button>
           </div>
+        ) : (
+          <div className="flex flex-1 items-center gap-2">
+            <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
+              <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+              <Input
+                placeholder={t('searchPlaceholder')}
+                value={searchInput}
+                onChange={(e) => setSearchInput(e.target.value)}
+                className="pl-9"
+              />
+            </form>
+            <Select
+              value={category || "all"}
+              onValueChange={(v) => navigate({ category: v === "all" ? undefined : v })}
+            >
+              <SelectTrigger className="w-[120px] sm:w-[150px]">
+                <SelectValue placeholder={t('categoryPlaceholder')} />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="all">{t('allCategories')}</SelectItem>
+                {categories.map((cat) => (
+                  <SelectItem key={cat} value={cat}>
+                    {cat}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
+          </div>
+        )}
+        <div className="flex items-center justify-between gap-2">
+          {selected.size > 0 ? (
+            <Button size="sm" variant="destructive" onClick={handleBulkDelete} disabled={isBulkDeleting}>
+              {isBulkDeleting ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-1 h-3.5 w-3.5" />}
+              {t('bulkDelete.deleteSelected', { count: selected.size })}
+            </Button>
+          ) : (
+            <>
+              <Button size="sm" variant="outline" onClick={() => setShowScanner(true)}>
+                <ScanBarcode className="h-3.5 w-3.5 sm:mr-1" />
+                <span className="hidden sm:inline">{t('scanBarcode')}</span>
+              </Button>
+              <div className="flex items-center gap-2">
+                <Button size="sm" variant="outline" onClick={() => setShowMarkup(true)}>
+                  <Percent className="h-3.5 w-3.5 sm:mr-1" />
+                  <span className="hidden sm:inline">{t('applyMarkup')}</span>
+                </Button>
+                <Button size="sm" onClick={() => setShowForm(true)}>
+                  <Plus className="h-3.5 w-3.5 sm:mr-1" />
+                  <span className="hidden sm:inline">{t('addPart')}</span>
+                </Button>
+              </div>
+            </>
+          )}
         </div>
       </div>
 
@@ -302,6 +365,19 @@ export function InventoryClient({
         <Table>
           <TableHeader>
             <TableRow>
+              <TableHead className="w-[40px]">
+                <Checkbox
+                  checked={
+                    data.parts.length > 0 && selected.size === data.parts.length
+                      ? true
+                      : selected.size > 0
+                        ? "indeterminate"
+                        : false
+                  }
+                  onCheckedChange={toggleSelectAll}
+                  onClick={(e) => e.stopPropagation()}
+                />
+              </TableHead>
               <TableHead>
                 <button type="button" className="flex items-center hover:text-foreground" onClick={() => handleSort("partNumber")}>
                   {t('table.partNumber')}<SortIcon column="partNumber" />
@@ -349,7 +425,7 @@ export function InventoryClient({
           <TableBody>
             {data.parts.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
+                <TableCell colSpan={11} className="h-32 text-center text-muted-foreground">
                   {search || category ? t('empty.noMatch') : t('empty.noParts')}
                 </TableCell>
               </TableRow>
@@ -365,6 +441,12 @@ export function InventoryClient({
                       setShowForm(true);
                     }}
                   >
+                    <TableCell className="w-[40px]" onClick={(e) => e.stopPropagation()}>
+                      <Checkbox
+                        checked={selected.has(part.id)}
+                        onCheckedChange={() => toggleSelect(part.id)}
+                      />
+                    </TableCell>
                     <TableCell>
                       {part.partNumber ? (
                         <span className="font-mono text-sm">{part.partNumber}</span>

+ 38 - 0
src/features/inventory/Actions/inventoryActions.ts

@@ -230,6 +230,44 @@ export async function deleteInventoryPart(partId: string) {
   });
 }
 
+export async function deleteInventoryParts(partIds: string[]) {
+  return withAuth(async ({ userId, organizationId }) => {
+    if (partIds.length === 0) throw new Error("No parts selected");
+
+    // Gather image URLs before deleting
+    const parts = await db.inventoryPart.findMany({
+      where: { id: { in: partIds }, organizationId },
+      select: { imageUrl: true, gallery: { select: { url: true } } },
+    });
+
+    const result = await db.inventoryPart.deleteMany({
+      where: { id: { in: partIds }, organizationId },
+    });
+
+    // Clean up associated image files
+    const allUrls = parts.flatMap(part => [
+      part.imageUrl,
+      ...part.gallery.map(g => g.url),
+    ]).filter(Boolean) as string[];
+    const uniqueUrls = [...new Set(allUrls)];
+    for (const url of uniqueUrls) {
+      try { await unlink(resolveUploadPath(url)); } catch { /* already gone */ }
+    }
+
+    revalidatePath("/inventory");
+    return { deleted: result.count };
+  }, {
+    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY }],
+    audit: ({ result }) => ({
+      action: "inventory.bulkDelete",
+      entity: "InventoryPart",
+      entityId: partIds.join(","),
+      message: `Bulk deleted ${result.deleted} inventory parts`,
+      metadata: { partIds, deleted: result.deleted },
+    }),
+  });
+}
+
 export async function adjustInventoryStock(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const { id, adjustment } = adjustStockSchema.parse(input);