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, ApiError } from '../api/client'; import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types'; import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } 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 { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker'; import { PAProfileSection } from './spool-form/PAProfileSection'; import { SpoolUsageHistory } from './SpoolUsageHistory'; type TabId = 'filament' | 'pa-profile'; const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null }; export type SpoolFormMode = 'create' | 'edit' | 'copy'; interface SpoolFormModalProps { isOpen: boolean; onClose: () => void; spool?: InventorySpool | null; mode: SpoolFormMode; printersWithCalibrations?: PrinterWithCalibrations[]; currencySymbol: string; onSpoolsCreated?: (spools: InventorySpool[]) => void; /** When true, CRUD operations target the Spoolman inventory proxy endpoints. */ spoolmanMode?: boolean; /** Query key to invalidate after mutations (differs for Spoolman vs local). */ spoolsQueryKey?: string[]; } export function SpoolFormModal({ isOpen, onClose, spool, mode, printersWithCalibrations = [], currencySymbol, onSpoolsCreated, spoolmanMode = false, spoolsQueryKey = ['inventory-spools'], }: SpoolFormModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const isEditing = mode === 'edit'; const isCopying = mode === 'copy'; // Form state const [formData, setFormData] = useState(defaultFormData); const [errors, setErrors] = useState>>({}); const [activeTab, setActiveTab] = useState('filament'); const [weightTouched, setWeightTouched] = useState(false); const [storageLocationTouched, setStorageLocationTouched] = 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([]); // Built-in filaments (static fallback) const [builtinFilaments, setBuiltinFilaments] = useState([]); // Color catalog const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null; // #1340: gradient + effect carried from the catalog entry through to the // color picker so they're applied alongside hex + name on selection. extra_colors?: string | null; effect_type?: 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 = selectedProfiles.size; // Fetch Spoolman filament catalog when in Spoolman mode // retry:false — Spoolman may be intentionally disabled (400); don't flood the server const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery({ queryKey: ['spoolman-inventory-filaments'], queryFn: () => api.getSpoolmanInventoryFilaments(), enabled: spoolmanMode && isOpen, staleTime: 60_000, retry: false, }); // 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(); if (!spoolmanMode) { api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error); } api.getColorCatalog().then(setColorCatalog).catch(console.error); api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error); api.getBuiltinFilaments().then(setBuiltinFilaments).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); } })(); } } // The effect intentionally depends only on `isOpen` (and the prop-side // calibration count) — re-running on every spoolmanMode toggle would // race the in-flight async fetches with unmount/teardown and emit // "test environment was torn down" errors in vitest. spoolmanMode only // gates a single fetch (getSpoolCatalog) which is cheap enough to skip // when the modal opens in Spoolman mode. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, printersWithCalibrations.length]); // Build filament options: cloud → local → fallback const filamentOptions = useMemo( () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments), [cloudPresets, localPresets, builtinFilaments], ); // 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 || '', // #1319: leave color_name blank when the backend reports it was // synthesised from subtype — otherwise the form would round-trip // the synth value to Spoolman on save as if the user had set it, // which is what produced the "color reverts to subtype" symptom. color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''), rgba: validRgba, extra_colors: spool.extra_colors || '', effect_type: spool.effect_type || '', label_weight: spool.label_weight || 1000, core_weight: spool.core_weight || 250, core_weight_catalog_id: spool.core_weight_catalog_id ?? null, weight_used: isCopying ? 0 : spool.weight_used || 0, slicer_filament: spool.slicer_filament || '', note: spool.note || '', cost_per_kg: spool.cost_per_kg ?? null, category: spool.category || '', low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null, storage_location: spool.storage_location || '', spoolman_filament_id: 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); setStorageLocationTouched(false); } }, [isOpen, spool, mode, isCopying]); // 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]) => { const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key); if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) { showToast(t('inventory.spoolmanFilamentUnlinked'), 'info'); } setFormData(prev => ({ ...prev, [key]: value, ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null ? { spoolman_filament_id: null } : {}), })); if (key === 'weight_used') setWeightTouched(true); if (key === 'storage_location') setStorageLocationTouched(true); if (errors[key]) { setErrors(prev => ({ ...prev, [key]: undefined })); } }; // Prefill form from a Spoolman filament catalog entry // subtype extraction mirrors _spoolman_helpers.py logic const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => { const material = filament.material || ''; const name = filament.name || ''; const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name; const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase(); // Guard against short/malformed hex values — must be exactly 6 hex chars const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080'; setFormData(prev => ({ ...prev, spoolman_filament_id: filament.id, material, subtype, brand: filament.vendor?.name || '', rgba: `${colorHex}FF`, color_name: filament.color_name || '', label_weight: filament.weight ?? prev.label_weight, })); showToast(t('inventory.spoolmanFilamentSelected'), 'success'); }; // Handle color selection const handleColorUsed = (color: ColorPreset) => { setRecentColors(prev => saveRecentColor(color, prev)); }; // Mutations – dispatch to Spoolman proxy or local inventory based on mode const createMutation = useMutation({ mutationFn: (data: Record) => spoolmanMode ? api.createSpoolmanInventorySpool(data as Parameters[0]) : api.createSpool(data as Parameters[0]), onSuccess: async (newSpool) => { if (newSpool?.id) { const ok = await saveKProfiles(newSpool.id); if (!ok) return; } await queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); if (onSpoolsCreated) onSpoolsCreated([newSpool]); showToast(t('inventory.spoolCreated'), 'success'); onClose(); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.saveFailed'), 'error'); } }, }); const bulkCreateMutation = useMutation< SpoolmanBulkCreateResult | InventorySpool[], Error, { data: Record; qty: number } >({ mutationFn: ({ data, qty }) => spoolmanMode ? api.bulkCreateSpoolmanInventorySpools(data as Parameters[0], qty) : api.bulkCreateSpools(data as Parameters[0], qty), onSuccess: async (result) => { // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[]. // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by // the duck-type check ('created' in result) before any property access. const spoolmanResult = (spoolmanMode && 'created' in result) ? (result as unknown as SpoolmanBulkCreateResult) : null; const createdSpools: InventorySpool[] = spoolmanResult ? spoolmanResult.created : (result as InventorySpool[]); if (selectedProfiles.size > 0) { for (const s of createdSpools) { await saveKProfiles(s.id); } } await queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); if (onSpoolsCreated) onSpoolsCreated(createdSpools); if (spoolmanResult && spoolmanResult.failed_count > 0) { showToast( t('inventory.spoolsPartiallyCreated', { created: createdSpools.length, total: spoolmanResult.requested_count, }), 'warning', ); } else { showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success'); } onClose(); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.saveFailed'), 'error'); } }, }); const updateMutation = useMutation({ mutationFn: (data: Record) => spoolmanMode ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters[1]) : api.updateSpool(spool!.id, data as Parameters[1]), onSuccess: async () => { if (spool?.id) { const ok = await saveKProfiles(spool.id); if (!ok) return; } await queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.spoolUpdated'), 'success'); onClose(); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.saveFailed'), 'error'); } }, }); const deleteTagMutation = useMutation({ mutationFn: () => { if (spoolmanMode) { return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters[1]); } return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters[1]); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success'); onClose(); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.tagClearFailed'), 'error'); } }, }); // Fetch assignment for this spool (to show Unassign button). In Spoolman mode // the slot assignment lives in the spoolman_slot_assignments table keyed by // spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the // resulting "Unassign button is always disabled" report. const { data: assignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), enabled: isOpen && isEditing && !spoolmanMode, }); const { data: spoolmanSlotAssignments } = useQuery({ queryKey: ['spoolman-slot-assignments-all'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: isOpen && isEditing && spoolmanMode, }); const spoolAssignment = (() => { if (!spool) return undefined; if (spoolmanMode) { return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id); } return assignments?.find(a => a.spool_id === spool.id); })(); // Read inventory + settings caches (already populated by InventoryPage) to // drive the category autocomplete and low-stock-threshold placeholder. #729 const { data: allSpools } = useQuery({ queryKey: ['inventory-spools'], queryFn: () => api.getSpools(true), enabled: isOpen, }); const { data: settingsForForm } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: isOpen, }); const availableCategories = (() => { const set = new Set(); for (const s of allSpools ?? []) { const c = s.category?.trim(); if (c) set.add(c); } return Array.from(set).sort((a, b) => a.localeCompare(b)); })(); const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20; const unassignMutation = useMutation({ mutationFn: async () => { if (!spoolAssignment) throw new Error('No assignment'); if (spoolmanMode) { if (!spool) throw new Error('No spool'); await api.unassignSpoolmanSlot(spool.id); return; } await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id); }, onSuccess: async () => { if (spoolmanMode) { await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] }); await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); } else { 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. Returns false if any error occurred. const saveKProfiles = async (spoolId: number): Promise => { const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles; if (selectedProfiles.size === 0) { try { await saveApi(spoolId, []); return true; } catch (e) { console.error('Failed to save K-profiles:', e); showToast(t('inventory.kProfileSaveFailed'), 'warning'); return false; } } const profiles: SpoolKProfileInput[] = []; let dropped = 0; 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); 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, }); } else { dropped++; } } else { dropped++; } } if (dropped > 0) { console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles)); showToast(t('inventory.kProfileSaveFailed'), 'warning'); return false; } if (profiles.length > 0) { try { await saveApi(spoolId, profiles); return true; } catch (e) { console.error('Failed to save K-profiles:', e); showToast(t('inventory.kProfileSaveFailed'), 'warning'); return false; } } return true; }; // 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, spoolmanMode); if (!validation.isValid) { setErrors(validation.errors); 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 || null, subtype: formData.subtype || null, brand: formData.brand || null, color_name: formData.color_name || null, rgba: formData.rgba || null, extra_colors: formData.extra_colors || null, effect_type: formData.effect_type || null, label_weight: formData.label_weight, ...(spoolmanMode ? {} : { 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, category: formData.category.trim() || null, low_stock_threshold_pct: formData.low_stock_threshold_pct, ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}), }; // 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; } // Only send storage_location when creating or when explicitly changed by the user. // This prevents the modal round-trip from overwriting the Spoolman location field // with a stale cached value when the user saves without touching this field. if (!isEditing || storageLocationTouched) { data.storage_location = formData.storage_location || null; } 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') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}

{/* Quick Add toggle — only in create mode (not edit, not copy). In copy mode the modal title is the singular "Copy Spool", so the quantity-driven bulkCreateMutation path would silently produce N copies under a misleading title — keep this toggle out of that mode entirely. */} {mode === 'create' && (
{t('inventory.quickAdd')}
)} {/* Tabs */}
{!quickAdd && ( )}
{/* Content */}
{activeTab === 'filament' ? (
{/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */} {spoolmanMode && !isEditing && (
{filamentsError ? (

{t('inventory.spoolmanCatalogLoadFailed')}

) : ( )}
)} {/* Filament Info Section */}

{t('inventory.filamentInfo')}

{/* Color Section */}

{t('inventory.color')}

{/* Additional Section */}

{t('inventory.additional')}

{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */} {isEditing && spool && !spoolmanMode && (
)}
) : ( )}
{/* Footer */}
{isEditing && (
)}
); }