Files
bambuddy/frontend/src/components/SpoolFormModal.tsx
T
maziggy 30fe88a334 fix(inventory): show all per-printer/per-nozzle variants in spool form's Slicer Preset dropdown (issue #1248)
Two defects in buildFilamentOptions, surfaced together:

  1. The function was precedence-based — cloud presets short-circuited
     the local-presets branch, silently hiding any imported Local Profile
     while the user was logged into Bambu Cloud. The wiki documents the
     dropdown as "merged and deduplicated" across cloud + local + built-in.

  2. Cloud default presets and local presets were being collapsed by base
     name (everything after "@" stripped), so all P1S/X1C/A1 variants of
     "Bambu PLA Basic" rendered as a single row. The spool form is
     printer-agnostic by design, so the right semantic is to show every
     variant individually — the union across all printers — not collapse
     them. AMS Slot is per-printer (it filters), the spool form is
     union-of-all (it doesn't).

  Rewrote the merge to push each cloud setting_id and each LocalPreset row
  as its own FilamentOption with the full @printer suffix preserved in
  displayName. Built-in dedup against cloud setting_id is kept (mirrors
  ConfigureAmsSlotModal.tsx). Wired api.getBuiltinFilaments() into both
  callers. slicer_filament persistence is unchanged so existing spools
  keep slicing correctly.
2026-05-09 08:43:51 +02:00

917 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
interface SpoolFormModalProps {
isOpen: boolean;
onClose: () => void;
spool?: InventorySpool | null;
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,
printersWithCalibrations = [],
currencySymbol,
onSpoolsCreated,
spoolmanMode = false,
spoolsQueryKey = ['inventory-spools'],
}: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isEditing = !!spool;
// Form state
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('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<SlicerSetting[]>([]);
const [presetInputValue, setPresetInputValue] = useState('');
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
// Built-in filaments (static fallback)
const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
// Color catalog
const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
// Color state
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
// PA Profile state
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(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<SpoolmanFilamentEntry[], Error>({
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<string>([...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<string>([...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<string, Set<string>>();
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<string, Set<string>>();
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,
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: 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<string>();
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]);
// 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 = <K extends keyof SpoolFormData>(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<string, unknown>) =>
spoolmanMode
? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
: api.createSpool(data as Parameters<typeof api.createSpool>[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<string, unknown>; qty: number }
>({
mutationFn: ({ data, qty }) =>
spoolmanMode
? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
: api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[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<string, unknown>) =>
spoolmanMode
? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[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<typeof api.updateSpoolmanInventorySpool>[1]);
}
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[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)
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;
// 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<string>();
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: () => {
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. Returns false if any error occurred.
const saveKProfiles = async (spoolId: number): Promise<boolean> => {
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<string, unknown> = {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
</h2>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Add toggle — only in create mode */}
{!isEditing && (
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-sm text-white">{t('inventory.quickAdd')}</span>
</div>
<button
type="button"
onClick={() => {
setQuickAdd(!quickAdd);
if (!quickAdd) setActiveTab('filament');
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
quickAdd ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
<button
onClick={() => setActiveTab('filament')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'filament'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Palette className="w-4 h-4" />
{t('inventory.filamentInfoTab')}
</button>
{!quickAdd && (
<button
onClick={() => setActiveTab('pa-profile')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'pa-profile'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Beaker className="w-4 h-4" />
{t('inventory.paProfileTab')}
{selectedProfileCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{selectedProfileCount}
</span>
)}
</button>
)}
</div>
{/* Content */}
<div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
{activeTab === 'filament' ? (
<div className="space-y-6">
{/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
{spoolmanMode && !isEditing && (
<div>
{filamentsError ? (
<p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
) : (
<SpoolmanFilamentPicker
filaments={spoolmanFilaments}
isLoading={isLoadingFilaments}
selectedId={formData.spoolman_filament_id}
onSelect={handleFilamentSelect}
/>
)}
</div>
)}
{/* Filament Info Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.filamentInfo')}
</h3>
<FilamentSection
formData={formData}
updateField={updateField}
cloudAuthenticated={cloudAuthenticated}
loadingCloudPresets={loadingCloudPresets}
presetInputValue={presetInputValue}
setPresetInputValue={setPresetInputValue}
selectedPresetOption={selectedPresetOption}
filamentOptions={filamentOptions}
availableBrands={availableBrands}
availableMaterials={availableMaterials}
quickAdd={quickAdd}
quantity={quantity}
onQuantityChange={setQuantity}
errors={errors}
/>
</div>
{/* Color Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.color')}
</h3>
<ColorSection
formData={formData}
updateField={updateField}
recentColors={recentColors}
onColorUsed={handleColorUsed}
catalogColors={colorCatalog}
/>
</div>
{/* Additional Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.additional')}
</h3>
<AdditionalSection
formData={formData}
updateField={updateField}
spoolCatalog={spoolCatalog}
currencySymbol={currencySymbol}
availableCategories={availableCategories}
globalLowStockThreshold={globalLowStockThreshold}
spoolmanMode={spoolmanMode}
/>
</div>
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
{isEditing && spool && !spoolmanMode && (
<div>
<SpoolUsageHistory spoolId={spool.id} />
</div>
)}
</div>
) : (
<PAProfileSection
formData={formData}
updateField={updateField}
printersWithCalibrations={resolvedCalibrations}
selectedProfiles={selectedProfiles}
setSelectedProfiles={setSelectedProfiles}
expandedPrinters={expandedPrinters}
setExpandedPrinters={setExpandedPrinters}
/>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
{isEditing && (
<div className="flex gap-2 mr-auto">
<Button
variant="secondary"
onClick={() => deleteTagMutation.mutate()}
disabled={isPending || !spool?.tag_uid}
>
<Tag className="w-4 h-4" />
{t('inventory.clearRfid', 'Clear RFID Tag')}
</Button>
<Button
variant="secondary"
onClick={() => unassignMutation.mutate()}
disabled={isPending || !spoolAssignment}
>
<Unlink className="w-4 h-4" />
{t('inventory.unassignSpool', 'Unassign')}
</Button>
</div>
)}
<div className="flex gap-2 ml-auto">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditing ? t('common.save') : t('inventory.addSpool')}
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}