diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index fa32038d2..51b337abf 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -105,6 +105,7 @@ async def get_settings( "ams_temp_good", "ams_temp_fair", "library_disk_warning_gb", + "low_stock_threshold", ]: settings_dict[setting.key] = float(setting.value) elif setting.key in [ diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index a3d66784e..12ba45229 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -149,6 +149,14 @@ class AppSettings(BaseModel): default="", description="Bearer token for Prometheus metrics authentication (optional)" ) + # Inventory low stock threshold + low_stock_threshold: float = Field( + default=20.0, + ge=0.1, + le=99.9, + description="Low stock threshold percentage (%) for inventory filtering and display", + ) + class AppSettingsUpdate(BaseModel): """Schema for updating settings (all fields optional).""" @@ -210,3 +218,4 @@ class AppSettingsUpdate(BaseModel): preferred_slicer: str | None = None prometheus_enabled: bool | None = None prometheus_token: str | None = None + low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9) diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index c2fec9fcb..598d98081 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/backend/tests/integration/test_settings_api.py @@ -166,6 +166,27 @@ class TestSettingsAPI: assert result["ams_temp_good"] == 25.0 assert result["ams_temp_fair"] == 32.0 + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_low_stock_threshold(self, async_client: AsyncClient): + """Verify low stock threshold setting can be updated.""" + # Get default value + response = await async_client.get("/api/v1/settings/") + assert response.status_code == 200 + assert response.json()["low_stock_threshold"] == 20.0 + + # Update to custom value + response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5}) + + assert response.status_code == 200 + result = response.json() + assert result["low_stock_threshold"] == 15.5 + + # Verify persistence + response = await async_client.get("/api/v1/settings/") + assert response.status_code == 200 + assert response.json()["low_stock_threshold"] == 15.5 + @pytest.mark.asyncio @pytest.mark.integration async def test_update_notification_language(self, async_client: AsyncClient): diff --git a/frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx b/frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx new file mode 100644 index 000000000..52bf0409f --- /dev/null +++ b/frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx @@ -0,0 +1,392 @@ +/** + * Tests for low stock threshold functionality in InventoryPage. + * + * Tests that the low stock threshold: + * - Is loaded from backend settings API + * - Can be updated via the UI + * - Persists changes to the backend + * - Does not use localStorage + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import InventoryPageRouter from '../../pages/InventoryPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const mockSettings = { + auto_archive: true, + save_thumbnails: true, + capture_finish_photo: true, + default_filament_cost: 25.0, + currency: 'USD', + energy_cost_per_kwh: 0.15, + energy_tracking_mode: 'total', + spoolman_enabled: false, + spoolman_url: '', + spoolman_sync_mode: 'auto', + spoolman_disable_weight_sync: false, + spoolman_report_partial_usage: true, + check_updates: true, + check_printer_firmware: true, + include_beta_updates: false, + language: 'en', + notification_language: 'en', + bed_cooled_threshold: 35, + ams_humidity_good: 40, + ams_humidity_fair: 60, + ams_temp_good: 28, + ams_temp_fair: 35, + ams_history_retention_days: 30, + per_printer_mapping_expanded: false, + date_format: 'system', + time_format: 'system', + default_printer_id: null, + virtual_printer_enabled: false, + virtual_printer_access_code: '', + virtual_printer_mode: 'immediate', + dark_style: 'classic', + dark_background: 'neutral', + dark_accent: 'green', + light_style: 'classic', + light_background: 'neutral', + light_accent: 'green', + ftp_retry_enabled: true, + ftp_retry_count: 3, + ftp_retry_delay: 2, + ftp_timeout: 30, + mqtt_enabled: false, + mqtt_broker: '', + mqtt_port: 1883, + mqtt_username: '', + mqtt_password: '', + mqtt_topic_prefix: 'bambuddy', + mqtt_use_tls: false, + external_url: '', + ha_enabled: false, + ha_url: '', + ha_token: '', + ha_url_from_env: false, + ha_token_from_env: false, + ha_env_managed: false, + library_archive_mode: 'ask', + library_disk_warning_gb: 5.0, + camera_view_mode: 'window', + preferred_slicer: 'bambu_studio', + prometheus_enabled: false, + prometheus_token: '', + low_stock_threshold: 20.0, +}; + +const mockSpools = [ + { + id: 1, + material: 'PLA', + subtype: null, + brand: 'Polymaker', + color_name: 'Red', + rgba: 'FF0000FF', + label_weight: 1000, + core_weight: 250, + weight_used: 900, // 10% remaining - low stock + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + }, + { + id: 2, + material: 'PETG', + subtype: null, + brand: 'eSun', + color_name: 'Blue', + rgba: '0000FFFF', + label_weight: 1000, + core_weight: 250, + weight_used: 200, // 80% remaining - not low stock + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-02T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + }, + { + id: 3, + material: 'ABS', + subtype: null, + brand: 'Hatchbox', + color_name: 'Black', + rgba: '000000FF', + label_weight: 1000, + core_weight: 250, + weight_used: 850, // 15% remaining - low stock + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-03T00:00:00Z', + updated_at: '2025-01-03T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + }, +]; + +describe('InventoryPage - Low Stock Threshold', () => { + beforeEach(() => { + // Clear localStorage to ensure we're not relying on it + localStorage.clear(); + + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json(mockSettings); + }), + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Partial; + return HttpResponse.json({ ...mockSettings, ...body }); + }), + http.get('/api/v1/spools/', () => { + return HttpResponse.json(mockSpools); + }), + http.get('/api/v1/spool-assignments/', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/spoolman/settings', () => { + return HttpResponse.json({ spoolman_enabled: 'false' }); + }) + ); + }); + + describe('default threshold from backend', () => { + it('loads the default threshold of 20% from backend settings', async () => { + render(); + + await waitFor(() => { + // Find the low stock stat showing the threshold + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + }); + + it('calculates low stock count based on default threshold', async () => { + render(); + + await waitFor(() => { + // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools) + const lowStockSection = screen.getByText(/low stock/i).closest('div'); + expect(lowStockSection).toBeInTheDocument(); + }); + }); + + it('does not use localStorage for threshold', async () => { + // Set a value in localStorage that should be ignored + localStorage.setItem('bambuddy-low-stock-threshold', '50'); + + render(); + + await waitFor(() => { + // Should show backend value (20%), not localStorage value (50%) + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + }); + }); + + describe('updating threshold via UI', () => { + it('shows edit button for threshold', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + + const editButton = screen.getByTitle(/edit/i); + expect(editButton).toBeInTheDocument(); + + await user.click(editButton); + + // Input field should appear + await waitFor(() => { + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('20'); + }); + }); + + it('updates threshold and persists to backend', async () => { + const user = userEvent.setup(); + let updatedSettings: Partial | null = null; + + server.use( + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Partial; + updatedSettings = body; + return HttpResponse.json({ ...mockSettings, ...body }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + + // Click edit button + const editButton = screen.getByTitle(/edit/i); + await user.click(editButton); + + // Enter new value + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, '15.5'); + + // Submit form + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); + + // Verify API was called with correct value + await waitFor(() => { + expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 }); + }); + }); + + it('validates threshold input range', async () => { + const user = userEvent.setup(); + let updatedSettings: Partial | null = null; + + server.use( + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Partial; + updatedSettings = body; + return HttpResponse.json({ ...mockSettings, ...body }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + + // Click edit button + const editButton = screen.getByTitle(/edit/i); + await user.click(editButton); + + // Try invalid values + const input = screen.getByRole('textbox'); + + // Too high + await user.clear(input); + await user.type(input, '0'); + + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); + + // Should show error and NOT call the PUT endpoint + await waitFor(() => { + expect(updatedSettings).toBeNull(); + }); + }); + + it('allows canceling threshold edit', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + + // Click edit button + const editButton = screen.getByTitle(/edit/i); + await user.click(editButton); + + // Change value + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, '30'); + + // Cancel + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + // Should revert to original display + await waitFor(() => { + expect(screen.getByText(/< 20%/i)).toBeInTheDocument(); + }); + }); + }); + + describe('custom threshold from backend', () => { + it('loads custom threshold value from backend', async () => { + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/< 25%/i)).toBeInTheDocument(); + }); + }); + + it('applies custom threshold to low stock filtering', async () => { + // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%) + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/< 30%/i)).toBeInTheDocument(); + }); + + // The low stock count should reflect the new threshold + // Implementation would show appropriate count based on 30% threshold + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c7e1b51ed..89e73c67e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -837,6 +837,8 @@ export interface AppSettings { prometheus_token: string; // Bed cooled threshold bed_cooled_threshold: number; + // Inventory low stock threshold + low_stock_threshold: number; } export type AppSettingsUpdate = Partial; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 10ebed2c8..664b09443 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2691,7 +2691,6 @@ export default { sinceTracking: 'Seit Beginn der Erfassung', loadedInAms: 'Im AMS/Ext geladen', remaining: 'Verbleibend', - lowStockThreshold: '<20% verbleibend', weightCheck: 'Gewichtskontrolle', lastWeighed: 'Zuletzt gewogen', neverWeighed: 'Nie gewogen', @@ -2744,6 +2743,7 @@ export default { clearHistory: 'Löschen', historyCleared: 'Verbrauchshistorie gelöscht', fillSourceLabel: '(Inv)', + lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen', }, // Timelapse diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2b69235c8..69c4cc398 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2695,7 +2695,6 @@ export default { sinceTracking: 'Since tracking started', loadedInAms: 'Loaded in AMS/Ext', remaining: 'Remaining', - lowStockThreshold: '<20% remaining', weightCheck: 'Weight Check', lastWeighed: 'Last weighed', neverWeighed: 'Never weighed', @@ -2748,6 +2747,7 @@ export default { clearHistory: 'Clear', historyCleared: 'Usage history cleared', fillSourceLabel: '(Inv)', + lowStockThresholdError: 'Threshold must be between 0.1 and 99.9', }, // Timelapse diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 3ad569ef9..3c735f58e 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2683,7 +2683,6 @@ export default { sinceTracking: 'Depuis le début du suivi', loadedInAms: 'Chargé dans AMS/Ext', remaining: 'Restant', - lowStockThreshold: '<20% restant', weightCheck: 'Vérification poids', lastWeighed: 'Dernière pesée', neverWeighed: 'Jamais pesé', @@ -2732,6 +2731,7 @@ export default { clearHistory: 'Effacer', historyCleared: 'Historique effacé', fillSourceLabel: '(Inv)', + lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9', }, // Timelapse diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 415d9a8d3..c283dd097 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2454,6 +2454,7 @@ export default { spoolRestored: 'Bobina ripristinata', deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.', advancedSettings: 'Impostazioni Avanzate', + lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9', weightCheck: 'Controllo Peso', lastWeighed: 'Ultima pesatura', neverWeighed: 'Mai pesato', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 65948fe05..64b0f9193 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2617,7 +2617,6 @@ export default { sinceTracking: '追跡開始以降', loadedInAms: 'AMS/Extに装填中', remaining: '残り', - lowStockThreshold: '残り20%未満', weightCheck: '重量チェック', lastWeighed: '最終計量', neverWeighed: '未計量', @@ -2668,6 +2667,7 @@ export default { clearHistory: 'クリア', historyCleared: '使用履歴がクリアされました', fillSourceLabel: '(Inv)', + lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません', }, timelapse: { download: 'ダウンロード', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index a93df339b..d6adb05bb 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2695,7 +2695,6 @@ export default { sinceTracking: 'Desde o início do rastreamento', loadedInAms: 'Carregado no AMS/Ext', remaining: 'Restante', - lowStockThreshold: '<20% restante', weightCheck: 'Verificação de Peso', lastWeighed: 'Última pesagem', neverWeighed: 'Nunca pesado', @@ -2744,6 +2743,7 @@ export default { clearHistory: 'Limpar', historyCleared: 'Histórico de uso limpo', fillSourceLabel: '(Inv)', + lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9', }, // Timelapse diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 9ec0e5b8c..fb8b32447 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, type ReactNode } from 'react'; +import { useState, useMemo, useEffect, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -20,7 +20,7 @@ import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date'; import { formatSlotLabel } from '../utils/amsHelpers'; type ArchiveFilter = 'active' | 'archived'; -type UsageFilter = 'all' | 'used' | 'new'; +type UsageFilter = 'all' | 'used' | 'new' | 'lowstock'; type ViewMode = 'table' | 'cards'; type SortDirection = 'asc' | 'desc'; type SortState = { column: string; direction: SortDirection } | null; @@ -513,6 +513,30 @@ function InventoryPage() { } }; + // Low stock threshold from backend settings + const lowStockThreshold = settings?.low_stock_threshold ?? 20; + const [showThresholdInput, setShowThresholdInput] = useState(false); + const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString()); + + // Sync thresholdInput when lowStockThreshold changes and input is not shown + useEffect(() => { + if (!showThresholdInput) { + setThresholdInput(lowStockThreshold.toString()); + } + }, [lowStockThreshold, showThresholdInput]); + + const updateThresholdMutation = useMutation({ + mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }); + showToast(t('common.saved'), 'success'); + setShowThresholdInput(false); + }, + onError: () => { + showToast(t('inventory.lowStockThresholdError'), 'error'); + }, + }); + // Stats calculation (active spools only) const stats = useMemo(() => { if (!spools) return null; @@ -528,14 +552,14 @@ function InventoryPage() { totalWeight += remaining; totalConsumed += s.weight_used; const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0; - if (pct < 20) lowStock++; + if (pct < lowStockThreshold) lowStock++; const mat = s.material || 'Unknown'; if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 }; byMaterial[mat].count++; byMaterial[mat].weight += remaining; } return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount }; - }, [spools]); + }, [spools, lowStockThreshold]); const inPrinterCount = assignments?.length ?? 0; @@ -574,6 +598,12 @@ function InventoryPage() { filtered = filtered.filter((s) => s.weight_used > 0); } else if (usageFilter === 'new') { filtered = filtered.filter((s) => s.weight_used === 0); + } else if (usageFilter === 'lowstock') { + filtered = filtered.filter((s) => { + const remaining = Math.max(0, s.label_weight - s.weight_used); + const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0; + return pct < lowStockThreshold; + }); } // Material dropdown @@ -607,7 +637,7 @@ function InventoryPage() { } return filtered; - }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]); + }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search, lowStockThreshold]); // Reset page on filter changes const resetPage = () => setPageIndex(0); @@ -818,7 +848,59 @@ function InventoryPage() { {t('inventory.lowStock')}
0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}
-
{t('inventory.lowStockThreshold')}
+
+ {showThresholdInput ? ( +
{ + e.preventDefault(); + const val = parseFloat(thresholdInput); + if (!isNaN(val) && val >= 0.1 && val <= 99.9) { + updateThresholdMutation.mutate(val); + } else { + showToast(t('inventory.lowStockThresholdError'), 'error'); + } + }} + className="flex items-center gap-2" + > + {'<'} + { + // Only allow up to 2 digits before decimal and 1 after + const val = e.target.value.replace(/[^\d.]/g, ''); + if (/^\d{0,2}(\.\d?)?$/.test(val)) { + setThresholdInput(val); + } + }} + className="px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center" + onWheel={e => e.currentTarget.blur()} + disabled={updateThresholdMutation.isPending} + /> + + % + + +
+ ) : ( + <> + {'< '}{lowStockThreshold}% + + + )} +
)} @@ -960,6 +1042,17 @@ function InventoryPage() { > {t('inventory.new')} + {/* Stock filter chips */} @@ -1541,7 +1634,7 @@ function SpoolTableRow({ ))}
e.stopPropagation()}> - {spool.archived_at ? (