Browse Source

feat(inventory): storage location filter chip (#1400)

  Reporter pgladel manages multiple physical filament storage
  locations and wanted to narrow the inventory list to a specific
  location without typing a search query each time.

  Adds a new Storage Location dropdown chip on the inventory page,
  next to the existing Material / Brand / Category / Spool Name
  filters. Distinct values are pulled from the spool list with
  .trim() so accidental trailing whitespace doesn't render as a
  separate option. A "No location set" entry appears when at least
  one spool has an empty storage_location (mirrors the categoryNone
  group). Chip self-hides when no spool has a storage location set.

  Same shape as the Category chip from #729 — clear-all-filters and
  hasActiveFilters both include the new state.

  i18n: reuses existing inventory.storageLocation label, adds
  inventory.storageLocationNone in all 8 locales. Parity check
  holds at 4818 leaves per locale. 24 InventoryPage tests still
  pass, frontend build clean.
maziggy 1 week ago
parent
commit
4ccde42e39

File diff suppressed because it is too large
+ 2 - 0
CHANGELOG.md


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

@@ -3572,6 +3572,7 @@ export default {
     category: 'Kategorie',
     category: 'Kategorie',
     categoryPlaceholder: 'z. B. Produktion, Prototyp, Kunde A',
     categoryPlaceholder: 'z. B. Produktion, Prototyp, Kunde A',
     categoryNone: 'Ohne Kategorie',
     categoryNone: 'Ohne Kategorie',
+    storageLocationNone: 'Kein Lagerort',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
     lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
     // RFID button rename (was "Tag löschen")
     // RFID button rename (was "Tag löschen")

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

@@ -3575,6 +3575,11 @@ export default {
     category: 'Category',
     category: 'Category',
     categoryPlaceholder: 'e.g. Production, Prototype, Client A',
     categoryPlaceholder: 'e.g. Production, Prototype, Client A',
     categoryNone: 'Uncategorized',
     categoryNone: 'Uncategorized',
+    // #1400: storage-location filter chip — `storageLocation` label is
+    // already defined above for the spool-edit field, reused here for the
+    // dropdown header. `storageLocationNone` is new (the "no location set"
+    // group, mirrors `categoryNone`).
+    storageLocationNone: 'No location set',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
     lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a

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

@@ -3561,6 +3561,7 @@ export default {
     category: 'Catégorie',
     category: 'Catégorie',
     categoryPlaceholder: 'ex. Production, Prototype, Client A',
     categoryPlaceholder: 'ex. Production, Prototype, Client A',
     categoryNone: 'Sans catégorie',
     categoryNone: 'Sans catégorie',
+    storageLocationNone: 'Aucun emplacement défini',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
     lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
     clearRfid: 'Effacer le tag RFID',
     clearRfid: 'Effacer le tag RFID',

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

@@ -3560,6 +3560,7 @@ export default {
     category: 'Categoria',
     category: 'Categoria',
     categoryPlaceholder: 'es. Produzione, Prototipo, Cliente A',
     categoryPlaceholder: 'es. Produzione, Prototipo, Cliente A',
     categoryNone: 'Senza categoria',
     categoryNone: 'Senza categoria',
+    storageLocationNone: 'Nessuna posizione impostata',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
     lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
     clearRfid: 'Cancella tag RFID',
     clearRfid: 'Cancella tag RFID',

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

@@ -3572,6 +3572,7 @@ export default {
     category: 'カテゴリ',
     category: 'カテゴリ',
     categoryPlaceholder: '例:本番、試作、クライアントA',
     categoryPlaceholder: '例:本番、試作、クライアントA',
     categoryNone: 'カテゴリなし',
     categoryNone: 'カテゴリなし',
+    storageLocationNone: '保管場所未設定',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
     lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
     clearRfid: 'RFIDタグをクリア',
     clearRfid: 'RFIDタグをクリア',

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

@@ -3560,6 +3560,7 @@ export default {
     category: 'Categoria',
     category: 'Categoria',
     categoryPlaceholder: 'ex. Produção, Protótipo, Cliente A',
     categoryPlaceholder: 'ex. Produção, Protótipo, Cliente A',
     categoryNone: 'Sem categoria',
     categoryNone: 'Sem categoria',
+    storageLocationNone: 'Sem local definido',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
     lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
     clearRfid: 'Limpar tag RFID',
     clearRfid: 'Limpar tag RFID',

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

@@ -3560,6 +3560,7 @@ export default {
     category: '类别',
     category: '类别',
     categoryPlaceholder: '例如:生产、原型、客户A',
     categoryPlaceholder: '例如:生产、原型、客户A',
     categoryNone: '未分类',
     categoryNone: '未分类',
+    storageLocationNone: '未设置位置',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
     lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
     clearRfid: '清除 RFID 标签',
     clearRfid: '清除 RFID 标签',

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

@@ -3560,6 +3560,7 @@ export default {
     category: '類別',
     category: '類別',
     categoryPlaceholder: '例如:生產、原型、客戶A',
     categoryPlaceholder: '例如:生產、原型、客戶A',
     categoryNone: '未分類',
     categoryNone: '未分類',
+    storageLocationNone: '未設定位置',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
     lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
     clearRfid: '清除 RFID 標籤',
     clearRfid: '清除 RFID 標籤',

+ 44 - 2
frontend/src/pages/InventoryPage.tsx

@@ -477,6 +477,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [categoryFilter, setCategoryFilter] = useState('');
   const [categoryFilter, setCategoryFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
+  // #1400: storage-location dropdown. Uses the sentinel `__none__` for the
+  // "no storage location set" group, same pattern as the category filter so
+  // users can find unfiled spools.
+  const [storageLocationFilter, setStorageLocationFilter] = useState('');
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
   const [sortState, setSortState] = useState<SortState>(loadSortState);
   const [sortState, setSortState] = useState<SortState>(loadSortState);
@@ -859,6 +863,16 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
       filtered = filtered.filter((s) => s.core_weight_catalog_id === catalogId);
       filtered = filtered.filter((s) => s.core_weight_catalog_id === catalogId);
     }
     }
 
 
+    // Storage location dropdown (#1400). `__none__` lets the user find
+    // spools that haven't been assigned a storage location yet.
+    if (storageLocationFilter) {
+      if (storageLocationFilter === '__none__') {
+        filtered = filtered.filter((s) => !s.storage_location?.trim());
+      } else {
+        filtered = filtered.filter((s) => s.storage_location?.trim() === storageLocationFilter);
+      }
+    }
+
     // Stock filter
     // Stock filter
     if (stockFilter === 'stock') {
     if (stockFilter === 'stock') {
       filtered = filtered.filter((s) => !s.slicer_filament);
       filtered = filtered.filter((s) => !s.slicer_filament);
@@ -872,7 +886,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     }
     }
 
 
     return filtered;
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold]);
 
 
   // Reset page on filter changes
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
   const resetPage = () => setPageIndex(0);
@@ -887,9 +901,13 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     const nameB = (catalogMap[b]?.name || '').toLowerCase();
     const nameB = (catalogMap[b]?.name || '').toLowerCase();
     return nameA.localeCompare(nameB);
     return nameA.localeCompare(nameB);
   });
   });
+  // #1400: storage-location distinct values. `.trim()` so accidental
+  // trailing whitespace doesn't show up as a separate option.
+  const uniqueStorageLocations = [...new Set(spools?.map((s) => s.storage_location?.trim()).filter(Boolean) as string[] || [])].sort();
+  const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.storage_location?.trim());
 
 
   // Check if any filters are non-default
   // Check if any filters are non-default
-  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
 
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
     setColumnConfig(config);
@@ -1012,6 +1030,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     setBrandFilter('');
     setBrandFilter('');
     setCategoryFilter('');
     setCategoryFilter('');
     setSpoolFilter('');
     setSpoolFilter('');
+    setStorageLocationFilter('');
     setStockFilter('all');
     setStockFilter('all');
     setSearch('');
     setSearch('');
     resetPage();
     resetPage();
@@ -1440,6 +1459,29 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           </select>
           </select>
         )}
         )}
 
 
+        {/* Storage location dropdown chip (#1400) — only render when at
+            least one spool carries a storage location, otherwise it's noise
+            (matches the category chip pattern). */}
+        {(uniqueStorageLocations.length > 0 || storageLocationFilter) && (
+          <select
+            value={storageLocationFilter}
+            onChange={(e) => { setStorageLocationFilter(e.target.value); resetPage(); }}
+            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+              storageLocationFilter
+                ? '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.storageLocation')}</option>
+            {uniqueStorageLocations.map((loc) => (
+              <option key={loc} value={loc}>{loc}</option>
+            ))}
+            {hasUnsetStorageLocation && (
+              <option value="__none__">{t('inventory.storageLocationNone')}</option>
+            )}
+          </select>
+        )}
+
         {/* Clear filters */}
         {/* Clear filters */}
         {hasActiveFilters && (
         {hasActiveFilters && (
           <>
           <>

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


+ 1 - 1
static/index.html

@@ -26,7 +26,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-Cy6PHBkY.js"></script>
+    <script type="module" crossorigin src="/assets/index-DuEb_u5w.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   </head>
   <body>
   <body>

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