diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 33e2e37a6..9467dcfed 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1185,6 +1185,12 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER")) + except OperationalError: + pass # Already applied + # Migration: Create spool_usage_history table for filament consumption tracking try: await conn.execute( diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index 893e4db66..cd353d624 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -19,6 +19,9 @@ class Spool(Base): brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker" label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g) core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g) + core_weight_catalog_id: Mapped[int | None] = mapped_column( + Integer + ) # Reference to spool_catalog entry for core weight weight_used: Mapped[float] = mapped_column(Float, default=0) # Consumed grams slicer_filament: Mapped[str | None] = mapped_column(String(50)) # Preset ID (e.g. "GFL99") slicer_filament_name: Mapped[str | None] = mapped_column(String(100)) # Preset name for slicer diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 6e86af4c6..bfcfb9c61 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -11,6 +11,7 @@ class SpoolBase(BaseModel): brand: str | None = None label_weight: int = 1000 core_weight: int = 250 + core_weight_catalog_id: int | None = None weight_used: float = 0 slicer_filament: str | None = None slicer_filament_name: str | None = None @@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel): brand: str | None = None label_weight: int | None = None core_weight: int | None = None + core_weight_catalog_id: int | None = None weight_used: float | None = None slicer_filament: str | None = None slicer_filament_name: str | None = None diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index a13e49b64..821f1ac2e 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -62,6 +62,7 @@ const existingSpool: InventorySpool = { rgba: 'FF0000FF', label_weight: 1000, core_weight: 250, + core_weight_catalog_id: null, weight_used: 300, slicer_filament: 'GFL99', slicer_filament_name: 'Generic PLA', @@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => { // weight_used MUST be included for new spools (default value 0) expect(payload).toHaveProperty('weight_used', 0); }); + + it('preserves core_weight_catalog_id when editing other fields', async () => { + const spoolWithCatalogId: InventorySpool = { + ...existingSpool, + core_weight_catalog_id: 5, + }; + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Edit Spool')).toBeInTheDocument(); + }); + + // Change the note field (unrelated to catalog ID) + const noteInputs = screen.getAllByPlaceholderText(/note/i); + expect(noteInputs.length).toBeGreaterThan(0); + fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } }); + + // Click Save + const saveButton = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(api.updateSpool).toHaveBeenCalledTimes(1); + }); + + const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0]; + expect(spoolId).toBe(1); + // core_weight_catalog_id MUST be preserved when editing other fields + expect(payload).toHaveProperty('core_weight_catalog_id', 5); + // Other changes should also be present + expect(payload).toHaveProperty('note', 'Updated note'); + }); + + it('includes core_weight_catalog_id when selecting from catalog', async () => { + const mockCatalog = [ + { id: 1, name: 'Generic 250g', weight: 250 }, + { id: 2, name: 'Bambu Lab 250g', weight: 250 }, + { id: 3, name: 'Standard 300g', weight: 300 }, + ]; + + vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); + }); + + // Wait for catalog to load + await waitFor(() => { + expect(api.getSpoolCatalog).toHaveBeenCalled(); + }); + + // Click on the empty spool weight field to open dropdown + const weightInputs = screen.getAllByPlaceholderText(/search/i); + const weightPicker = weightInputs.find(input => + input.getAttribute('placeholder')?.toLowerCase().includes('spool') + ); + expect(weightPicker).toBeTruthy(); + fireEvent.focus(weightPicker!); + + // Click on "Bambu Lab 250g" option + const bambuOption = await screen.findByText('Bambu Lab 250g'); + fireEvent.click(bambuOption); + + // Click the add spool button + const addButtons = screen.getAllByRole('button', { name: /add spool/i }); + const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save')); + expect(submitButton).toBeTruthy(); + fireEvent.click(submitButton!); + + await waitFor(() => { + expect(api.createSpool).toHaveBeenCalledTimes(1); + }); + + const [payload] = vi.mocked(api.createSpool).mock.calls[0]; + // Both weight AND catalog ID should be sent + expect(payload).toHaveProperty('core_weight', 250); + expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g" + }); + + it('displays correct catalog name when duplicates exist', async () => { + const spoolWithCatalogId: InventorySpool = { + ...existingSpool, + core_weight: 250, + core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match + }; + + const mockCatalog = [ + { id: 1, name: 'Generic 250g', weight: 250 }, + { id: 2, name: 'Bambu Lab 250g', weight: 250 }, + { id: 3, name: 'Standard 300g', weight: 300 }, + ]; + + vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Edit Spool')).toBeInTheDocument(); + }); + + // Wait for catalog to load + await waitFor(() => { + expect(api.getSpoolCatalog).toHaveBeenCalled(); + }); + + // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight) + await waitFor(() => { + const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i); + const bambuFound = weightInputs.some(input => + input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g' + ); + expect(bambuFound).toBeTruthy(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 49625a8d1..9c95a260a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1771,6 +1771,7 @@ export interface InventorySpool { brand: string | null; label_weight: number; core_weight: number; + core_weight_catalog_id: number | null; weight_used: number; slicer_filament: string | null; slicer_filament_name: string | null; diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 5b2a8e2eb..11988349c 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration rgba: spool.rgba || '808080FF', label_weight: spool.label_weight || 1000, core_weight: spool.core_weight || 250, + core_weight_catalog_id: spool.core_weight_catalog_id ?? null, weight_used: spool.weight_used || 0, slicer_filament: spool.slicer_filament || '', note: spool.note || '', @@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration rgba: formData.rgba || null, label_weight: formData.label_weight, core_weight: formData.core_weight, + core_weight_catalog_id: formData.core_weight_catalog_id, slicer_filament: formData.slicer_filament || null, slicer_filament_name: presetName, nozzle_temp_min: null, diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index 2921479c3..44acdd7c0 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -8,15 +8,18 @@ function SpoolWeightPicker({ catalog, value, onChange, + catalogId, + onCatalogIdChange, }: { catalog: { id: number; name: string; weight: number }[]; value: number; onChange: (weight: number) => void; + catalogId: number | null; + onCatalogIdChange: (id: number | null) => void; }) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(''); - const [selectedId, setSelectedId] = useState(null); const dropdownRef = useRef(null); const inputRef = useRef(null); @@ -30,6 +33,35 @@ function SpoolWeightPicker({ return () => document.removeEventListener('mousedown', handleClick); }, []); + // When value changes, auto-select if there's only one matching entry or keep selection if it still matches + useEffect(() => { + // If no catalog loaded yet, skip matching logic + if (catalog.length === 0) { + return; + } + + const matches = catalog.filter(e => e.weight === value); + + // If currently selected entry still matches the weight, keep it selected + if (catalogId) { + const selected = catalog.find(e => e.id === catalogId); + if (selected && selected.weight === value) { + return; // Keep current selection + } + } + + // If exactly one match, auto-select it + if (matches.length === 1) { + onCatalogIdChange(matches[0].id); + } else if (matches.length === 0) { + // No matches, clear selection to prevent stale catalog ID + if (catalogId !== null) { + onCatalogIdChange(null); + } + } + // If multiple matches, don't auto-select - let user choose + }, [value, catalog, catalogId, onCatalogIdChange]); + const filtered = useMemo(() => { if (!search) return catalog; const s = search.toLowerCase(); @@ -39,17 +71,29 @@ function SpoolWeightPicker({ ); }, [catalog, search]); - // Display value: show catalog name if selected, or the weight + // Find all entries matching the current weight + const matchingEntries = useMemo(() => { + return catalog.filter(e => e.weight === value); + }, [catalog, value]); + + // Display value: show catalog name if selected by ID, otherwise show first match const displayValue = useMemo(() => { if (isOpen) return search; - if (selectedId) { - const entry = catalog.find(e => e.id === selectedId); + + // If a catalog ID is explicitly selected, use that + if (catalogId) { + const entry = catalog.find(e => e.id === catalogId); if (entry) return entry.name; } - const match = catalog.find(e => e.weight === value); - if (match) return match.name; + + // Otherwise, show the first matching entry as a suggestion + if (matchingEntries.length > 0) { + return matchingEntries[0].name; + } + + // Leave empty if there are no matches return ''; - }, [isOpen, search, selectedId, catalog, value]); + }, [isOpen, search, catalogId, catalog, matchingEntries]); return (
@@ -86,12 +130,12 @@ function SpoolWeightPicker({ key={entry.id} type="button" className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${ - (selectedId ? entry.id === selectedId : entry.weight === value) + (catalogId ? entry.id === catalogId : entry.weight === value) ? 'bg-bambu-green/10 text-bambu-green' : 'text-white' }`} onClick={() => { - setSelectedId(entry.id); + onCatalogIdChange(entry.id); onChange(entry.weight); setIsOpen(false); setSearch(''); @@ -150,6 +194,8 @@ export function AdditionalSection({ catalog={spoolCatalog} value={formData.core_weight} onChange={(weight) => updateField('core_weight', weight)} + catalogId={formData.core_weight_catalog_id} + onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)} /> {/* Current Weight (remaining filament) */} diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts index b9f791e88..131ca444b 100644 --- a/frontend/src/components/spool-form/types.ts +++ b/frontend/src/components/spool-form/types.ts @@ -9,6 +9,7 @@ export interface SpoolFormData { rgba: string; label_weight: number; core_weight: number; + core_weight_catalog_id: number | null; weight_used: number; slicer_filament: string; note: string; @@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = { rgba: '808080FF', label_weight: 1000, core_weight: 250, + core_weight_catalog_id: null, weight_used: 0, slicer_filament: '', note: '',