Add customizable low stock threshold, add low stock filter (#531)

* feat(queue): show spool grams left in filament slot mapping

* Bumped version

* Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo

* Fix SpoolBuddy daemon crash when read_tag module is missing

NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.

* Fix SpoolBuddy daemon failing to import hardware drivers

The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.

* Increase scale moving average window to reduce weight bouncing

5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.

* Remove stability flipping as scale report trigger

When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.

* Fix formatting of option elements in FilamentMapping

* Make low stock threshold editable

* Add new filter for low spools

* Update bug report template to require additional fields

* Added toast for invalid imputs with locales, updated inputb field restrictions

* Minor Spoolbuddy frontend improvements

* Updated test_backend.sh

* Updated Spoolbuddy install script to strip down Raspbian

* Updated Spoolbuddy install script

* Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)

* SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display

Enlarge all interactive elements across 9 SpoolBuddy components to meet
44px minimum tap targets on the RPi touchscreen. Increase nav icons
(20→24px), labels (10→12px), bar heights, section headers, printer
buttons, spool visualizations, fill bars, and status indicators.
Compact the dashboard stats bar and remove the printers card. Add
fullScreen prop to ConfigureAmsSlotModal with two-column layout
(filament list left, K-profile + color right) to eliminate scrolling.

* Minor changes, CSS fixes

* Refactor usageFilter state to remove 'lowstock' option for clarity

* Move var saving to API, add test coverage

* fix: threshold validation and cleanup

* Change test input from '150' to '0'

---------

Co-authored-by: tridev <c.tripod@gmx.ch>
Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
Keybored
2026-03-03 10:04:13 +01:00
committed by GitHub
co-authored by tridev MartinNYHC
parent 9cdda80849
commit 97f6250a0b
12 changed files with 531 additions and 12 deletions
+1
View File
@@ -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 [
+9
View File
@@ -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)
@@ -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):
@@ -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<typeof mockSettings>;
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(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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<typeof mockSettings> | null = null;
server.use(
http.put('/api/v1/settings/', async ({ request }) => {
const body = (await request.json()) as Partial<typeof mockSettings>;
updatedSettings = body;
return HttpResponse.json({ ...mockSettings, ...body });
})
);
render(<InventoryPageRouter />);
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<typeof mockSettings> | null = null;
server.use(
http.put('/api/v1/settings/', async ({ request }) => {
const body = (await request.json()) as Partial<typeof mockSettings>;
updatedSettings = body;
return HttpResponse.json({ ...mockSettings, ...body });
})
);
render(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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(<InventoryPageRouter />);
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
});
});
});
+2
View File
@@ -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<AppSettings>;
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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',
+1 -1
View File
@@ -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: 'ダウンロード',
+1 -1
View File
@@ -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
+100 -7
View File
@@ -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() {
<span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
</div>
<div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
<div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
<div className="text-xs text-bambu-gray mt-1 flex items-center gap-2">
{showThresholdInput ? (
<form
onSubmit={e => {
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"
>
<span className="text-xs text-bambu-gray">{'<'}</span>
<input
type="text"
inputMode="decimal"
pattern="^\d{0,2}(\.\d?)?$"
maxLength={4}
value={thresholdInput}
onChange={e => {
// 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}
/>
<span className="text-xs text-bambu-gray">%</span>
<Button type="submit" size="sm" disabled={updateThresholdMutation.isPending}>{t('common.save')}</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowThresholdInput(false)} disabled={updateThresholdMutation.isPending}>{t('common.cancel')}</Button>
</form>
) : (
<>
<span className="text-bambu-gray">{'< '}{lowStockThreshold}%</span>
<button
className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
title={t('common.edit')}
onClick={() => {
setThresholdInput(lowStockThreshold.toString());
setShowThresholdInput(true);
}}
>
<Edit2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
)}
@@ -960,6 +1042,17 @@ function InventoryPage() {
>
{t('inventory.new')}
</button>
<button
onClick={() => { setUsageFilter('lowstock'); resetPage(); }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
usageFilter === 'lowstock'
? 'bg-yellow-500/20 text-yellow-400'
: 'text-bambu-gray hover:bg-bambu-dark-tertiary'
}`}
>
<AlertTriangle className="w-3.5 h-3.5" />
{t('inventory.lowStock')}
</button>
</div>
{/* Stock filter chips */}
@@ -1541,7 +1634,7 @@ function SpoolTableRow({
))}
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
<button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
<Edit2 className="w-4 h-4" />
</button>
{spool.archived_at ? (