فهرست منبع

Add bulk delete for spool and color catalog entries (#646)

  Checkbox selection + "Delete Selected" button in Settings > Filament
  for both Spool Catalog and Color Catalog. Adds POST bulk-delete
  endpoints and translations for all 7 locales.
maziggy 2 ماه پیش
والد
کامیت
a7ddc483b3

+ 1 - 0
CHANGELOG.md

@@ -7,6 +7,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 ### New Features
 - **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
 - **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
 - **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
 - **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
+- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
 
 
 ### Improved
 ### Improved
 - **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
 - **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.

+ 38 - 0
backend/app/api/routes/inventory.py

@@ -79,6 +79,10 @@ class CatalogEntryUpdate(BaseModel):
     weight: int
     weight: int
 
 
 
 
+class BulkDeleteIdsRequest(BaseModel):
+    ids: list[int]
+
+
 # ── Color Catalog Schemas ──────────────────────────────────────────────────
 # ── Color Catalog Schemas ──────────────────────────────────────────────────
 
 
 
 
@@ -176,6 +180,23 @@ async def delete_catalog_entry(
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
 
 
+@router.post("/catalog/bulk-delete")
+async def bulk_delete_catalog_entries(
+    data: BulkDeleteIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete multiple spool catalog entries by ID."""
+    if not data.ids:
+        return {"deleted": 0}
+    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
+    rows = result.scalars().all()
+    for row in rows:
+        await db.delete(row)
+    await db.commit()
+    return {"deleted": len(rows)}
+
+
 @router.post("/catalog/reset")
 @router.post("/catalog/reset")
 async def reset_spool_catalog(
 async def reset_spool_catalog(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
@@ -268,6 +289,23 @@ async def delete_color_entry(
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
 
 
+@router.post("/colors/bulk-delete")
+async def bulk_delete_color_entries(
+    data: BulkDeleteIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete multiple color catalog entries by ID."""
+    if not data.ids:
+        return {"deleted": 0}
+    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
+    rows = result.scalars().all()
+    for row in rows:
+        await db.delete(row)
+    await db.commit()
+    return {"deleted": len(rows)}
+
+
 @router.post("/colors/reset")
 @router.post("/colors/reset")
 async def reset_color_catalog(
 async def reset_color_catalog(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),

+ 22 - 0
backend/tests/unit/test_catalog_bulk_delete.py

@@ -0,0 +1,22 @@
+"""Unit tests for catalog bulk delete endpoints."""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.api.routes.inventory import BulkDeleteIdsRequest
+
+
+class TestBulkDeleteIdsRequest:
+    """Tests for BulkDeleteIdsRequest schema."""
+
+    def test_accepts_list_of_ids(self):
+        req = BulkDeleteIdsRequest(ids=[1, 2, 3])
+        assert req.ids == [1, 2, 3]
+
+    def test_accepts_empty_list(self):
+        req = BulkDeleteIdsRequest(ids=[])
+        assert req.ids == []
+
+    def test_rejects_missing_ids(self):
+        with pytest.raises(ValidationError):
+            BulkDeleteIdsRequest()

+ 4 - 0
frontend/src/api/client.ts

@@ -3591,6 +3591,8 @@ export const api = {
     request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
     request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
   deleteCatalogEntry: (id: number) =>
   deleteCatalogEntry: (id: number) =>
     request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
     request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
+  bulkDeleteCatalogEntries: (ids: number[]) =>
+    request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
   resetSpoolCatalog: () =>
   resetSpoolCatalog: () =>
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
   getColorCatalog: () =>
   getColorCatalog: () =>
@@ -3601,6 +3603,8 @@ export const api = {
     request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
     request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
   deleteColorEntry: (id: number) =>
   deleteColorEntry: (id: number) =>
     request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
     request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
+  bulkDeleteColorEntries: (ids: number[]) =>
+    request<{ deleted: number }>('/inventory/colors/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
   resetColorCatalog: () =>
   resetColorCatalog: () =>
     request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
     request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
   lookupColor: (manufacturer: string, colorName: string, material?: string) =>
   lookupColor: (manufacturer: string, colorName: string, material?: string) =>

+ 92 - 2
frontend/src/components/ColorCatalogSettings.tsx

@@ -25,6 +25,10 @@ export function ColorCatalogSettings() {
   const [formMaterial, setFormMaterial] = useState('');
   const [formMaterial, setFormMaterial] = useState('');
   const [saving, setSaving] = useState(false);
   const [saving, setSaving] = useState(false);
 
 
+  // Selection state
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+
   // Sync state
   // Sync state
   const [syncing, setSyncing] = useState(false);
   const [syncing, setSyncing] = useState(false);
   const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);
   const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);
@@ -219,6 +223,36 @@ export function ColorCatalogSettings() {
     }
     }
   };
   };
 
 
+  const toggleSelect = (id: number) => {
+    setSelectedIds(prev => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleSelectAll = () => {
+    if (selectedIds.size === filteredCatalog.length) {
+      setSelectedIds(new Set());
+    } else {
+      setSelectedIds(new Set(filteredCatalog.map(e => e.id)));
+    }
+  };
+
+  const handleBulkDelete = async () => {
+    setShowBulkDeleteConfirm(false);
+    if (selectedIds.size === 0) return;
+    try {
+      const result = await api.bulkDeleteColorEntries([...selectedIds]);
+      setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));
+      setSelectedIds(new Set());
+      showToast(t('settings.colorCatalog.bulkDeleted', { count: result.deleted }), 'success');
+    } catch {
+      showToast(t('settings.colorCatalog.bulkDeleteFailed'), 'error');
+    }
+  };
+
   const handleExport = () => {
   const handleExport = () => {
     const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
     const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
       manufacturer, color_name, hex_color, material,
       manufacturer, color_name, hex_color, material,
@@ -331,6 +365,26 @@ export function ColorCatalogSettings() {
             <span className="hidden sm:inline">{t('common.add')}</span>
             <span className="hidden sm:inline">{t('common.add')}</span>
           </button>
           </button>
         </div>
         </div>
+        {selectedIds.size > 0 && (
+          <div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
+            <span className="text-sm text-red-400">
+              {t('settings.colorCatalog.selectedCount', { count: selectedIds.size })}
+            </span>
+            <button
+              onClick={() => setShowBulkDeleteConfirm(true)}
+              className="ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5"
+            >
+              <Trash2 className="w-4 h-4" />
+              {t('settings.colorCatalog.deleteSelected')}
+            </button>
+            <button
+              onClick={() => setSelectedIds(new Set())}
+              className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors"
+            >
+              {t('common.cancel')}
+            </button>
+          </div>
+        )}
       </CardHeader>
       </CardHeader>
       <CardContent className="space-y-4">
       <CardContent className="space-y-4">
         <p className="text-sm text-bambu-gray">
         <p className="text-sm text-bambu-gray">
@@ -440,6 +494,14 @@ export function ColorCatalogSettings() {
             <table className="w-full text-sm">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>
                 <tr>
+                  <th className="px-2 py-2 w-10">
+                    <input
+                      type="checkbox"
+                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
+                      onChange={toggleSelectAll}
+                      className="w-4 h-4 accent-bambu-green cursor-pointer"
+                    />
+                  </th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
@@ -451,15 +513,23 @@ export function ColorCatalogSettings() {
               <tbody>
               <tbody>
                 {filteredCatalog.length === 0 ? (
                 {filteredCatalog.length === 0 ? (
                   <tr>
                   <tr>
-                    <td colSpan={6} className="px-3 py-8 text-center text-bambu-gray">
+                    <td colSpan={7} className="px-3 py-8 text-center text-bambu-gray">
                       {search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
                       {search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
                     </td>
                     </td>
                   </tr>
                   </tr>
                 ) : (
                 ) : (
                   filteredCatalog.map(entry => (
                   filteredCatalog.map(entry => (
-                    <tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
+                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
                       {editingId === entry.id ? (
                       {editingId === entry.id ? (
                         <>
                         <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
                             <input
                             <input
                               type="color"
                               type="color"
@@ -517,6 +587,14 @@ export function ColorCatalogSettings() {
                         </>
                         </>
                       ) : (
                       ) : (
                         <>
                         <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
                             <div
                             <div
                               className="w-8 h-8 rounded border border-bambu-dark-tertiary"
                               className="w-8 h-8 rounded border border-bambu-dark-tertiary"
@@ -567,6 +645,18 @@ export function ColorCatalogSettings() {
         />
         />
       )}
       )}
 
 
+      {/* Bulk delete confirmation */}
+      {showBulkDeleteConfirm && (
+        <ConfirmModal
+          title={t('settings.colorCatalog.deleteSelected')}
+          message={t('settings.colorCatalog.bulkDeleteConfirm', { count: selectedIds.size })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={handleBulkDelete}
+          onCancel={() => setShowBulkDeleteConfirm(false)}
+        />
+      )}
+
       {/* Reset confirmation */}
       {/* Reset confirmation */}
       {showResetConfirm && (
       {showResetConfirm && (
         <ConfirmModal
         <ConfirmModal

+ 92 - 2
frontend/src/components/SpoolCatalogSettings.tsx

@@ -22,6 +22,10 @@ export function SpoolCatalogSettings() {
   const [formWeight, setFormWeight] = useState('');
   const [formWeight, setFormWeight] = useState('');
   const [saving, setSaving] = useState(false);
   const [saving, setSaving] = useState(false);
 
 
+  // Selection state
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+
   // Confirmation modals
   // Confirmation modals
   const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
   const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
   const [showResetConfirm, setShowResetConfirm] = useState(false);
   const [showResetConfirm, setShowResetConfirm] = useState(false);
@@ -123,6 +127,36 @@ export function SpoolCatalogSettings() {
     }
     }
   };
   };
 
 
+  const toggleSelect = (id: number) => {
+    setSelectedIds(prev => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleSelectAll = () => {
+    if (selectedIds.size === filteredCatalog.length) {
+      setSelectedIds(new Set());
+    } else {
+      setSelectedIds(new Set(filteredCatalog.map(e => e.id)));
+    }
+  };
+
+  const handleBulkDelete = async () => {
+    setShowBulkDeleteConfirm(false);
+    if (selectedIds.size === 0) return;
+    try {
+      const result = await api.bulkDeleteCatalogEntries([...selectedIds]);
+      setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));
+      setSelectedIds(new Set());
+      showToast(t('settings.catalog.bulkDeleted', { count: result.deleted }), 'success');
+    } catch {
+      showToast(t('settings.catalog.bulkDeleteFailed'), 'error');
+    }
+  };
+
   const handleExport = () => {
   const handleExport = () => {
     const exportData = catalog.map(({ name, weight }) => ({ name, weight }));
     const exportData = catalog.map(({ name, weight }) => ({ name, weight }));
     const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
     const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
@@ -206,6 +240,26 @@ export function SpoolCatalogSettings() {
             <span className="hidden sm:inline">{t('common.add')}</span>
             <span className="hidden sm:inline">{t('common.add')}</span>
           </button>
           </button>
         </div>
         </div>
+        {selectedIds.size > 0 && (
+          <div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
+            <span className="text-sm text-red-400">
+              {t('settings.catalog.selectedCount', { count: selectedIds.size })}
+            </span>
+            <button
+              onClick={() => setShowBulkDeleteConfirm(true)}
+              className="ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5"
+            >
+              <Trash2 className="w-4 h-4" />
+              {t('settings.catalog.deleteSelected')}
+            </button>
+            <button
+              onClick={() => setSelectedIds(new Set())}
+              className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors"
+            >
+              {t('common.cancel')}
+            </button>
+          </div>
+        )}
       </CardHeader>
       </CardHeader>
       <CardContent className="space-y-4">
       <CardContent className="space-y-4">
         <p className="text-sm text-bambu-gray">
         <p className="text-sm text-bambu-gray">
@@ -275,6 +329,14 @@ export function SpoolCatalogSettings() {
             <table className="w-full text-sm">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>
                 <tr>
+                  <th className="px-2 py-2 w-10">
+                    <input
+                      type="checkbox"
+                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
+                      onChange={toggleSelectAll}
+                      className="w-4 h-4 accent-bambu-green cursor-pointer"
+                    />
+                  </th>
                   <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
                   <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
                   <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
                   <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
                   <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
                   <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
@@ -284,15 +346,23 @@ export function SpoolCatalogSettings() {
               <tbody>
               <tbody>
                 {filteredCatalog.length === 0 ? (
                 {filteredCatalog.length === 0 ? (
                   <tr>
                   <tr>
-                    <td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
+                    <td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
                       {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
                       {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
                     </td>
                     </td>
                   </tr>
                   </tr>
                 ) : (
                 ) : (
                   filteredCatalog.map(entry => (
                   filteredCatalog.map(entry => (
-                    <tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
+                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
                       {editingId === entry.id ? (
                       {editingId === entry.id ? (
                         <>
                         <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
                           <td className="px-4 py-2">
                           <td className="px-4 py-2">
                             <input
                             <input
                               type="text"
                               type="text"
@@ -329,6 +399,14 @@ export function SpoolCatalogSettings() {
                         </>
                         </>
                       ) : (
                       ) : (
                         <>
                         <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
                           <td className="px-4 py-2 text-white">{entry.name}</td>
                           <td className="px-4 py-2 text-white">{entry.name}</td>
                           <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
                           <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
                           <td className="px-4 py-2 text-center">
                           <td className="px-4 py-2 text-center">
@@ -381,6 +459,18 @@ export function SpoolCatalogSettings() {
         />
         />
       )}
       )}
 
 
+      {/* Bulk delete confirmation */}
+      {showBulkDeleteConfirm && (
+        <ConfirmModal
+          title={t('settings.catalog.deleteSelected')}
+          message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={handleBulkDelete}
+          onCancel={() => setShowBulkDeleteConfirm(false)}
+        />
+      )}
+
       {/* Reset confirmation */}
       {/* Reset confirmation */}
       {showResetConfirm && (
       {showResetConfirm && (
         <ConfirmModal
         <ConfirmModal

+ 10 - 0
frontend/src/i18n/locales/de.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'Katalog als JSON exportieren',
       exportTooltip: 'Katalog als JSON exportieren',
       importTooltip: 'Katalog aus JSON importieren',
       importTooltip: 'Katalog aus JSON importieren',
       resetTooltip: 'Auf Standardwerte zurücksetzen',
       resetTooltip: 'Auf Standardwerte zurücksetzen',
+      selectedCount: '{{count}} ausgewählt',
+      deleteSelected: 'Ausgewählte löschen',
+      bulkDeleteConfirm: 'Möchten Sie {{count}} Einträge wirklich löschen?',
+      bulkDeleted: '{{count}} Einträge gelöscht',
+      bulkDeleteFailed: 'Fehler beim Löschen der Einträge',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'Farbkatalog',
       title: 'Farbkatalog',
@@ -1656,6 +1661,11 @@ export default {
       exported: '{{count}} Farben exportiert',
       exported: '{{count}} Farben exportiert',
       imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',
       imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',
       importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
       importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
+      selectedCount: '{{count}} ausgewählt',
+      deleteSelected: 'Ausgewählte löschen',
+      bulkDeleteConfirm: 'Möchten Sie {{count}} Farben wirklich löschen?',
+      bulkDeleted: '{{count}} Farben gelöscht',
+      bulkDeleteFailed: 'Fehler beim Löschen der Farben',
     },
     },
     // General tab
     // General tab
     dateFormat: 'Datumsformat',
     dateFormat: 'Datumsformat',

+ 10 - 0
frontend/src/i18n/locales/en.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'Export catalog to JSON',
       exportTooltip: 'Export catalog to JSON',
       importTooltip: 'Import catalog from JSON',
       importTooltip: 'Import catalog from JSON',
       resetTooltip: 'Reset to defaults',
       resetTooltip: 'Reset to defaults',
+      selectedCount: '{{count}} selected',
+      deleteSelected: 'Delete Selected',
+      bulkDeleteConfirm: 'Are you sure you want to delete {{count}} entries?',
+      bulkDeleted: 'Deleted {{count}} entries',
+      bulkDeleteFailed: 'Failed to delete entries',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'Color Catalog',
       title: 'Color Catalog',
@@ -1656,6 +1661,11 @@ export default {
       exported: 'Exported {{count}} colors',
       exported: 'Exported {{count}} colors',
       imported: 'Imported {{added}} colors ({{skipped}} skipped)',
       imported: 'Imported {{added}} colors ({{skipped}} skipped)',
       importFailed: 'Failed to import: invalid JSON format',
       importFailed: 'Failed to import: invalid JSON format',
+      selectedCount: '{{count}} selected',
+      deleteSelected: 'Delete Selected',
+      bulkDeleteConfirm: 'Are you sure you want to delete {{count}} colors?',
+      bulkDeleted: 'Deleted {{count}} colors',
+      bulkDeleteFailed: 'Failed to delete colors',
     },
     },
     // General tab
     // General tab
     dateFormat: 'Date Format',
     dateFormat: 'Date Format',

+ 10 - 0
frontend/src/i18n/locales/fr.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'Exporter en JSON',
       exportTooltip: 'Exporter en JSON',
       importTooltip: 'Importer depuis JSON',
       importTooltip: 'Importer depuis JSON',
       resetTooltip: 'Réinitialiser par défaut',
       resetTooltip: 'Réinitialiser par défaut',
+      selectedCount: '{{count}} sélectionnés',
+      deleteSelected: 'Supprimer la sélection',
+      bulkDeleteConfirm: 'Supprimer {{count}} entrées ?',
+      bulkDeleted: '{{count}} entrées supprimées',
+      bulkDeleteFailed: 'Échec de la suppression',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'Catalogue de Couleurs',
       title: 'Catalogue de Couleurs',
@@ -1656,6 +1661,11 @@ export default {
       exported: '{{count}} couleurs exportées',
       exported: '{{count}} couleurs exportées',
       imported: '{{added}} couleurs importées ({{skipped}} ignorées)',
       imported: '{{added}} couleurs importées ({{skipped}} ignorées)',
       importFailed: 'Échec import : format JSON invalide',
       importFailed: 'Échec import : format JSON invalide',
+      selectedCount: '{{count}} sélectionnés',
+      deleteSelected: 'Supprimer la sélection',
+      bulkDeleteConfirm: 'Supprimer {{count}} couleurs ?',
+      bulkDeleted: '{{count}} couleurs supprimées',
+      bulkDeleteFailed: 'Échec de la suppression des couleurs',
     },
     },
     // General tab
     // General tab
     dateFormat: 'Format de date',
     dateFormat: 'Format de date',

+ 10 - 0
frontend/src/i18n/locales/it.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'Esporta catalogo in JSON',
       exportTooltip: 'Esporta catalogo in JSON',
       importTooltip: 'Importa catalogo da JSON',
       importTooltip: 'Importa catalogo da JSON',
       resetTooltip: 'Ripristina valori predefiniti',
       resetTooltip: 'Ripristina valori predefiniti',
+      selectedCount: '{{count}} selezionati',
+      deleteSelected: 'Elimina selezionati',
+      bulkDeleteConfirm: 'Eliminare {{count}} voci?',
+      bulkDeleted: '{{count}} voci eliminate',
+      bulkDeleteFailed: 'Impossibile eliminare le voci',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'Catalogo colori',
       title: 'Catalogo colori',
@@ -1656,6 +1661,11 @@ export default {
       exported: '{{count}} colori esportati',
       exported: '{{count}} colori esportati',
       imported: '{{added}} colori importati ({{skipped}} saltati)',
       imported: '{{added}} colori importati ({{skipped}} saltati)',
       importFailed: 'Impossibile importare: formato JSON non valido',
       importFailed: 'Impossibile importare: formato JSON non valido',
+      selectedCount: '{{count}} selezionati',
+      deleteSelected: 'Elimina selezionati',
+      bulkDeleteConfirm: 'Eliminare {{count}} colori?',
+      bulkDeleted: '{{count}} colori eliminati',
+      bulkDeleteFailed: 'Impossibile eliminare i colori',
     },
     },
     dateFormat: 'Formato data',
     dateFormat: 'Formato data',
     dateFormatUs: 'US (MM/GG/AAAA)',
     dateFormatUs: 'US (MM/GG/AAAA)',

+ 10 - 0
frontend/src/i18n/locales/ja.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'カタログをJSONにエクスポート',
       exportTooltip: 'カタログをJSONにエクスポート',
       importTooltip: 'JSONからカタログをインポート',
       importTooltip: 'JSONからカタログをインポート',
       resetTooltip: 'デフォルトにリセット',
       resetTooltip: 'デフォルトにリセット',
+      selectedCount: '{{count}}件選択中',
+      deleteSelected: '選択を削除',
+      bulkDeleteConfirm: '{{count}}件のエントリーを削除してもよろしいですか?',
+      bulkDeleted: '{{count}}件のエントリーを削除しました',
+      bulkDeleteFailed: 'エントリーの削除に失敗しました',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'カラーカタログ',
       title: 'カラーカタログ',
@@ -1656,6 +1661,11 @@ export default {
       exported: '{{count}}件のカラーをエクスポートしました',
       exported: '{{count}}件のカラーをエクスポートしました',
       imported: '{{added}}件のカラーをインポートしました({{skipped}}件スキップ)',
       imported: '{{added}}件のカラーをインポートしました({{skipped}}件スキップ)',
       importFailed: 'インポートに失敗しました:無効なJSON形式',
       importFailed: 'インポートに失敗しました:無効なJSON形式',
+      selectedCount: '{{count}}件選択中',
+      deleteSelected: '選択を削除',
+      bulkDeleteConfirm: '{{count}}件のカラーを削除してもよろしいですか?',
+      bulkDeleted: '{{count}}件のカラーを削除しました',
+      bulkDeleteFailed: 'カラーの削除に失敗しました',
     },
     },
     // General tab
     // General tab
     dateFormat: '日付形式',
     dateFormat: '日付形式',

+ 10 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: 'Exportar catálogo para JSON',
       exportTooltip: 'Exportar catálogo para JSON',
       importTooltip: 'Importar catálogo de JSON',
       importTooltip: 'Importar catálogo de JSON',
       resetTooltip: 'Redefinir para os padrões',
       resetTooltip: 'Redefinir para os padrões',
+      selectedCount: '{{count}} selecionados',
+      deleteSelected: 'Excluir Selecionados',
+      bulkDeleteConfirm: 'Tem certeza de que deseja excluir {{count}} entradas?',
+      bulkDeleted: '{{count}} entradas excluídas',
+      bulkDeleteFailed: 'Falha ao excluir entradas',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: 'Catálogo de Cores',
       title: 'Catálogo de Cores',
@@ -1656,6 +1661,11 @@ export default {
       exported: 'Exportadas {{count}} cores',
       exported: 'Exportadas {{count}} cores',
       imported: 'Importadas {{added}} cores ({{skipped}} ignoradas)',
       imported: 'Importadas {{added}} cores ({{skipped}} ignoradas)',
       importFailed: 'Falha ao importar: formato JSON inválido',
       importFailed: 'Falha ao importar: formato JSON inválido',
+      selectedCount: '{{count}} selecionados',
+      deleteSelected: 'Excluir Selecionados',
+      bulkDeleteConfirm: 'Tem certeza de que deseja excluir {{count}} cores?',
+      bulkDeleted: '{{count}} cores excluídas',
+      bulkDeleteFailed: 'Falha ao excluir cores',
     },
     },
     dateFormat: 'Formato de data',
     dateFormat: 'Formato de data',
     dateFormatUs: 'US (MM/DD/AAAA)',
     dateFormatUs: 'US (MM/DD/AAAA)',

+ 10 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1618,6 +1618,11 @@ export default {
       exportTooltip: '导出目录为 JSON',
       exportTooltip: '导出目录为 JSON',
       importTooltip: '从 JSON 导入目录',
       importTooltip: '从 JSON 导入目录',
       resetTooltip: '重置为默认值',
       resetTooltip: '重置为默认值',
+      selectedCount: '已选择 {{count}} 项',
+      deleteSelected: '删除所选',
+      bulkDeleteConfirm: '确定要删除 {{count}} 个条目吗?',
+      bulkDeleted: '已删除 {{count}} 个条目',
+      bulkDeleteFailed: '删除条目失败',
     },
     },
     colorCatalog: {
     colorCatalog: {
       title: '颜色目录',
       title: '颜色目录',
@@ -1656,6 +1661,11 @@ export default {
       exported: '已导出 {{count}} 种颜色',
       exported: '已导出 {{count}} 种颜色',
       imported: '已导入 {{added}} 种颜色(跳过 {{skipped}} 种)',
       imported: '已导入 {{added}} 种颜色(跳过 {{skipped}} 种)',
       importFailed: '导入失败:无效的 JSON 格式',
       importFailed: '导入失败:无效的 JSON 格式',
+      selectedCount: '已选择 {{count}} 项',
+      deleteSelected: '删除所选',
+      bulkDeleteConfirm: '确定要删除 {{count}} 种颜色吗?',
+      bulkDeleted: '已删除 {{count}} 种颜色',
+      bulkDeleteFailed: '删除颜色失败',
     },
     },
     dateFormat: '日期格式',
     dateFormat: '日期格式',
     dateFormatUs: '美式 (MM/DD/YYYY)',
     dateFormatUs: '美式 (MM/DD/YYYY)',

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CDBlYFWf.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-01PXexAN.js"></script>
+    <script type="module" crossorigin src="/assets/index-CDBlYFWf.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
   </head>
   </head>
   <body>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است