Files
bambuddy/frontend/src/components/SpoolFormModal.tsx
T
maziggy f4f76e0121 fix(inventory): allow editing and duplicating stock spools without a slicer preset (#1905)
A spool created by Quick Add, a CSV import or an RFID scan has no slicer
preset, brand or subtype. Reopening it in Edit Spool demanded all three
before anything could be saved, so changing its storage location, cost
or notes was impossible - and Copy Spool had the same gate with no Quick
Add toggle to waive it. The preset you were then forced to pick auto-
filled material, brand and subtype from the preset name, silently
rewriting a hand-entered manufacturer (Elegoo -> Generic) so the spool
no longer appeared where it had been filed.

Editing and copying now require only what the backend requires: the
material. Preset, brand and subtype stay fully visible and editable -
nothing is hidden the way Quick Add hides it - and the required-field
markers no longer advertise a rule that isn't enforced. Selecting a
preset fills only fields that are still empty or that a previously
selected preset had filled, so values the user (or the saved spool)
provided survive; switching between presets still replaces what the
earlier one contributed.

The brand and material dropdowns also no longer filter themselves down
to the brand/material pairs known to the color catalog and slicer
presets. Elegoo is catalogued only for PLA, which made a real product
like Elegoo ASA look impossible to enter. Both lists now always offer
everything known, with paired entries ranked first under Suggested and
the rest under All, and a spool's own custom brand or material is always
present in its own dropdown. The SpoolBuddy write-tag form shares these
fields and gets the same treatment.

Lastly the Quick Add layout no longer leaks out of create mode: quick-
adding a spool and then opening Edit left the edit form in the reduced
layout with no toggle to leave it, because the toggle is create-only.

Frontend only. Translated in all locales; wiki updated. Covered by
validation and form-interaction tests.
2026-07-28 13:09:16 +02:00

1041 lines
42 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, SpoolFormMode } from './spool-form/types';
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } 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';
import {
invalidateInventoryLocations,
invalidateSpoolAndLocationQueries,
} from '../utils/inventoryQueries';
type TabId = 'filament' | 'pa-profile';
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
export type { SpoolFormMode };
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 refreshSpoolQueries = () =>
invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
const isEditing = mode === 'edit';
const isCopying = mode === 'copy';
// 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 [locationIdTouched, setLocationIdTouched] = 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[]>([]);
const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
// 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;
// #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<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. Fetches Bambu Cloud
// and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
// since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
// (same fields, same semantics). ``cloudAuthenticated`` flips on if either
// cloud is connected — the UI only uses it to gate "no cloud" hints.
useEffect(() => {
// ``cancelled`` gates every state setter so a fetch that resolves AFTER
// the modal closes / unmounts can't fire setState on a torn-down
// component. Without this guard the parallel Promise.allSettled chain
// can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
// after vitest has dismantled the JSDOM window — surfaced as an
// "Unhandled Rejection: window is not defined" in CI runs.
let cancelled = false;
if (isOpen) {
const fetchData = async () => {
setLoadingCloudPresets(true);
try {
const [bambuResult, orcaResult] = await Promise.allSettled([
(async () => {
const status = await api.getCloudStatus();
if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
const presets = await api.getFilamentPresets();
return { connected: true, presets };
})(),
(async () => {
const status = await api.orcaCloudStatus();
if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
const list = await api.orcaCloudListProfiles();
// OrcaProfileMeta is structurally identical to SlicerSetting.
return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
})(),
]);
if (cancelled) return;
const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
setCloudAuthenticated(bambuConnected || orcaConnected);
setCloudPresets([...bambuPresets, ...orcaPresets]);
} catch (e) {
if (cancelled) return;
console.error('Failed to fetch cloud presets:', e);
setCloudAuthenticated(false);
} finally {
if (!cancelled) 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);
api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).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) {
// Fetch across every installed nozzle so dual-nozzle printers
// surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
calibrations = await fetchPrinterCalibrations(printer.id, status);
}
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.
return () => {
cancelled = true;
};
// 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]);
// #1905: the brand and material dropdowns used to be filtered down to the
// pairs seen in the color catalog / slicer presets, which hid perfectly valid
// combinations — "Elegoo" exists (as a PLA brand) but vanished from the list
// once ASA was selected, making the entry look impossible. Both lists now
// always offer everything we know about, plus whatever the spool already has
// stored (a custom brand saved earlier was missing from its own dropdown).
// The pairing knowledge survives as `suggestedBrands`/`suggestedMaterials`,
// which the dropdowns sort to the top instead of filtering by.
const availableBrands = useMemo(
() => withCurrentValue(baseAvailableBrands, formData.brand),
[baseAvailableBrands, formData.brand],
);
const availableMaterials = useMemo(
() => withCurrentValue(baseAvailableMaterials, formData.material),
[baseAvailableMaterials, formData.material],
);
const suggestedBrands = useMemo(
() => pairedOptions(availableBrands, formData.material, materialToBrands),
[availableBrands, formData.material, materialToBrands],
);
const suggestedMaterials = useMemo(
() => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
[availableMaterials, 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,
location_id: spool.location_id ?? null,
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());
}
// Reset on every open, not just the create path (#1905). The modal keeps
// its state while closed, and the Quick Add toggle only renders in create
// mode — so quick-adding a spool and then opening Edit left the edit form
// stuck in quick-add layout (no preset field, no PA-profile tab) with no
// control to switch back.
setQuickAdd(false);
setQuantity(1);
setErrors({});
setActiveTab('filament');
setWeightTouched(false);
setLocationIdTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
useEffect(() => {
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
const legacy = spool.storage_location?.trim();
if (!legacy || storageLocations.length === 0) return;
const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
if (match) {
setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
}
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
// 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 === 'location_id') setLocationIdTouched(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 refreshSpoolQueries();
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 refreshSpoolQueries();
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 refreshSpoolQueries();
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 refreshSpoolQueries();
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<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: 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<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, mode);
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 location_id when creating or when explicitly changed by the user.
// Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
if (!isEditing || locationIdTouched) {
data.location_id = formData.location_id;
}
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 flex items-baseline gap-2">
{isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
{isEditing && spool && (
<span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
)}
</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 (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' && (
<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-600 dark: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-700 dark: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}
suggestedBrands={suggestedBrands}
suggestedMaterials={suggestedMaterials}
quickAdd={quickAdd}
detailsRequired={!quickAdd && !spoolmanMode && mode === 'create'}
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}
availableLocations={storageLocations}
onCreateLocation={async (name) => {
try {
const created = await api.createLocation({ name });
setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
await invalidateInventoryLocations(queryClient);
return { id: created.id, name: created.name };
} catch (e) {
// Surface the backend's actual error so the user can
// distinguish 409 duplicate / 400 validation / 500 from
// a generic "save failed" message.
console.error(e);
const message = e instanceof Error ? e.message : t('locations.saveFailed');
showToast(message || t('locations.saveFailed'), 'error');
return null;
}
}}
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') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}