import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react'; import { api } from '../api/client'; import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types'; import { defaultFormData, validateForm } from './spool-form/types'; import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils'; import { MATERIALS } from './spool-form/constants'; import { FilamentSection } from './spool-form/FilamentSection'; import { ColorSection } from './spool-form/ColorSection'; import { AdditionalSection } from './spool-form/AdditionalSection'; import { PAProfileSection } from './spool-form/PAProfileSection'; import { SpoolUsageHistory } from './SpoolUsageHistory'; type TabId = 'filament' | 'pa-profile'; interface SpoolFormModalProps { isOpen: boolean; onClose: () => void; spool?: InventorySpool | null; printersWithCalibrations?: PrinterWithCalibrations[]; currencySymbol: string; onSpoolsCreated?: (spools: InventorySpool[]) => void; } export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol, onSpoolsCreated, }: SpoolFormModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const isEditing = !!spool; // Form state const [formData, setFormData] = useState(defaultFormData); const [errors, setErrors] = useState>>({}); const [activeTab, setActiveTab] = useState('filament'); const [weightTouched, setWeightTouched] = useState(false); const [quickAdd, setQuickAdd] = useState(false); const [quantity, setQuantity] = useState(1); // Cloud presets const [cloudAuthenticated, setCloudAuthenticated] = useState(false); const [loadingCloudPresets, setLoadingCloudPresets] = useState(false); const [cloudPresets, setCloudPresets] = useState([]); const [presetInputValue, setPresetInputValue] = useState(''); // Spool catalog const [spoolCatalog, setSpoolCatalog] = useState([]); // Local presets (OrcaSlicer imports) const [localPresets, setLocalPresets] = useState([]); // Color catalog const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]); // Color state const [recentColors, setRecentColors] = useState([]); // PA Profile state const [fetchedCalibrations, setFetchedCalibrations] = useState([]); const [selectedProfiles, setSelectedProfiles] = useState>(new Set()); const [expandedPrinters, setExpandedPrinters] = useState>(new Set()); // Use prop if provided, otherwise use self-fetched data const resolvedCalibrations = printersWithCalibrations.length > 0 ? printersWithCalibrations : fetchedCalibrations; // Count selected PA profiles for tab badge const selectedProfileCount = useMemo(() => { return selectedProfiles.size; }, [selectedProfiles]); // Load recent colors on mount useEffect(() => { setRecentColors(loadRecentColors()); }, []); // Fetch cloud presets and catalog when modal opens useEffect(() => { if (isOpen) { const fetchData = async () => { setLoadingCloudPresets(true); try { const status = await api.getCloudStatus(); setCloudAuthenticated(status.is_authenticated); if (status.is_authenticated) { const presets = await api.getFilamentPresets(); setCloudPresets(presets); } } catch (e) { console.error('Failed to fetch cloud presets:', e); setCloudAuthenticated(false); } finally { setLoadingCloudPresets(false); } }; fetchData(); api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error); api.getColorCatalog().then(setColorCatalog).catch(console.error); api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error); // Fetch printer calibrations if not provided via props if (printersWithCalibrations.length === 0) { (async () => { try { const printers = await api.getPrinters(); const statuses = await Promise.all( printers.map(p => api.getPrinterStatus(p.id).catch(() => null)), ); const results: PrinterWithCalibrations[] = []; for (let i = 0; i < printers.length; i++) { const printer = printers[i]; const status = statuses[i]; const connected = status?.connected ?? false; let calibrations: PrinterWithCalibrations['calibrations'] = []; if (connected) { try { const kRes = await api.getKProfiles(printer.id); calibrations = kRes.profiles.map(p => ({ cali_idx: p.slot_id, filament_id: p.filament_id, setting_id: p.setting_id || '', name: p.name, k_value: parseFloat(p.k_value) || 0, n_coef: parseFloat(p.n_coef) || 0, extruder_id: p.extruder_id, nozzle_diameter: p.nozzle_diameter, })); } catch { // Printer may not support K-profiles } } results.push({ printer: { ...printer, connected }, calibrations }); } setFetchedCalibrations(results); } catch (e) { console.error('Failed to fetch printer calibrations:', e); } })(); } } }, [isOpen, printersWithCalibrations.length]); // Build filament options: cloud → local → fallback const filamentOptions = useMemo( () => buildFilamentOptions(cloudPresets, new Set(), localPresets), [cloudPresets, localPresets], ); // Extract brands from presets const baseAvailableBrands = useMemo(() => { const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets); const catalogBrands = colorCatalog .map(entry => entry.manufacturer?.trim()) .filter((brand): brand is string => !!brand); const brandSet = new Set([...presetBrands, ...catalogBrands]); return Array.from(brandSet).sort((a, b) => a.localeCompare(b)); }, [cloudPresets, localPresets, colorCatalog]); const baseAvailableMaterials = useMemo(() => { const catalogMaterials = colorCatalog .map(entry => entry.material?.trim()) .filter((material): material is string => !!material); const materialSet = new Set([...MATERIALS, ...catalogMaterials]); return Array.from(materialSet).sort((a, b) => a.localeCompare(b)); }, [colorCatalog]); const brandMaterialPairs = useMemo(() => { const pairs: Array<{ brand: string; material: string }> = []; for (const entry of colorCatalog) { const brand = entry.manufacturer?.trim(); const material = entry.material?.trim(); if (brand && material) pairs.push({ brand, material }); } for (const preset of cloudPresets) { const parsed = parsePresetName(preset.name); if (parsed.brand && parsed.material) { pairs.push({ brand: parsed.brand, material: parsed.material }); } } for (const preset of localPresets) { const parsed = parsePresetName(preset.name); const brand = preset.filament_vendor?.trim() || parsed.brand; const material = parsed.material; if (brand && material) { pairs.push({ brand, material }); } } return pairs; }, [cloudPresets, colorCatalog, localPresets]); const brandToMaterials = useMemo(() => { const map = new Map>(); for (const pair of brandMaterialPairs) { const brandKey = pair.brand.toLowerCase(); const materialKey = pair.material.toLowerCase(); if (!map.has(brandKey)) map.set(brandKey, new Set()); map.get(brandKey)!.add(materialKey); } return map; }, [brandMaterialPairs]); const materialToBrands = useMemo(() => { const map = new Map>(); for (const pair of brandMaterialPairs) { const brandKey = pair.brand.toLowerCase(); const materialKey = pair.material.toLowerCase(); if (!map.has(materialKey)) map.set(materialKey, new Set()); map.get(materialKey)!.add(brandKey); } return map; }, [brandMaterialPairs]); const availableBrands = useMemo(() => { if (!formData.material) return baseAvailableBrands; const materialKey = formData.material.toLowerCase(); const brandKeys = materialToBrands.get(materialKey); if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands; return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase())); }, [baseAvailableBrands, formData.material, materialToBrands]); const availableMaterials = useMemo(() => { if (!formData.brand) return baseAvailableMaterials; const brandKey = formData.brand.toLowerCase(); const materialKeys = brandToMaterials.get(brandKey); if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials; return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase())); }, [baseAvailableMaterials, formData.brand, brandToMaterials]); // Find selected preset option const selectedPresetOption = useMemo( () => findPresetOption(formData.slicer_filament, filamentOptions), [formData.slicer_filament, filamentOptions], ); // Reset form when modal opens/closes or spool changes useEffect(() => { if (isOpen) { if (spool) { // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF' // from #1055 before the create/update pattern was enforced). The // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so // re-submitting a malformed value would 422 every edit on that spool // — even edits that don't touch color. Normalize on load: any value // that isn't exactly 8 hex chars falls back to the default, so the // user can save unrelated fields (weight, material, note) without // first being forced to fix a color they may not even be aware is // broken. Saving also purges the bad value from the DB. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF'; setFormData({ material: spool.material || '', subtype: spool.subtype || '', brand: spool.brand || '', color_name: spool.color_name || '', rgba: validRgba, 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 || '', cost_per_kg: spool.cost_per_kg ?? null, }); setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || ''); // Load K-profiles for this spool if (spool.k_profiles && spool.k_profiles.length > 0) { const profileKeys = new Set(); for (const p of spool.k_profiles) { if (p.cali_idx !== null && p.cali_idx !== undefined) { profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`); } } setSelectedProfiles(profileKeys); } else { setSelectedProfiles(new Set()); } } else { setFormData(defaultFormData); setPresetInputValue(''); setSelectedProfiles(new Set()); setQuickAdd(false); setQuantity(1); } setErrors({}); setActiveTab('filament'); setWeightTouched(false); } }, [isOpen, spool]); // Expand all printers in PA profile section when calibrations are available useEffect(() => { if (isOpen && resolvedCalibrations.length > 0) { setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id)))); } }, [isOpen, resolvedCalibrations]); // Update field helper const updateField = (key: K, value: SpoolFormData[K]) => { setFormData(prev => ({ ...prev, [key]: value })); if (key === 'weight_used') setWeightTouched(true); if (errors[key]) { setErrors(prev => ({ ...prev, [key]: undefined })); } }; // Handle color selection const handleColorUsed = (color: ColorPreset) => { setRecentColors(prev => saveRecentColor(color, prev)); }; // Mutations const createMutation = useMutation({ mutationFn: (data: Record) => api.createSpool(data as Parameters[0]), onSuccess: async (newSpool) => { // Save K-profiles if any selected if (selectedProfiles.size > 0 && newSpool?.id) { await saveKProfiles(newSpool.id); } await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); if (onSpoolsCreated) onSpoolsCreated([newSpool]); showToast(t('inventory.spoolCreated'), 'success'); onClose(); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const bulkCreateMutation = useMutation({ mutationFn: ({ data, qty }: { data: Record; qty: number }) => api.bulkCreateSpools(data as Parameters[0], qty), onSuccess: async (newSpools) => { if (selectedProfiles.size > 0) { for (const spool of newSpools) { await saveKProfiles(spool.id); } } await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); if (onSpoolsCreated) onSpoolsCreated(newSpools); showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success'); onClose(); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const updateMutation = useMutation({ mutationFn: (data: Record) => api.updateSpool(spool!.id, data as Parameters[1]), onSuccess: async () => { // Save K-profiles if (spool?.id) { await saveKProfiles(spool.id); } await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); showToast(t('inventory.spoolUpdated'), 'success'); onClose(); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const deleteTagMutation = useMutation({ mutationFn: () => api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters[1]), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); showToast(t('inventory.tagDeleted', 'Tag removed'), 'success'); onClose(); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); // Fetch assignment for this spool (to show Unassign button) const { data: assignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), enabled: isOpen && isEditing, }); const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined; const unassignMutation = useMutation({ mutationFn: () => { if (!spoolAssignment) throw new Error('No assignment'); return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success'); onClose(); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); // Save K-profiles for selected calibrations const saveKProfiles = async (spoolId: number) => { if (selectedProfiles.size === 0) { // Clear existing K-profiles try { await api.saveSpoolKProfiles(spoolId, []); } catch { // Ignore } return; } const profiles = []; for (const key of selectedProfiles) { const [printerIdStr, caliIdxStr, extruderStr] = key.split(':'); const printerId = parseInt(printerIdStr); const caliIdx = parseInt(caliIdxStr); const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr); // Find the matching calibration const pc = resolvedCalibrations.find(p => p.printer.id === printerId); if (pc) { const cal = pc.calibrations.find(c => c.cali_idx === caliIdx); if (cal) { profiles.push({ printer_id: printerId, extruder, nozzle_diameter: cal.nozzle_diameter || '0.4', k_value: cal.k_value, name: cal.name || null, cali_idx: cal.cali_idx, setting_id: cal.setting_id || null, }); } } } if (profiles.length > 0) { try { await api.saveSpoolKProfiles(spoolId, profiles); } catch (e) { console.error('Failed to save K-profiles:', e); } } }; // Close on Escape key useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); if (!isOpen) return null; const handleSubmit = () => { const validation = validateForm(formData, quickAdd); if (!validation.isValid) { setErrors(validation.errors); // Switch to filament tab if there are errors there if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) { setActiveTab('filament'); } return; } // Find preset name from selected option const presetName = selectedPresetOption?.displayName || presetInputValue || null; const data: Record = { material: formData.material, subtype: formData.subtype || null, brand: formData.brand || null, color_name: formData.color_name || null, 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, nozzle_temp_max: null, note: formData.note || null, cost_per_kg: formData.cost_per_kg, }; // Only send weight_used when creating or when explicitly changed by the user. // This prevents stale cached values from overwriting usage-tracker data. if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; } if (isEditing) { updateMutation.mutate(data); } else if (quantity > 1) { bulkCreateMutation.mutate({ data, qty: quantity }); } else { createMutation.mutate(data); } }; const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending; return (
{/* Header */}

{isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}

{/* Quick Add toggle — only in create mode */} {!isEditing && (
{t('inventory.quickAdd')}
)} {/* Tabs */}
{!quickAdd && ( )}
{/* Content */}
{activeTab === 'filament' ? (
{/* Filament Info Section */}

{t('inventory.filamentInfo')}

{/* Color Section */}

{t('inventory.color')}

{/* Additional Section */}

{t('inventory.additional')}

{/* Usage History (only when editing) */} {isEditing && spool && (
)}
) : ( )}
{/* Footer */}
{isEditing && (
)}
); }