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
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Added
- **Inventory: Storage Location filter chip (#1400, reported by @pgladel)** — Reporter manages a lot of physical filament storage locations and wanted a quick way to narrow the inventory list to "what's in shelf A" / "what's in drawer 1" without typing a search query each time. Inventory page grows a new filter chip alongside the existing Material / Brand / Category / Spool Name dropdowns. Distinct storage-location values are pulled from the spool list and rendered as options; selecting one filters the table to spools assigned to that location. An additional **No location set** entry appears when at least one spool has an empty `storage_location`, so users can find unfiled spools the same way `categoryNone` works for unfiled categories. The chip self-hides when no spool has a storage location set (avoids noise on fresh installs). Pattern is identical to the existing Category chip from #729 — clear-all-filters and `hasActiveFilters` both include the new state. **Whitespace normalisation:** distinct-value extraction and filter comparison both `.trim()` the field so a spool whose location was saved as `"Shelf A "` doesn't render as a separate dropdown option from `"Shelf A"`. **i18n:** reuses the existing `inventory.storageLocation` label (already shipped for the spool-edit field — no duplication); adds a new `inventory.storageLocationNone` key, translated to all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). The "Extended Solution" from the issue (dashboard widget showing locations) is not in this change — open to revisiting if there's appetite. Parity check holds at 4818 leaves per locale. 24 InventoryPage tests in the existing suite still pass.
- **Smart plugs: auto-off after AMS drying completes (#1349, reported by @Kyobinoyo)** — Reporter asked for the equivalent of the existing print-finish auto-off, but triggered when an AMS drying cycle ends — so the smart plug that powers the printer + AMS combo cuts power once humidity has been driven out, without the user babysitting it. Shipped as a simple per-plug pair of fields that mirrors the existing print-finish auto-off shape. Per-AMS plug routing (separate plug for the AMS only, per-AMS targeting on dual-AMS printers) was scoped out for now — Bambuddy's plug model is plug→printer, not plug→AMS, so the trigger fires whenever any AMS attached to the linked printer finishes a dry cycle. **Two new SmartPlug columns** with a same-migration block in `database.py` (SQLite uses `BOOLEAN DEFAULT 0` / `INTEGER DEFAULT 10`; Postgres branches to `DEFAULT false` / `IF NOT EXISTS`): `auto_off_after_drying BOOLEAN` (defaults False so nobody opts in by accident); `off_delay_after_drying_minutes INTEGER` (defaults 10 — separate from the print-finish delay because the AMS chamber is hot post-cycle and users often want longer cooldown than the print-finish default of 5). **Trigger** is observed at the MQTT layer, not the scheduler — `BambuMQTTClient` now keeps a per-AMS `_previous_dry_times: dict[int, int]` and, every time `_handle_ams_data` finalises the merged AMS list, walks each unit looking for the `dry_time > 0 → 0` falling edge. When it fires, the new `on_drying_complete(ams_id)` callback runs, plumbed through `PrinterManager.set_drying_complete_callback` exactly the way `on_print_start` / `on_print_complete` already are. The seed-from-zero false positive (first MQTT push reports `dry_time=0` and the previous would otherwise read as 0→0) is guarded by the explicit `previous > 0` check, and the per-AMS state means dual-AMS printers can finish drying on AMS 0 and AMS 1 independently without the second one missing the edge. Observing the falling edge at the MQTT layer (rather than in `print_scheduler._sync_drying_state`) is deliberate: the scheduler's `_drying_in_progress` dict only tracks auto-drying initiated by the scheduler itself, so manually-triggered drying from the printer card would not fire there. The new path catches queue-triggered, ambient, AND manual drying identically because it observes firmware-reported state, not our own intent. **Manager hook** in `SmartPlugManager.on_drying_complete(printer_id, db)` mirrors `on_print_complete` but reads the drying-specific toggle, calls `_schedule_delayed_off` with `off_delay_after_drying_minutes` (always time-based — temperature-cooldown is meaningful for the printer hotend, not the AMS chamber, and Bambuddy doesn't track AMS chamber temperature). The HA-script guard from the print-finish path is preserved (scripts can be triggered but not turned off, so they're skipped). **Frontend** adds a single toggle + delay input on the Smart Plug card next to the existing "Auto Off" section: "Auto Off After Drying" and "Drying delay (minutes)". No changes to the Add Smart Plug modal beyond what the new fields require. **Backend tests** in `test_smart_plug_manager.py` cover the new shape: drying auto-off schedules with the correct per-plug delay; the toggle being off is a no-op even when `auto_off` (print-finish) is on; the master `enabled` flag still gates; HA script entities are skipped; printer with no linked plugs is a silent no-op. `test_bambu_mqtt.py` gets a new `TestDryingCompleteCallback` class covering the falling-edge firing once, the seed-from-zero non-fire guard, repeated zero-pushes after the edge not refiring, per-AMS independent tracking on dual-AMS units, and the "new cycle after completion refires" case (covers the user starting a second dry from the printer card). 4961 backend tests green; SQLite + Postgres 16 migration verified idempotent. **i18n**: 3 new keys (`autoOffAfterDrying`, `autoOffAfterDryingDescription`, `delayAfterDryingMinutes`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4817 leaves per locale.
### Changed
+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 && (
<>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<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">
</head>
<body>