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.
This commit is contained in:
maziggy
2026-05-17 12:34:46 +02:00
parent 6f2cec5eb3
commit 4ccde42e39
12 changed files with 138 additions and 82 deletions
+1
View File
@@ -3572,6 +3572,7 @@ export default {
category: 'Kategorie',
categoryPlaceholder: 'z. B. Produktion, Prototyp, Kunde A',
categoryNone: 'Ohne Kategorie',
storageLocationNone: 'Kein Lagerort',
lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
// RFID button rename (was "Tag löschen")
+5
View File
@@ -3575,6 +3575,11 @@ export default {
category: 'Category',
categoryPlaceholder: 'e.g. Production, Prototype, Client A',
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)',
lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
+1
View File
@@ -3561,6 +3561,7 @@ export default {
category: 'Catégorie',
categoryPlaceholder: 'ex. Production, Prototype, Client A',
categoryNone: 'Sans catégorie',
storageLocationNone: 'Aucun emplacement défini',
lowStockThresholdOverride: 'Seuil bas (cette bobine)',
lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
clearRfid: 'Effacer le tag RFID',
+1
View File
@@ -3560,6 +3560,7 @@ export default {
category: 'Categoria',
categoryPlaceholder: 'es. Produzione, Prototipo, Cliente A',
categoryNone: 'Senza categoria',
storageLocationNone: 'Nessuna posizione impostata',
lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
clearRfid: 'Cancella tag RFID',
+1
View File
@@ -3572,6 +3572,7 @@ export default {
category: 'カテゴリ',
categoryPlaceholder: '例:本番、試作、クライアントA',
categoryNone: 'カテゴリなし',
storageLocationNone: '保管場所未設定',
lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
clearRfid: 'RFIDタグをクリア',
+1
View File
@@ -3560,6 +3560,7 @@ export default {
category: 'Categoria',
categoryPlaceholder: 'ex. Produção, Protótipo, Cliente A',
categoryNone: 'Sem categoria',
storageLocationNone: 'Sem local definido',
lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
clearRfid: 'Limpar tag RFID',
+1
View File
@@ -3560,6 +3560,7 @@ export default {
category: '类别',
categoryPlaceholder: '例如:生产、原型、客户A',
categoryNone: '未分类',
storageLocationNone: '未设置位置',
lowStockThresholdOverride: '低库存阈值(此料盘)',
lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
clearRfid: '清除 RFID 标签',
+1
View File
@@ -3560,6 +3560,7 @@ export default {
category: '類別',
categoryPlaceholder: '例如:生產、原型、客戶A',
categoryNone: '未分類',
storageLocationNone: '未設定位置',
lowStockThresholdOverride: '低庫存閾值(此料盤)',
lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
clearRfid: '清除 RFID 標籤',
+44 -2
View File
@@ -477,6 +477,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [categoryFilter, setCategoryFilter] = useState('');
const [spoolFilter, setSpoolFilter] = useState('');
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 [viewMode, setViewMode] = useState<ViewMode>('table');
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);
}
// 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
if (stockFilter === 'stock') {
filtered = filtered.filter((s) => !s.slicer_filament);
@@ -872,7 +886,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
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
const resetPage = () => setPageIndex(0);
@@ -887,9 +901,13 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const nameB = (catalogMap[b]?.name || '').toLowerCase();
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
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[]) => {
setColumnConfig(config);
@@ -1012,6 +1030,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
setBrandFilter('');
setCategoryFilter('');
setSpoolFilter('');
setStorageLocationFilter('');
setStockFilter('all');
setSearch('');
resetPage();
@@ -1440,6 +1459,29 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
</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 */}
{hasActiveFilters && (
<>