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/api/client.ts b/frontend/src/api/client.ts index 5b6c2ed6d..7dbad0093 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..9ebd56905 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..82e8d0f5f 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,36 @@ 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 only if we don't have a catalogId + // (to avoid clearing valid IDs when catalog hasn't loaded yet) + if (!catalogId) { + 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 +72,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 +131,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 +195,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: '',