mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3572,6 +3572,7 @@ export default {
|
||||
category: 'カテゴリ',
|
||||
categoryPlaceholder: '例:本番、試作、クライアントA',
|
||||
categoryNone: 'カテゴリなし',
|
||||
storageLocationNone: '保管場所未設定',
|
||||
lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
|
||||
lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
|
||||
clearRfid: 'RFIDタグをクリア',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3560,6 +3560,7 @@ export default {
|
||||
category: '类别',
|
||||
categoryPlaceholder: '例如:生产、原型、客户A',
|
||||
categoryNone: '未分类',
|
||||
storageLocationNone: '未设置位置',
|
||||
lowStockThresholdOverride: '低库存阈值(此料盘)',
|
||||
lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
|
||||
clearRfid: '清除 RFID 标签',
|
||||
|
||||
@@ -3560,6 +3560,7 @@ export default {
|
||||
category: '類別',
|
||||
categoryPlaceholder: '例如:生產、原型、客戶A',
|
||||
categoryNone: '未分類',
|
||||
storageLocationNone: '未設定位置',
|
||||
lowStockThresholdOverride: '低庫存閾值(此料盤)',
|
||||
lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
|
||||
clearRfid: '清除 RFID 標籤',
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user