mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 05:31:31 +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:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user