SpoolFormModal.tsx 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043
  1. import { useState, useEffect, useMemo } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
  5. import { api, ApiError } from '../api/client';
  6. import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
  10. import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
  11. import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
  12. import { MATERIALS } from './spool-form/constants';
  13. import { FilamentSection } from './spool-form/FilamentSection';
  14. import { ColorSection } from './spool-form/ColorSection';
  15. import { AdditionalSection } from './spool-form/AdditionalSection';
  16. import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
  17. import { PAProfileSection } from './spool-form/PAProfileSection';
  18. import { SpoolUsageHistory } from './SpoolUsageHistory';
  19. import {
  20. invalidateInventoryLocations,
  21. invalidateSpoolAndLocationQueries,
  22. } from '../utils/inventoryQueries';
  23. type TabId = 'filament' | 'pa-profile';
  24. const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
  25. export type { SpoolFormMode };
  26. interface SpoolFormModalProps {
  27. isOpen: boolean;
  28. onClose: () => void;
  29. spool?: InventorySpool | null;
  30. mode: SpoolFormMode;
  31. printersWithCalibrations?: PrinterWithCalibrations[];
  32. currencySymbol: string;
  33. onSpoolsCreated?: (spools: InventorySpool[]) => void;
  34. /** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
  35. spoolmanMode?: boolean;
  36. /** Query key to invalidate after mutations (differs for Spoolman vs local). */
  37. spoolsQueryKey?: string[];
  38. }
  39. export function SpoolFormModal({
  40. isOpen,
  41. onClose,
  42. spool,
  43. mode,
  44. printersWithCalibrations = [],
  45. currencySymbol,
  46. onSpoolsCreated,
  47. spoolmanMode = false,
  48. spoolsQueryKey = ['inventory-spools'],
  49. }: SpoolFormModalProps) {
  50. const { t } = useTranslation();
  51. const queryClient = useQueryClient();
  52. const { showToast } = useToast();
  53. const refreshSpoolQueries = () =>
  54. invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
  55. const isEditing = mode === 'edit';
  56. const isCopying = mode === 'copy';
  57. // Form state
  58. const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
  59. const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
  60. const [activeTab, setActiveTab] = useState<TabId>('filament');
  61. const [weightTouched, setWeightTouched] = useState(false);
  62. const [locationIdTouched, setLocationIdTouched] = useState(false);
  63. const [quickAdd, setQuickAdd] = useState(false);
  64. const [quantity, setQuantity] = useState(1);
  65. // Cloud presets
  66. const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
  67. const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
  68. const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
  69. const [presetInputValue, setPresetInputValue] = useState('');
  70. // Spool catalog
  71. const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
  72. const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
  73. // Local presets (OrcaSlicer imports)
  74. const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
  75. // Built-in filaments (static fallback)
  76. const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
  77. // Color catalog
  78. const [colorCatalog, setColorCatalog] = useState<{
  79. manufacturer: string;
  80. color_name: string;
  81. hex_color: string;
  82. material: string | null;
  83. // #1340: gradient + effect carried from the catalog entry through to the
  84. // color picker so they're applied alongside hex + name on selection.
  85. extra_colors?: string | null;
  86. effect_type?: string | null;
  87. }[]>([]);
  88. // Color state
  89. const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
  90. // PA Profile state
  91. const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
  92. const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
  93. const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
  94. // Use prop if provided, otherwise use self-fetched data
  95. const resolvedCalibrations = printersWithCalibrations.length > 0
  96. ? printersWithCalibrations
  97. : fetchedCalibrations;
  98. // Count selected PA profiles for tab badge
  99. const selectedProfileCount = selectedProfiles.size;
  100. // Fetch Spoolman filament catalog when in Spoolman mode
  101. // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
  102. const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
  103. queryKey: ['spoolman-inventory-filaments'],
  104. queryFn: () => api.getSpoolmanInventoryFilaments(),
  105. enabled: spoolmanMode && isOpen,
  106. staleTime: 60_000,
  107. retry: false,
  108. });
  109. // Load recent colors on mount
  110. useEffect(() => {
  111. setRecentColors(loadRecentColors());
  112. }, []);
  113. // Fetch cloud presets and catalog when modal opens. Fetches Bambu Cloud
  114. // and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
  115. // since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
  116. // (same fields, same semantics). ``cloudAuthenticated`` flips on if either
  117. // cloud is connected — the UI only uses it to gate "no cloud" hints.
  118. useEffect(() => {
  119. // ``cancelled`` gates every state setter so a fetch that resolves AFTER
  120. // the modal closes / unmounts can't fire setState on a torn-down
  121. // component. Without this guard the parallel Promise.allSettled chain
  122. // can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
  123. // after vitest has dismantled the JSDOM window — surfaced as an
  124. // "Unhandled Rejection: window is not defined" in CI runs.
  125. let cancelled = false;
  126. if (isOpen) {
  127. const fetchData = async () => {
  128. setLoadingCloudPresets(true);
  129. try {
  130. const [bambuResult, orcaResult] = await Promise.allSettled([
  131. (async () => {
  132. const status = await api.getCloudStatus();
  133. if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
  134. const presets = await api.getFilamentPresets();
  135. return { connected: true, presets };
  136. })(),
  137. (async () => {
  138. const status = await api.orcaCloudStatus();
  139. if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
  140. const list = await api.orcaCloudListProfiles();
  141. // OrcaProfileMeta is structurally identical to SlicerSetting.
  142. return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
  143. })(),
  144. ]);
  145. if (cancelled) return;
  146. const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
  147. const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
  148. const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
  149. const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
  150. setCloudAuthenticated(bambuConnected || orcaConnected);
  151. setCloudPresets([...bambuPresets, ...orcaPresets]);
  152. } catch (e) {
  153. if (cancelled) return;
  154. console.error('Failed to fetch cloud presets:', e);
  155. setCloudAuthenticated(false);
  156. } finally {
  157. if (!cancelled) setLoadingCloudPresets(false);
  158. }
  159. };
  160. fetchData();
  161. if (!spoolmanMode) {
  162. api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
  163. }
  164. api.getColorCatalog().then(setColorCatalog).catch(console.error);
  165. api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
  166. api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
  167. api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).catch(console.error);
  168. // Fetch printer calibrations if not provided via props
  169. if (printersWithCalibrations.length === 0) {
  170. (async () => {
  171. try {
  172. const printers = await api.getPrinters();
  173. const statuses = await Promise.all(
  174. printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
  175. );
  176. const results: PrinterWithCalibrations[] = [];
  177. for (let i = 0; i < printers.length; i++) {
  178. const printer = printers[i];
  179. const status = statuses[i];
  180. const connected = status?.connected ?? false;
  181. let calibrations: PrinterWithCalibrations['calibrations'] = [];
  182. if (connected) {
  183. // Fetch across every installed nozzle so dual-nozzle printers
  184. // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
  185. calibrations = await fetchPrinterCalibrations(printer.id, status);
  186. }
  187. results.push({ printer: { ...printer, connected }, calibrations });
  188. }
  189. setFetchedCalibrations(results);
  190. } catch (e) {
  191. console.error('Failed to fetch printer calibrations:', e);
  192. }
  193. })();
  194. }
  195. }
  196. // The effect intentionally depends only on `isOpen` (and the prop-side
  197. // calibration count) — re-running on every spoolmanMode toggle would
  198. // race the in-flight async fetches with unmount/teardown and emit
  199. // "test environment was torn down" errors in vitest. spoolmanMode only
  200. // gates a single fetch (getSpoolCatalog) which is cheap enough to skip
  201. // when the modal opens in Spoolman mode.
  202. return () => {
  203. cancelled = true;
  204. };
  205. // eslint-disable-next-line react-hooks/exhaustive-deps
  206. }, [isOpen, printersWithCalibrations.length]);
  207. // Build filament options: cloud → local → fallback
  208. const filamentOptions = useMemo(
  209. () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
  210. [cloudPresets, localPresets, builtinFilaments],
  211. );
  212. // Extract brands from presets
  213. const baseAvailableBrands = useMemo(() => {
  214. const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
  215. const catalogBrands = colorCatalog
  216. .map(entry => entry.manufacturer?.trim())
  217. .filter((brand): brand is string => !!brand);
  218. const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
  219. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  220. }, [cloudPresets, localPresets, colorCatalog]);
  221. const baseAvailableMaterials = useMemo(() => {
  222. const catalogMaterials = colorCatalog
  223. .map(entry => entry.material?.trim())
  224. .filter((material): material is string => !!material);
  225. const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
  226. return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
  227. }, [colorCatalog]);
  228. const brandMaterialPairs = useMemo(() => {
  229. const pairs: Array<{ brand: string; material: string }> = [];
  230. for (const entry of colorCatalog) {
  231. const brand = entry.manufacturer?.trim();
  232. const material = entry.material?.trim();
  233. if (brand && material) pairs.push({ brand, material });
  234. }
  235. for (const preset of cloudPresets) {
  236. const parsed = parsePresetName(preset.name);
  237. if (parsed.brand && parsed.material) {
  238. pairs.push({ brand: parsed.brand, material: parsed.material });
  239. }
  240. }
  241. for (const preset of localPresets) {
  242. const parsed = parsePresetName(preset.name);
  243. const brand = preset.filament_vendor?.trim() || parsed.brand;
  244. const material = parsed.material;
  245. if (brand && material) {
  246. pairs.push({ brand, material });
  247. }
  248. }
  249. return pairs;
  250. }, [cloudPresets, colorCatalog, localPresets]);
  251. const brandToMaterials = useMemo(() => {
  252. const map = new Map<string, Set<string>>();
  253. for (const pair of brandMaterialPairs) {
  254. const brandKey = pair.brand.toLowerCase();
  255. const materialKey = pair.material.toLowerCase();
  256. if (!map.has(brandKey)) map.set(brandKey, new Set());
  257. map.get(brandKey)!.add(materialKey);
  258. }
  259. return map;
  260. }, [brandMaterialPairs]);
  261. const materialToBrands = useMemo(() => {
  262. const map = new Map<string, Set<string>>();
  263. for (const pair of brandMaterialPairs) {
  264. const brandKey = pair.brand.toLowerCase();
  265. const materialKey = pair.material.toLowerCase();
  266. if (!map.has(materialKey)) map.set(materialKey, new Set());
  267. map.get(materialKey)!.add(brandKey);
  268. }
  269. return map;
  270. }, [brandMaterialPairs]);
  271. // #1905: the brand and material dropdowns used to be filtered down to the
  272. // pairs seen in the color catalog / slicer presets, which hid perfectly valid
  273. // combinations — "Elegoo" exists (as a PLA brand) but vanished from the list
  274. // once ASA was selected, making the entry look impossible. Both lists now
  275. // always offer everything we know about, plus whatever the spool already has
  276. // stored (a custom brand saved earlier was missing from its own dropdown).
  277. // The pairing knowledge survives as `suggestedBrands`/`suggestedMaterials`,
  278. // which the dropdowns sort to the top instead of filtering by.
  279. const availableBrands = useMemo(
  280. () => withCurrentValue(baseAvailableBrands, formData.brand),
  281. [baseAvailableBrands, formData.brand],
  282. );
  283. const availableMaterials = useMemo(
  284. () => withCurrentValue(baseAvailableMaterials, formData.material),
  285. [baseAvailableMaterials, formData.material],
  286. );
  287. const suggestedBrands = useMemo(
  288. () => pairedOptions(availableBrands, formData.material, materialToBrands),
  289. [availableBrands, formData.material, materialToBrands],
  290. );
  291. const suggestedMaterials = useMemo(
  292. () => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
  293. [availableMaterials, formData.brand, brandToMaterials],
  294. );
  295. // Find selected preset option
  296. const selectedPresetOption = useMemo(
  297. () => findPresetOption(formData.slicer_filament, filamentOptions),
  298. [formData.slicer_filament, filamentOptions],
  299. );
  300. // Reset form when modal opens/closes or spool changes
  301. useEffect(() => {
  302. if (isOpen) {
  303. if (spool) {
  304. // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
  305. // from #1055 before the create/update pattern was enforced). The
  306. // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
  307. // re-submitting a malformed value would 422 every edit on that spool
  308. // — even edits that don't touch color. Normalize on load: any value
  309. // that isn't exactly 8 hex chars falls back to the default, so the
  310. // user can save unrelated fields (weight, material, note) without
  311. // first being forced to fix a color they may not even be aware is
  312. // broken. Saving also purges the bad value from the DB.
  313. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
  314. setFormData({
  315. material: spool.material || '',
  316. subtype: spool.subtype || '',
  317. brand: spool.brand || '',
  318. // #1319: leave color_name blank when the backend reports it was
  319. // synthesised from subtype — otherwise the form would round-trip
  320. // the synth value to Spoolman on save as if the user had set it,
  321. // which is what produced the "color reverts to subtype" symptom.
  322. color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''),
  323. rgba: validRgba,
  324. extra_colors: spool.extra_colors || '',
  325. effect_type: spool.effect_type || '',
  326. label_weight: spool.label_weight || 1000,
  327. core_weight: spool.core_weight || 250,
  328. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  329. weight_used: isCopying ? 0 : spool.weight_used || 0,
  330. slicer_filament: spool.slicer_filament || '',
  331. note: spool.note || '',
  332. cost_per_kg: spool.cost_per_kg ?? null,
  333. category: spool.category || '',
  334. low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
  335. location_id: spool.location_id ?? null,
  336. spoolman_filament_id: null,
  337. });
  338. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  339. // Load K-profiles for this spool
  340. if (spool.k_profiles && spool.k_profiles.length > 0) {
  341. const profileKeys = new Set<string>();
  342. for (const p of spool.k_profiles) {
  343. if (p.cali_idx !== null && p.cali_idx !== undefined) {
  344. profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
  345. }
  346. }
  347. setSelectedProfiles(profileKeys);
  348. } else {
  349. setSelectedProfiles(new Set());
  350. }
  351. } else {
  352. setFormData(defaultFormData);
  353. setPresetInputValue('');
  354. setSelectedProfiles(new Set());
  355. }
  356. // Reset on every open, not just the create path (#1905). The modal keeps
  357. // its state while closed, and the Quick Add toggle only renders in create
  358. // mode — so quick-adding a spool and then opening Edit left the edit form
  359. // stuck in quick-add layout (no preset field, no PA-profile tab) with no
  360. // control to switch back.
  361. setQuickAdd(false);
  362. setQuantity(1);
  363. setErrors({});
  364. setActiveTab('filament');
  365. setWeightTouched(false);
  366. setLocationIdTouched(false);
  367. }
  368. }, [isOpen, spool, mode, isCopying]);
  369. // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
  370. useEffect(() => {
  371. if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
  372. const legacy = spool.storage_location?.trim();
  373. if (!legacy || storageLocations.length === 0) return;
  374. const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
  375. if (match) {
  376. setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
  377. }
  378. }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
  379. // Expand all printers in PA profile section when calibrations are available
  380. useEffect(() => {
  381. if (isOpen && resolvedCalibrations.length > 0) {
  382. setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
  383. }
  384. }, [isOpen, resolvedCalibrations]);
  385. // Update field helper
  386. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  387. const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
  388. if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
  389. showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
  390. }
  391. setFormData(prev => ({
  392. ...prev,
  393. [key]: value,
  394. ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
  395. ? { spoolman_filament_id: null }
  396. : {}),
  397. }));
  398. if (key === 'weight_used') setWeightTouched(true);
  399. if (key === 'location_id') setLocationIdTouched(true);
  400. if (errors[key]) {
  401. setErrors(prev => ({ ...prev, [key]: undefined }));
  402. }
  403. };
  404. // Prefill form from a Spoolman filament catalog entry
  405. // subtype extraction mirrors _spoolman_helpers.py logic
  406. const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
  407. const material = filament.material || '';
  408. const name = filament.name || '';
  409. const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
  410. const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
  411. // Guard against short/malformed hex values — 6 chars (RRGGBB), or 8 when the
  412. // filament is translucent and carries its own alpha (#2912). Rejecting 8 here
  413. // prefilled a clear filament picked from the Spoolman catalogue as 808080FF.
  414. const colorHex = /^[0-9A-F]{6}(?:[0-9A-F]{2})?$/.test(rawHex) ? rawHex : '808080';
  415. const prefillRgba = colorHex.length === 8 ? colorHex : `${colorHex}FF`;
  416. setFormData(prev => ({
  417. ...prev,
  418. spoolman_filament_id: filament.id,
  419. material,
  420. subtype,
  421. brand: filament.vendor?.name || '',
  422. rgba: prefillRgba,
  423. color_name: filament.color_name || '',
  424. label_weight: filament.weight ?? prev.label_weight,
  425. }));
  426. showToast(t('inventory.spoolmanFilamentSelected'), 'success');
  427. };
  428. // Handle color selection
  429. const handleColorUsed = (color: ColorPreset) => {
  430. setRecentColors(prev => saveRecentColor(color, prev));
  431. };
  432. // Mutations – dispatch to Spoolman proxy or local inventory based on mode
  433. const createMutation = useMutation({
  434. mutationFn: (data: Record<string, unknown>) =>
  435. spoolmanMode
  436. ? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
  437. : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  438. onSuccess: async (newSpool) => {
  439. if (newSpool?.id) {
  440. const ok = await saveKProfiles(newSpool.id);
  441. if (!ok) return;
  442. }
  443. await refreshSpoolQueries();
  444. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  445. showToast(t('inventory.spoolCreated'), 'success');
  446. onClose();
  447. },
  448. onError: (error: Error) => {
  449. if (error instanceof ApiError && error.status === 503) {
  450. showToast(t('inventory.spoolmanUnreachable'), 'error');
  451. } else {
  452. showToast(t('inventory.saveFailed'), 'error');
  453. }
  454. },
  455. });
  456. const bulkCreateMutation = useMutation<
  457. SpoolmanBulkCreateResult | InventorySpool[],
  458. Error,
  459. { data: Record<string, unknown>; qty: number }
  460. >({
  461. mutationFn: ({ data, qty }) =>
  462. spoolmanMode
  463. ? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
  464. : api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  465. onSuccess: async (result) => {
  466. // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
  467. // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
  468. // the duck-type check ('created' in result) before any property access.
  469. const spoolmanResult = (spoolmanMode && 'created' in result)
  470. ? (result as unknown as SpoolmanBulkCreateResult)
  471. : null;
  472. const createdSpools: InventorySpool[] = spoolmanResult
  473. ? spoolmanResult.created
  474. : (result as InventorySpool[]);
  475. if (selectedProfiles.size > 0) {
  476. for (const s of createdSpools) {
  477. await saveKProfiles(s.id);
  478. }
  479. }
  480. await refreshSpoolQueries();
  481. if (onSpoolsCreated) onSpoolsCreated(createdSpools);
  482. if (spoolmanResult && spoolmanResult.failed_count > 0) {
  483. showToast(
  484. t('inventory.spoolsPartiallyCreated', {
  485. created: createdSpools.length,
  486. total: spoolmanResult.requested_count,
  487. }),
  488. 'warning',
  489. );
  490. } else {
  491. showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
  492. }
  493. onClose();
  494. },
  495. onError: (error: Error) => {
  496. if (error instanceof ApiError && error.status === 503) {
  497. showToast(t('inventory.spoolmanUnreachable'), 'error');
  498. } else {
  499. showToast(t('inventory.saveFailed'), 'error');
  500. }
  501. },
  502. });
  503. const updateMutation = useMutation({
  504. mutationFn: (data: Record<string, unknown>) =>
  505. spoolmanMode
  506. ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
  507. : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  508. onSuccess: async () => {
  509. if (spool?.id) {
  510. const ok = await saveKProfiles(spool.id);
  511. if (!ok) return;
  512. }
  513. await refreshSpoolQueries();
  514. showToast(t('inventory.spoolUpdated'), 'success');
  515. onClose();
  516. },
  517. onError: (error: Error) => {
  518. if (error instanceof ApiError && error.status === 503) {
  519. showToast(t('inventory.spoolmanUnreachable'), 'error');
  520. } else {
  521. showToast(t('inventory.saveFailed'), 'error');
  522. }
  523. },
  524. });
  525. const deleteTagMutation = useMutation({
  526. mutationFn: () => {
  527. if (spoolmanMode) {
  528. return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
  529. }
  530. return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
  531. },
  532. onSuccess: async () => {
  533. await refreshSpoolQueries();
  534. showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
  535. onClose();
  536. },
  537. onError: (error: Error) => {
  538. if (error instanceof ApiError && error.status === 503) {
  539. showToast(t('inventory.spoolmanUnreachable'), 'error');
  540. } else {
  541. showToast(t('inventory.tagClearFailed'), 'error');
  542. }
  543. },
  544. });
  545. // Fetch assignment for this spool (to show Unassign button). In Spoolman mode
  546. // the slot assignment lives in the spoolman_slot_assignments table keyed by
  547. // spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
  548. // resulting "Unassign button is always disabled" report.
  549. const { data: assignments } = useQuery({
  550. queryKey: ['spool-assignments'],
  551. queryFn: () => api.getAssignments(),
  552. enabled: isOpen && isEditing && !spoolmanMode,
  553. });
  554. const { data: spoolmanSlotAssignments } = useQuery({
  555. queryKey: ['spoolman-slot-assignments-all'],
  556. queryFn: () => api.getSpoolmanSlotAssignments(),
  557. enabled: isOpen && isEditing && spoolmanMode,
  558. });
  559. const spoolAssignment = (() => {
  560. if (!spool) return undefined;
  561. if (spoolmanMode) {
  562. return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id);
  563. }
  564. return assignments?.find(a => a.spool_id === spool.id);
  565. })();
  566. // Read inventory + settings caches (already populated by InventoryPage) to
  567. // drive the category autocomplete and low-stock-threshold placeholder. #729
  568. const { data: allSpools } = useQuery({
  569. queryKey: ['inventory-spools'],
  570. queryFn: () => api.getSpools(true),
  571. enabled: isOpen,
  572. });
  573. const { data: settingsForForm } = useQuery({
  574. queryKey: ['settings'],
  575. queryFn: api.getSettings,
  576. enabled: isOpen,
  577. });
  578. const availableCategories = (() => {
  579. const set = new Set<string>();
  580. for (const s of allSpools ?? []) {
  581. const c = s.category?.trim();
  582. if (c) set.add(c);
  583. }
  584. return Array.from(set).sort((a, b) => a.localeCompare(b));
  585. })();
  586. const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
  587. const unassignMutation = useMutation({
  588. mutationFn: async () => {
  589. if (!spoolAssignment) throw new Error('No assignment');
  590. if (spoolmanMode) {
  591. if (!spool) throw new Error('No spool');
  592. await api.unassignSpoolmanSlot(spool.id);
  593. return;
  594. }
  595. await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  596. },
  597. onSuccess: async () => {
  598. if (spoolmanMode) {
  599. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  600. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  601. } else {
  602. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  603. }
  604. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  605. onClose();
  606. },
  607. onError: (error: Error) => {
  608. showToast(error.message, 'error');
  609. },
  610. });
  611. // Save K-profiles for selected calibrations. Returns false if any error occurred.
  612. const saveKProfiles = async (spoolId: number): Promise<boolean> => {
  613. const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
  614. if (selectedProfiles.size === 0) {
  615. try {
  616. await saveApi(spoolId, []);
  617. return true;
  618. } catch (e) {
  619. console.error('Failed to save K-profiles:', e);
  620. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  621. return false;
  622. }
  623. }
  624. const profiles: SpoolKProfileInput[] = [];
  625. let dropped = 0;
  626. for (const key of selectedProfiles) {
  627. const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
  628. const printerId = parseInt(printerIdStr);
  629. const caliIdx = parseInt(caliIdxStr);
  630. const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
  631. const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
  632. if (pc) {
  633. const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
  634. if (cal) {
  635. profiles.push({
  636. printer_id: printerId,
  637. extruder,
  638. nozzle_diameter: cal.nozzle_diameter || '0.4',
  639. k_value: cal.k_value,
  640. name: cal.name || null,
  641. cali_idx: cal.cali_idx,
  642. setting_id: cal.setting_id || null,
  643. });
  644. } else {
  645. dropped++;
  646. }
  647. } else {
  648. dropped++;
  649. }
  650. }
  651. if (dropped > 0) {
  652. console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
  653. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  654. return false;
  655. }
  656. if (profiles.length > 0) {
  657. try {
  658. await saveApi(spoolId, profiles);
  659. return true;
  660. } catch (e) {
  661. console.error('Failed to save K-profiles:', e);
  662. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  663. return false;
  664. }
  665. }
  666. return true;
  667. };
  668. // Close on Escape key
  669. useEffect(() => {
  670. if (!isOpen) return;
  671. const handleKeyDown = (e: KeyboardEvent) => {
  672. if (e.key === 'Escape') onClose();
  673. };
  674. document.addEventListener('keydown', handleKeyDown);
  675. return () => document.removeEventListener('keydown', handleKeyDown);
  676. }, [isOpen, onClose]);
  677. if (!isOpen) return null;
  678. const handleSubmit = () => {
  679. const validation = validateForm(formData, quickAdd, spoolmanMode, mode);
  680. if (!validation.isValid) {
  681. setErrors(validation.errors);
  682. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  683. setActiveTab('filament');
  684. }
  685. return;
  686. }
  687. // Find preset name from selected option
  688. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  689. const data: Record<string, unknown> = {
  690. material: formData.material || null,
  691. subtype: formData.subtype || null,
  692. brand: formData.brand || null,
  693. color_name: formData.color_name || null,
  694. rgba: formData.rgba || null,
  695. extra_colors: formData.extra_colors || null,
  696. effect_type: formData.effect_type || null,
  697. label_weight: formData.label_weight,
  698. ...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
  699. slicer_filament: formData.slicer_filament || null,
  700. slicer_filament_name: presetName,
  701. nozzle_temp_min: null,
  702. nozzle_temp_max: null,
  703. note: formData.note || null,
  704. cost_per_kg: formData.cost_per_kg,
  705. category: formData.category.trim() || null,
  706. low_stock_threshold_pct: formData.low_stock_threshold_pct,
  707. ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
  708. };
  709. // Only send weight_used when creating or when explicitly changed by the user.
  710. // This prevents stale cached values from overwriting usage-tracker data.
  711. if (!isEditing || weightTouched) {
  712. data.weight_used = formData.weight_used;
  713. }
  714. // Only send location_id when creating or when explicitly changed by the user.
  715. // Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
  716. if (!isEditing || locationIdTouched) {
  717. data.location_id = formData.location_id;
  718. }
  719. if (isEditing) {
  720. updateMutation.mutate(data);
  721. } else if (quantity > 1) {
  722. bulkCreateMutation.mutate({ data, qty: quantity });
  723. } else {
  724. createMutation.mutate(data);
  725. }
  726. };
  727. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  728. return (
  729. <div className="fixed inset-0 z-50 flex items-center justify-center">
  730. <div
  731. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  732. onClick={onClose}
  733. />
  734. <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">
  735. {/* Header */}
  736. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  737. <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
  738. {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  739. {isEditing && spool && (
  740. <span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
  741. )}
  742. </h2>
  743. <button
  744. onClick={onClose}
  745. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  746. >
  747. <X className="w-5 h-5" />
  748. </button>
  749. </div>
  750. {/* Quick Add toggle — only in create mode (not edit, not copy).
  751. In copy mode the modal title is the singular "Copy Spool", so the
  752. quantity-driven bulkCreateMutation path would silently produce N
  753. copies under a misleading title — keep this toggle out of that
  754. mode entirely. */}
  755. {mode === 'create' && (
  756. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  757. <div className="flex items-center gap-2">
  758. <Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  759. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  760. </div>
  761. <button
  762. type="button"
  763. onClick={() => {
  764. setQuickAdd(!quickAdd);
  765. if (!quickAdd) setActiveTab('filament');
  766. }}
  767. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  768. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  769. }`}
  770. >
  771. <span
  772. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  773. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  774. }`}
  775. />
  776. </button>
  777. </div>
  778. )}
  779. {/* Tabs */}
  780. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  781. <button
  782. onClick={() => setActiveTab('filament')}
  783. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  784. activeTab === 'filament'
  785. ? 'text-bambu-green border-b-2 border-bambu-green'
  786. : 'text-bambu-gray hover:text-white'
  787. }`}
  788. >
  789. <Palette className="w-4 h-4" />
  790. {t('inventory.filamentInfoTab')}
  791. </button>
  792. {!quickAdd && (
  793. <button
  794. onClick={() => setActiveTab('pa-profile')}
  795. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  796. activeTab === 'pa-profile'
  797. ? 'text-bambu-green border-b-2 border-bambu-green'
  798. : 'text-bambu-gray hover:text-white'
  799. }`}
  800. >
  801. <Beaker className="w-4 h-4" />
  802. {t('inventory.paProfileTab')}
  803. {selectedProfileCount > 0 && (
  804. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  805. {selectedProfileCount}
  806. </span>
  807. )}
  808. </button>
  809. )}
  810. </div>
  811. {/* Content */}
  812. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  813. {activeTab === 'filament' ? (
  814. <div className="space-y-6">
  815. {/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
  816. {spoolmanMode && !isEditing && (
  817. <div>
  818. {filamentsError ? (
  819. <p className="text-sm text-red-700 dark:text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
  820. ) : (
  821. <SpoolmanFilamentPicker
  822. filaments={spoolmanFilaments}
  823. isLoading={isLoadingFilaments}
  824. selectedId={formData.spoolman_filament_id}
  825. onSelect={handleFilamentSelect}
  826. />
  827. )}
  828. </div>
  829. )}
  830. {/* Filament Info Section */}
  831. <div>
  832. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  833. {t('inventory.filamentInfo')}
  834. </h3>
  835. <FilamentSection
  836. formData={formData}
  837. updateField={updateField}
  838. cloudAuthenticated={cloudAuthenticated}
  839. loadingCloudPresets={loadingCloudPresets}
  840. presetInputValue={presetInputValue}
  841. setPresetInputValue={setPresetInputValue}
  842. selectedPresetOption={selectedPresetOption}
  843. filamentOptions={filamentOptions}
  844. availableBrands={availableBrands}
  845. availableMaterials={availableMaterials}
  846. suggestedBrands={suggestedBrands}
  847. suggestedMaterials={suggestedMaterials}
  848. quickAdd={quickAdd}
  849. detailsRequired={!quickAdd && !spoolmanMode && mode === 'create'}
  850. quantity={quantity}
  851. onQuantityChange={setQuantity}
  852. errors={errors}
  853. />
  854. </div>
  855. {/* Color Section */}
  856. <div>
  857. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  858. {t('inventory.color')}
  859. </h3>
  860. <ColorSection
  861. formData={formData}
  862. updateField={updateField}
  863. recentColors={recentColors}
  864. onColorUsed={handleColorUsed}
  865. catalogColors={colorCatalog}
  866. />
  867. </div>
  868. {/* Additional Section */}
  869. <div>
  870. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  871. {t('inventory.additional')}
  872. </h3>
  873. <AdditionalSection
  874. formData={formData}
  875. updateField={updateField}
  876. spoolCatalog={spoolCatalog}
  877. currencySymbol={currencySymbol}
  878. availableCategories={availableCategories}
  879. availableLocations={storageLocations}
  880. onCreateLocation={async (name) => {
  881. try {
  882. const created = await api.createLocation({ name });
  883. setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
  884. await invalidateInventoryLocations(queryClient);
  885. return { id: created.id, name: created.name };
  886. } catch (e) {
  887. // Surface the backend's actual error so the user can
  888. // distinguish 409 duplicate / 400 validation / 500 from
  889. // a generic "save failed" message.
  890. console.error(e);
  891. const message = e instanceof Error ? e.message : t('locations.saveFailed');
  892. showToast(message || t('locations.saveFailed'), 'error');
  893. return null;
  894. }
  895. }}
  896. globalLowStockThreshold={globalLowStockThreshold}
  897. spoolmanMode={spoolmanMode}
  898. />
  899. </div>
  900. {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
  901. {isEditing && spool && !spoolmanMode && (
  902. <div>
  903. <SpoolUsageHistory spoolId={spool.id} />
  904. </div>
  905. )}
  906. </div>
  907. ) : (
  908. <PAProfileSection
  909. formData={formData}
  910. updateField={updateField}
  911. printersWithCalibrations={resolvedCalibrations}
  912. selectedProfiles={selectedProfiles}
  913. setSelectedProfiles={setSelectedProfiles}
  914. expandedPrinters={expandedPrinters}
  915. setExpandedPrinters={setExpandedPrinters}
  916. />
  917. )}
  918. </div>
  919. {/* Footer */}
  920. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  921. {isEditing && (
  922. <div className="flex gap-2 mr-auto">
  923. <Button
  924. variant="secondary"
  925. onClick={() => deleteTagMutation.mutate()}
  926. disabled={isPending || !spool?.tag_uid}
  927. >
  928. <Tag className="w-4 h-4" />
  929. {t('inventory.clearRfid', 'Clear RFID Tag')}
  930. </Button>
  931. <Button
  932. variant="secondary"
  933. onClick={() => unassignMutation.mutate()}
  934. disabled={isPending || !spoolAssignment}
  935. >
  936. <Unlink className="w-4 h-4" />
  937. {t('inventory.unassignSpool', 'Unassign')}
  938. </Button>
  939. </div>
  940. )}
  941. <div className="flex gap-2 ml-auto">
  942. <Button variant="secondary" onClick={onClose}>
  943. {t('common.cancel')}
  944. </Button>
  945. <Button
  946. onClick={handleSubmit}
  947. disabled={isPending}
  948. >
  949. {isPending ? (
  950. <>
  951. <Loader2 className="w-4 h-4 animate-spin" />
  952. {t('common.saving')}
  953. </>
  954. ) : (
  955. <>
  956. <Save className="w-4 h-4" />
  957. {isEditing ? t('common.save') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  958. </>
  959. )}
  960. </Button>
  961. </div>
  962. </div>
  963. </div>
  964. </div>
  965. );
  966. }