Browse Source

[Feature] Add spool name column and filter to filament inventory (#740)

  Add a "Spool" column to the filament inventory table that displays
  the spool catalog entry name associated with each spool. The column
  is hidden by default and can be enabled via the column visibility
  menu. Also add a spool name filter dropdown next to the brand filter,
  shown when any spools have catalog entries assigned. No backend
  changes needed — catalog data is fetched and joined on the frontend.
maziggy 2 months ago
parent
commit
96966cd3d2

+ 3 - 0
CHANGELOG.md

@@ -19,6 +19,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.
 - **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
 
+### Added
+- **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.
+
 ### Changed
 
 ### Improved

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

@@ -2858,6 +2858,7 @@ export default {
     weightUsed: 'Verbraucht',
     currentWeight: 'Restgewicht',
     measuredWeight: 'Gemessenes Gewicht',
+    spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
     measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
     slicerFilament: 'Slicer-Filament',

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

@@ -2858,6 +2858,7 @@ export default {
     weightUsed: 'Used',
     currentWeight: 'Remaining Weight',
     measuredWeight: 'Measured Weight',
+    spoolName: 'Spool',
     costPerKg: 'Cost per kg',
     measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
     slicerFilament: 'Slicer Filament',

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

@@ -2845,6 +2845,7 @@ export default {
     weightUsed: 'Consommé',
     currentWeight: 'Poids restant',
     measuredWeight: 'Poids mesuré',
+    spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
     measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
     slicerFilament: 'Filament Slicer',

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

@@ -2844,6 +2844,7 @@ export default {
     weightUsed: 'Utilizzato',
     currentWeight: 'Peso Rimanente',
     measuredWeight: 'Peso Misurato',
+    spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
     measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento Slicer',

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

@@ -2857,6 +2857,7 @@ export default {
     weightUsed: '使用量',
     currentWeight: '残量',
     measuredWeight: '計測重量',
+    spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
     measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
     slicerFilament: 'スライサーフィラメント',

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

@@ -2844,6 +2844,7 @@ export default {
     weightUsed: 'Usado',
     currentWeight: 'Peso Restante',
     measuredWeight: 'Peso Medido',
+    spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
     measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento do Fatiador',

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

@@ -2844,6 +2844,7 @@ export default {
     weightUsed: '已使用',
     currentWeight: '剩余重量',
     measuredWeight: '称量重量',
+    spoolName: '线轴',
     costPerKg: '每公斤成本',
     measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
     slicerFilament: '切片耗材',

+ 66 - 8
frontend/src/pages/InventoryPage.tsx

@@ -8,7 +8,7 @@ import {
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
 } from 'lucide-react';
 import { api, spoolbuddyApi } from '../api/client';
-import type { InventorySpool, SpoolAssignment } from '../api/client';
+import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
 import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -62,6 +62,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'tag_type', label: 'Linked Tag Type', visible: false },
   { id: 'stock', label: 'Stock', visible: false },
   { id: 'remaining', label: 'Remaining', visible: true },
+  { id: 'spool_name', label: 'Spool', visible: false },
   { id: 'cost_per_kg', label: 'Cost/kg', visible: false },
   { id: 'weight_check', label: 'Weight Check', visible: false },
 ];
@@ -124,6 +125,7 @@ type CellCtx = {
   remaining: number;
   pct: number;
   assignmentMap: Record<number, SpoolAssignment>;
+  catalogMap: Record<number, SpoolCatalogEntry>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
@@ -157,6 +159,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   tag_type: () => 'Linked Tag Type',
   stock: (t) => t('inventory.stock'),
   remaining: (t) => t('inventory.remaining'),
+  spool_name: (t) => t('inventory.spoolName'),
   cost_per_kg: (t) => t('inventory.costPerKg'),
   weight_check: (t) => t('inventory.weightCheck'),
 };
@@ -283,6 +286,10 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
       <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
     </div>
   ),
+  spool_name: ({ spool, catalogMap }) => {
+    const entry = spool.core_weight_catalog_id != null ? catalogMap[spool.core_weight_catalog_id] : undefined;
+    return <span className="text-sm text-bambu-gray">{entry?.name || '-'}</span>;
+  },
   cost_per_kg: ({ spool, currencySymbol }) => (
     <span className="text-sm text-bambu-gray">
       {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
@@ -371,6 +378,7 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
   data_origin: (s) => (s.data_origin || '').toLowerCase(),
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
+  spool_name: (s) => s.core_weight_catalog_id ?? 0,
   cost_per_kg: (s) => s.cost_per_kg ?? 0,
   weight_check: (s) => {
     if (s.last_scale_weight == null) return -1;
@@ -433,6 +441,7 @@ function InventoryPage() {
   const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
+  const [spoolFilter, setSpoolFilter] = useState('');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [search, setSearch] = useState('');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
@@ -478,6 +487,11 @@ function InventoryPage() {
     refetchInterval: 30000,
   });
 
+  const { data: catalogEntries } = useQuery({
+    queryKey: ['spool-catalog'],
+    queryFn: () => api.getSpoolCatalog(),
+  });
+
   const deleteMutation = useMutation({
     mutationFn: (id: number) => api.deleteSpool(id),
     onSuccess: () => {
@@ -575,6 +589,15 @@ function InventoryPage() {
     return map;
   }, [assignments]);
 
+  // Map catalog_id -> catalog entry for spool name column
+  const catalogMap = useMemo(() => {
+    const map: Record<number, SpoolCatalogEntry> = {};
+    for (const e of catalogEntries || []) {
+      map[e.id] = e;
+    }
+    return map;
+  }, [catalogEntries]);
+
   // Top materials by weight for stat card pills
   const topMaterials = useMemo(() => {
     if (!stats) return [];
@@ -617,6 +640,12 @@ function InventoryPage() {
       filtered = filtered.filter((s) => s.brand === brandFilter);
     }
 
+    // Spool name dropdown
+    if (spoolFilter) {
+      const catalogId = Number(spoolFilter);
+      filtered = filtered.filter((s) => s.core_weight_catalog_id === catalogId);
+    }
+
     // Stock filter
     if (stockFilter === 'stock') {
       filtered = filtered.filter((s) => !s.slicer_filament);
@@ -638,7 +667,7 @@ function InventoryPage() {
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search, lowStockThreshold]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -646,9 +675,14 @@ function InventoryPage() {
   // Unique values for filter dropdowns
   const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
   const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
+  const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {
+    const nameA = (catalogMap[a]?.name || '').toLowerCase();
+    const nameB = (catalogMap[b]?.name || '').toLowerCase();
+    return nameA.localeCompare(nameB);
+  });
 
   // Check if any filters are non-default
-  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || stockFilter !== 'all' || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
@@ -769,6 +803,7 @@ function InventoryPage() {
     setUsageFilter('all');
     setMaterialFilter('');
     setBrandFilter('');
+    setSpoolFilter('');
     setStockFilter('all');
     setSearch('');
     resetPage();
@@ -1124,6 +1159,24 @@ function InventoryPage() {
           ))}
         </select>
 
+        {/* Spool name dropdown chip */}
+        {uniqueSpoolCatalogIds.length > 0 && (
+          <select
+            value={spoolFilter}
+            onChange={(e) => { setSpoolFilter(e.target.value); resetPage(); }}
+            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+              spoolFilter
+                ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+                : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <option value="">{t('inventory.spoolName')}</option>
+            {uniqueSpoolCatalogIds.map((id) => (
+              <option key={id} value={id}>{catalogMap[id]?.name || `#${id}`}</option>
+            ))}
+          </select>
+        )}
+
         {/* Clear filters */}
         {hasActiveFilters && (
           <>
@@ -1303,6 +1356,7 @@ function InventoryPage() {
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           visibleColumns={visibleColumns}
                           assignmentMap={assignmentMap}
+                          catalogMap={catalogMap}
                           currencySymbol={currencySymbol}
                           dateFormat={dateFormat}
                           t={t}
@@ -1325,6 +1379,7 @@ function InventoryPage() {
                         onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
                         visibleColumns={visibleColumns}
                         assignmentMap={assignmentMap}
+                        catalogMap={catalogMap}
                         currencySymbol={currencySymbol}
                         dateFormat={dateFormat}
                         t={t}
@@ -1605,7 +1660,7 @@ function SpoolCard({
 /* Single spool row for table view */
 function SpoolTableRow({
   spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
-  visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
+  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spool: InventorySpool;
   remaining: number;
@@ -1616,6 +1671,7 @@ function SpoolTableRow({
   onDelete: () => void;
   visibleColumns: string[];
   assignmentMap: Record<number, SpoolAssignment>;
+  catalogMap: Record<number, SpoolCatalogEntry>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
@@ -1630,7 +1686,7 @@ function SpoolTableRow({
     >
       {visibleColumns.map((colId) => (
         <td key={colId} className="py-3 px-4">
-          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
+          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
         </td>
       ))}
       <td className="py-3 px-4">
@@ -1660,7 +1716,7 @@ function SpoolTableRow({
 function SpoolTableGroup({
   spools, representative, remaining, pct, isExpanded, onToggle,
   onEdit, onArchive, onDelete,
-  visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
+  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spools: InventorySpool[];
   representative: InventorySpool;
@@ -1673,6 +1729,7 @@ function SpoolTableGroup({
   onDelete: (id: number) => void;
   visibleColumns: string[];
   assignmentMap: Record<number, SpoolAssignment>;
+  catalogMap: Record<number, SpoolCatalogEntry>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
@@ -1690,14 +1747,14 @@ function SpoolTableGroup({
             {idx === 0 ? (
               <div className="flex items-center gap-2">
                 <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
-                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
+                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
               </div>
             ) : colId === 'id' ? (
               <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
                 {t('inventory.groupedSpools', { count: spools.length })}
               </span>
             ) : (
-              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })
+              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
             )}
           </td>
         ))}
@@ -1723,6 +1780,7 @@ function SpoolTableGroup({
             onDelete={() => onDelete(spool.id)}
             visibleColumns={visibleColumns}
             assignmentMap={assignmentMap}
+            catalogMap={catalogMap}
             currencySymbol={currencySymbol}
             dateFormat={dateFormat}
             t={t}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CHMyQdwG.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff