SpoolFormModal.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032
  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 } from './spool-form/types';
  10. import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
  11. import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } 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 = 'create' | 'edit' | 'copy';
  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. try {
  184. const kRes = await api.getKProfiles(printer.id);
  185. calibrations = kRes.profiles.map(p => ({
  186. cali_idx: p.slot_id,
  187. filament_id: p.filament_id,
  188. setting_id: p.setting_id || '',
  189. name: p.name,
  190. k_value: parseFloat(p.k_value) || 0,
  191. n_coef: parseFloat(p.n_coef) || 0,
  192. extruder_id: p.extruder_id,
  193. nozzle_diameter: p.nozzle_diameter,
  194. }));
  195. } catch {
  196. // Printer may not support K-profiles
  197. }
  198. }
  199. results.push({ printer: { ...printer, connected }, calibrations });
  200. }
  201. setFetchedCalibrations(results);
  202. } catch (e) {
  203. console.error('Failed to fetch printer calibrations:', e);
  204. }
  205. })();
  206. }
  207. }
  208. // The effect intentionally depends only on `isOpen` (and the prop-side
  209. // calibration count) — re-running on every spoolmanMode toggle would
  210. // race the in-flight async fetches with unmount/teardown and emit
  211. // "test environment was torn down" errors in vitest. spoolmanMode only
  212. // gates a single fetch (getSpoolCatalog) which is cheap enough to skip
  213. // when the modal opens in Spoolman mode.
  214. return () => {
  215. cancelled = true;
  216. };
  217. // eslint-disable-next-line react-hooks/exhaustive-deps
  218. }, [isOpen, printersWithCalibrations.length]);
  219. // Build filament options: cloud → local → fallback
  220. const filamentOptions = useMemo(
  221. () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
  222. [cloudPresets, localPresets, builtinFilaments],
  223. );
  224. // Extract brands from presets
  225. const baseAvailableBrands = useMemo(() => {
  226. const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
  227. const catalogBrands = colorCatalog
  228. .map(entry => entry.manufacturer?.trim())
  229. .filter((brand): brand is string => !!brand);
  230. const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
  231. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  232. }, [cloudPresets, localPresets, colorCatalog]);
  233. const baseAvailableMaterials = useMemo(() => {
  234. const catalogMaterials = colorCatalog
  235. .map(entry => entry.material?.trim())
  236. .filter((material): material is string => !!material);
  237. const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
  238. return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
  239. }, [colorCatalog]);
  240. const brandMaterialPairs = useMemo(() => {
  241. const pairs: Array<{ brand: string; material: string }> = [];
  242. for (const entry of colorCatalog) {
  243. const brand = entry.manufacturer?.trim();
  244. const material = entry.material?.trim();
  245. if (brand && material) pairs.push({ brand, material });
  246. }
  247. for (const preset of cloudPresets) {
  248. const parsed = parsePresetName(preset.name);
  249. if (parsed.brand && parsed.material) {
  250. pairs.push({ brand: parsed.brand, material: parsed.material });
  251. }
  252. }
  253. for (const preset of localPresets) {
  254. const parsed = parsePresetName(preset.name);
  255. const brand = preset.filament_vendor?.trim() || parsed.brand;
  256. const material = parsed.material;
  257. if (brand && material) {
  258. pairs.push({ brand, material });
  259. }
  260. }
  261. return pairs;
  262. }, [cloudPresets, colorCatalog, localPresets]);
  263. const brandToMaterials = useMemo(() => {
  264. const map = new Map<string, Set<string>>();
  265. for (const pair of brandMaterialPairs) {
  266. const brandKey = pair.brand.toLowerCase();
  267. const materialKey = pair.material.toLowerCase();
  268. if (!map.has(brandKey)) map.set(brandKey, new Set());
  269. map.get(brandKey)!.add(materialKey);
  270. }
  271. return map;
  272. }, [brandMaterialPairs]);
  273. const materialToBrands = useMemo(() => {
  274. const map = new Map<string, Set<string>>();
  275. for (const pair of brandMaterialPairs) {
  276. const brandKey = pair.brand.toLowerCase();
  277. const materialKey = pair.material.toLowerCase();
  278. if (!map.has(materialKey)) map.set(materialKey, new Set());
  279. map.get(materialKey)!.add(brandKey);
  280. }
  281. return map;
  282. }, [brandMaterialPairs]);
  283. const availableBrands = useMemo(() => {
  284. if (!formData.material) return baseAvailableBrands;
  285. const materialKey = formData.material.toLowerCase();
  286. const brandKeys = materialToBrands.get(materialKey);
  287. if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
  288. return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
  289. }, [baseAvailableBrands, formData.material, materialToBrands]);
  290. const availableMaterials = useMemo(() => {
  291. if (!formData.brand) return baseAvailableMaterials;
  292. const brandKey = formData.brand.toLowerCase();
  293. const materialKeys = brandToMaterials.get(brandKey);
  294. if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
  295. return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
  296. }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
  297. // Find selected preset option
  298. const selectedPresetOption = useMemo(
  299. () => findPresetOption(formData.slicer_filament, filamentOptions),
  300. [formData.slicer_filament, filamentOptions],
  301. );
  302. // Reset form when modal opens/closes or spool changes
  303. useEffect(() => {
  304. if (isOpen) {
  305. if (spool) {
  306. // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
  307. // from #1055 before the create/update pattern was enforced). The
  308. // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
  309. // re-submitting a malformed value would 422 every edit on that spool
  310. // — even edits that don't touch color. Normalize on load: any value
  311. // that isn't exactly 8 hex chars falls back to the default, so the
  312. // user can save unrelated fields (weight, material, note) without
  313. // first being forced to fix a color they may not even be aware is
  314. // broken. Saving also purges the bad value from the DB.
  315. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
  316. setFormData({
  317. material: spool.material || '',
  318. subtype: spool.subtype || '',
  319. brand: spool.brand || '',
  320. // #1319: leave color_name blank when the backend reports it was
  321. // synthesised from subtype — otherwise the form would round-trip
  322. // the synth value to Spoolman on save as if the user had set it,
  323. // which is what produced the "color reverts to subtype" symptom.
  324. color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''),
  325. rgba: validRgba,
  326. extra_colors: spool.extra_colors || '',
  327. effect_type: spool.effect_type || '',
  328. label_weight: spool.label_weight || 1000,
  329. core_weight: spool.core_weight || 250,
  330. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  331. weight_used: isCopying ? 0 : spool.weight_used || 0,
  332. slicer_filament: spool.slicer_filament || '',
  333. note: spool.note || '',
  334. cost_per_kg: spool.cost_per_kg ?? null,
  335. category: spool.category || '',
  336. low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
  337. location_id: spool.location_id ?? null,
  338. spoolman_filament_id: null,
  339. });
  340. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  341. // Load K-profiles for this spool
  342. if (spool.k_profiles && spool.k_profiles.length > 0) {
  343. const profileKeys = new Set<string>();
  344. for (const p of spool.k_profiles) {
  345. if (p.cali_idx !== null && p.cali_idx !== undefined) {
  346. profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
  347. }
  348. }
  349. setSelectedProfiles(profileKeys);
  350. } else {
  351. setSelectedProfiles(new Set());
  352. }
  353. } else {
  354. setFormData(defaultFormData);
  355. setPresetInputValue('');
  356. setSelectedProfiles(new Set());
  357. setQuickAdd(false);
  358. setQuantity(1);
  359. }
  360. setErrors({});
  361. setActiveTab('filament');
  362. setWeightTouched(false);
  363. setLocationIdTouched(false);
  364. }
  365. }, [isOpen, spool, mode, isCopying]);
  366. // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
  367. useEffect(() => {
  368. if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
  369. const legacy = spool.storage_location?.trim();
  370. if (!legacy || storageLocations.length === 0) return;
  371. const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
  372. if (match) {
  373. setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
  374. }
  375. }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
  376. // Expand all printers in PA profile section when calibrations are available
  377. useEffect(() => {
  378. if (isOpen && resolvedCalibrations.length > 0) {
  379. setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
  380. }
  381. }, [isOpen, resolvedCalibrations]);
  382. // Update field helper
  383. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  384. const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
  385. if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
  386. showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
  387. }
  388. setFormData(prev => ({
  389. ...prev,
  390. [key]: value,
  391. ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
  392. ? { spoolman_filament_id: null }
  393. : {}),
  394. }));
  395. if (key === 'weight_used') setWeightTouched(true);
  396. if (key === 'location_id') setLocationIdTouched(true);
  397. if (errors[key]) {
  398. setErrors(prev => ({ ...prev, [key]: undefined }));
  399. }
  400. };
  401. // Prefill form from a Spoolman filament catalog entry
  402. // subtype extraction mirrors _spoolman_helpers.py logic
  403. const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
  404. const material = filament.material || '';
  405. const name = filament.name || '';
  406. const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
  407. const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
  408. // Guard against short/malformed hex values — must be exactly 6 hex chars
  409. const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080';
  410. setFormData(prev => ({
  411. ...prev,
  412. spoolman_filament_id: filament.id,
  413. material,
  414. subtype,
  415. brand: filament.vendor?.name || '',
  416. rgba: `${colorHex}FF`,
  417. color_name: filament.color_name || '',
  418. label_weight: filament.weight ?? prev.label_weight,
  419. }));
  420. showToast(t('inventory.spoolmanFilamentSelected'), 'success');
  421. };
  422. // Handle color selection
  423. const handleColorUsed = (color: ColorPreset) => {
  424. setRecentColors(prev => saveRecentColor(color, prev));
  425. };
  426. // Mutations – dispatch to Spoolman proxy or local inventory based on mode
  427. const createMutation = useMutation({
  428. mutationFn: (data: Record<string, unknown>) =>
  429. spoolmanMode
  430. ? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
  431. : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  432. onSuccess: async (newSpool) => {
  433. if (newSpool?.id) {
  434. const ok = await saveKProfiles(newSpool.id);
  435. if (!ok) return;
  436. }
  437. await refreshSpoolQueries();
  438. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  439. showToast(t('inventory.spoolCreated'), 'success');
  440. onClose();
  441. },
  442. onError: (error: Error) => {
  443. if (error instanceof ApiError && error.status === 503) {
  444. showToast(t('inventory.spoolmanUnreachable'), 'error');
  445. } else {
  446. showToast(t('inventory.saveFailed'), 'error');
  447. }
  448. },
  449. });
  450. const bulkCreateMutation = useMutation<
  451. SpoolmanBulkCreateResult | InventorySpool[],
  452. Error,
  453. { data: Record<string, unknown>; qty: number }
  454. >({
  455. mutationFn: ({ data, qty }) =>
  456. spoolmanMode
  457. ? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
  458. : api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  459. onSuccess: async (result) => {
  460. // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
  461. // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
  462. // the duck-type check ('created' in result) before any property access.
  463. const spoolmanResult = (spoolmanMode && 'created' in result)
  464. ? (result as unknown as SpoolmanBulkCreateResult)
  465. : null;
  466. const createdSpools: InventorySpool[] = spoolmanResult
  467. ? spoolmanResult.created
  468. : (result as InventorySpool[]);
  469. if (selectedProfiles.size > 0) {
  470. for (const s of createdSpools) {
  471. await saveKProfiles(s.id);
  472. }
  473. }
  474. await refreshSpoolQueries();
  475. if (onSpoolsCreated) onSpoolsCreated(createdSpools);
  476. if (spoolmanResult && spoolmanResult.failed_count > 0) {
  477. showToast(
  478. t('inventory.spoolsPartiallyCreated', {
  479. created: createdSpools.length,
  480. total: spoolmanResult.requested_count,
  481. }),
  482. 'warning',
  483. );
  484. } else {
  485. showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
  486. }
  487. onClose();
  488. },
  489. onError: (error: Error) => {
  490. if (error instanceof ApiError && error.status === 503) {
  491. showToast(t('inventory.spoolmanUnreachable'), 'error');
  492. } else {
  493. showToast(t('inventory.saveFailed'), 'error');
  494. }
  495. },
  496. });
  497. const updateMutation = useMutation({
  498. mutationFn: (data: Record<string, unknown>) =>
  499. spoolmanMode
  500. ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
  501. : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  502. onSuccess: async () => {
  503. if (spool?.id) {
  504. const ok = await saveKProfiles(spool.id);
  505. if (!ok) return;
  506. }
  507. await refreshSpoolQueries();
  508. showToast(t('inventory.spoolUpdated'), 'success');
  509. onClose();
  510. },
  511. onError: (error: Error) => {
  512. if (error instanceof ApiError && error.status === 503) {
  513. showToast(t('inventory.spoolmanUnreachable'), 'error');
  514. } else {
  515. showToast(t('inventory.saveFailed'), 'error');
  516. }
  517. },
  518. });
  519. const deleteTagMutation = useMutation({
  520. mutationFn: () => {
  521. if (spoolmanMode) {
  522. return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
  523. }
  524. return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
  525. },
  526. onSuccess: async () => {
  527. await refreshSpoolQueries();
  528. showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
  529. onClose();
  530. },
  531. onError: (error: Error) => {
  532. if (error instanceof ApiError && error.status === 503) {
  533. showToast(t('inventory.spoolmanUnreachable'), 'error');
  534. } else {
  535. showToast(t('inventory.tagClearFailed'), 'error');
  536. }
  537. },
  538. });
  539. // Fetch assignment for this spool (to show Unassign button). In Spoolman mode
  540. // the slot assignment lives in the spoolman_slot_assignments table keyed by
  541. // spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
  542. // resulting "Unassign button is always disabled" report.
  543. const { data: assignments } = useQuery({
  544. queryKey: ['spool-assignments'],
  545. queryFn: () => api.getAssignments(),
  546. enabled: isOpen && isEditing && !spoolmanMode,
  547. });
  548. const { data: spoolmanSlotAssignments } = useQuery({
  549. queryKey: ['spoolman-slot-assignments-all'],
  550. queryFn: () => api.getSpoolmanSlotAssignments(),
  551. enabled: isOpen && isEditing && spoolmanMode,
  552. });
  553. const spoolAssignment = (() => {
  554. if (!spool) return undefined;
  555. if (spoolmanMode) {
  556. return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id);
  557. }
  558. return assignments?.find(a => a.spool_id === spool.id);
  559. })();
  560. // Read inventory + settings caches (already populated by InventoryPage) to
  561. // drive the category autocomplete and low-stock-threshold placeholder. #729
  562. const { data: allSpools } = useQuery({
  563. queryKey: ['inventory-spools'],
  564. queryFn: () => api.getSpools(true),
  565. enabled: isOpen,
  566. });
  567. const { data: settingsForForm } = useQuery({
  568. queryKey: ['settings'],
  569. queryFn: api.getSettings,
  570. enabled: isOpen,
  571. });
  572. const availableCategories = (() => {
  573. const set = new Set<string>();
  574. for (const s of allSpools ?? []) {
  575. const c = s.category?.trim();
  576. if (c) set.add(c);
  577. }
  578. return Array.from(set).sort((a, b) => a.localeCompare(b));
  579. })();
  580. const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
  581. const unassignMutation = useMutation({
  582. mutationFn: async () => {
  583. if (!spoolAssignment) throw new Error('No assignment');
  584. if (spoolmanMode) {
  585. if (!spool) throw new Error('No spool');
  586. await api.unassignSpoolmanSlot(spool.id);
  587. return;
  588. }
  589. await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  590. },
  591. onSuccess: async () => {
  592. if (spoolmanMode) {
  593. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  594. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  595. } else {
  596. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  597. }
  598. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  599. onClose();
  600. },
  601. onError: (error: Error) => {
  602. showToast(error.message, 'error');
  603. },
  604. });
  605. // Save K-profiles for selected calibrations. Returns false if any error occurred.
  606. const saveKProfiles = async (spoolId: number): Promise<boolean> => {
  607. const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
  608. if (selectedProfiles.size === 0) {
  609. try {
  610. await saveApi(spoolId, []);
  611. return true;
  612. } catch (e) {
  613. console.error('Failed to save K-profiles:', e);
  614. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  615. return false;
  616. }
  617. }
  618. const profiles: SpoolKProfileInput[] = [];
  619. let dropped = 0;
  620. for (const key of selectedProfiles) {
  621. const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
  622. const printerId = parseInt(printerIdStr);
  623. const caliIdx = parseInt(caliIdxStr);
  624. const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
  625. const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
  626. if (pc) {
  627. const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
  628. if (cal) {
  629. profiles.push({
  630. printer_id: printerId,
  631. extruder,
  632. nozzle_diameter: cal.nozzle_diameter || '0.4',
  633. k_value: cal.k_value,
  634. name: cal.name || null,
  635. cali_idx: cal.cali_idx,
  636. setting_id: cal.setting_id || null,
  637. });
  638. } else {
  639. dropped++;
  640. }
  641. } else {
  642. dropped++;
  643. }
  644. }
  645. if (dropped > 0) {
  646. console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
  647. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  648. return false;
  649. }
  650. if (profiles.length > 0) {
  651. try {
  652. await saveApi(spoolId, profiles);
  653. return true;
  654. } catch (e) {
  655. console.error('Failed to save K-profiles:', e);
  656. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  657. return false;
  658. }
  659. }
  660. return true;
  661. };
  662. // Close on Escape key
  663. useEffect(() => {
  664. if (!isOpen) return;
  665. const handleKeyDown = (e: KeyboardEvent) => {
  666. if (e.key === 'Escape') onClose();
  667. };
  668. document.addEventListener('keydown', handleKeyDown);
  669. return () => document.removeEventListener('keydown', handleKeyDown);
  670. }, [isOpen, onClose]);
  671. if (!isOpen) return null;
  672. const handleSubmit = () => {
  673. const validation = validateForm(formData, quickAdd, spoolmanMode);
  674. if (!validation.isValid) {
  675. setErrors(validation.errors);
  676. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  677. setActiveTab('filament');
  678. }
  679. return;
  680. }
  681. // Find preset name from selected option
  682. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  683. const data: Record<string, unknown> = {
  684. material: formData.material || null,
  685. subtype: formData.subtype || null,
  686. brand: formData.brand || null,
  687. color_name: formData.color_name || null,
  688. rgba: formData.rgba || null,
  689. extra_colors: formData.extra_colors || null,
  690. effect_type: formData.effect_type || null,
  691. label_weight: formData.label_weight,
  692. ...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
  693. slicer_filament: formData.slicer_filament || null,
  694. slicer_filament_name: presetName,
  695. nozzle_temp_min: null,
  696. nozzle_temp_max: null,
  697. note: formData.note || null,
  698. cost_per_kg: formData.cost_per_kg,
  699. category: formData.category.trim() || null,
  700. low_stock_threshold_pct: formData.low_stock_threshold_pct,
  701. ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
  702. };
  703. // Only send weight_used when creating or when explicitly changed by the user.
  704. // This prevents stale cached values from overwriting usage-tracker data.
  705. if (!isEditing || weightTouched) {
  706. data.weight_used = formData.weight_used;
  707. }
  708. // Only send location_id when creating or when explicitly changed by the user.
  709. // Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
  710. if (!isEditing || locationIdTouched) {
  711. data.location_id = formData.location_id;
  712. }
  713. if (isEditing) {
  714. updateMutation.mutate(data);
  715. } else if (quantity > 1) {
  716. bulkCreateMutation.mutate({ data, qty: quantity });
  717. } else {
  718. createMutation.mutate(data);
  719. }
  720. };
  721. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  722. return (
  723. <div className="fixed inset-0 z-50 flex items-center justify-center">
  724. <div
  725. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  726. onClick={onClose}
  727. />
  728. <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">
  729. {/* Header */}
  730. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  731. <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
  732. {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  733. {isEditing && spool && (
  734. <span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
  735. )}
  736. </h2>
  737. <button
  738. onClick={onClose}
  739. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  740. >
  741. <X className="w-5 h-5" />
  742. </button>
  743. </div>
  744. {/* Quick Add toggle — only in create mode (not edit, not copy).
  745. In copy mode the modal title is the singular "Copy Spool", so the
  746. quantity-driven bulkCreateMutation path would silently produce N
  747. copies under a misleading title — keep this toggle out of that
  748. mode entirely. */}
  749. {mode === 'create' && (
  750. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  751. <div className="flex items-center gap-2">
  752. <Zap className="w-4 h-4 text-amber-400" />
  753. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  754. </div>
  755. <button
  756. type="button"
  757. onClick={() => {
  758. setQuickAdd(!quickAdd);
  759. if (!quickAdd) setActiveTab('filament');
  760. }}
  761. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  762. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  763. }`}
  764. >
  765. <span
  766. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  767. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  768. }`}
  769. />
  770. </button>
  771. </div>
  772. )}
  773. {/* Tabs */}
  774. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  775. <button
  776. onClick={() => setActiveTab('filament')}
  777. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  778. activeTab === 'filament'
  779. ? 'text-bambu-green border-b-2 border-bambu-green'
  780. : 'text-bambu-gray hover:text-white'
  781. }`}
  782. >
  783. <Palette className="w-4 h-4" />
  784. {t('inventory.filamentInfoTab')}
  785. </button>
  786. {!quickAdd && (
  787. <button
  788. onClick={() => setActiveTab('pa-profile')}
  789. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  790. activeTab === 'pa-profile'
  791. ? 'text-bambu-green border-b-2 border-bambu-green'
  792. : 'text-bambu-gray hover:text-white'
  793. }`}
  794. >
  795. <Beaker className="w-4 h-4" />
  796. {t('inventory.paProfileTab')}
  797. {selectedProfileCount > 0 && (
  798. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  799. {selectedProfileCount}
  800. </span>
  801. )}
  802. </button>
  803. )}
  804. </div>
  805. {/* Content */}
  806. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  807. {activeTab === 'filament' ? (
  808. <div className="space-y-6">
  809. {/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
  810. {spoolmanMode && !isEditing && (
  811. <div>
  812. {filamentsError ? (
  813. <p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
  814. ) : (
  815. <SpoolmanFilamentPicker
  816. filaments={spoolmanFilaments}
  817. isLoading={isLoadingFilaments}
  818. selectedId={formData.spoolman_filament_id}
  819. onSelect={handleFilamentSelect}
  820. />
  821. )}
  822. </div>
  823. )}
  824. {/* Filament Info Section */}
  825. <div>
  826. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  827. {t('inventory.filamentInfo')}
  828. </h3>
  829. <FilamentSection
  830. formData={formData}
  831. updateField={updateField}
  832. cloudAuthenticated={cloudAuthenticated}
  833. loadingCloudPresets={loadingCloudPresets}
  834. presetInputValue={presetInputValue}
  835. setPresetInputValue={setPresetInputValue}
  836. selectedPresetOption={selectedPresetOption}
  837. filamentOptions={filamentOptions}
  838. availableBrands={availableBrands}
  839. availableMaterials={availableMaterials}
  840. quickAdd={quickAdd}
  841. quantity={quantity}
  842. onQuantityChange={setQuantity}
  843. errors={errors}
  844. />
  845. </div>
  846. {/* Color Section */}
  847. <div>
  848. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  849. {t('inventory.color')}
  850. </h3>
  851. <ColorSection
  852. formData={formData}
  853. updateField={updateField}
  854. recentColors={recentColors}
  855. onColorUsed={handleColorUsed}
  856. catalogColors={colorCatalog}
  857. />
  858. </div>
  859. {/* Additional Section */}
  860. <div>
  861. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  862. {t('inventory.additional')}
  863. </h3>
  864. <AdditionalSection
  865. formData={formData}
  866. updateField={updateField}
  867. spoolCatalog={spoolCatalog}
  868. currencySymbol={currencySymbol}
  869. availableCategories={availableCategories}
  870. availableLocations={storageLocations}
  871. onCreateLocation={async (name) => {
  872. try {
  873. const created = await api.createLocation({ name });
  874. setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
  875. await invalidateInventoryLocations(queryClient);
  876. return { id: created.id, name: created.name };
  877. } catch (e) {
  878. // Surface the backend's actual error so the user can
  879. // distinguish 409 duplicate / 400 validation / 500 from
  880. // a generic "save failed" message.
  881. console.error(e);
  882. const message = e instanceof Error ? e.message : t('locations.saveFailed');
  883. showToast(message || t('locations.saveFailed'), 'error');
  884. return null;
  885. }
  886. }}
  887. globalLowStockThreshold={globalLowStockThreshold}
  888. spoolmanMode={spoolmanMode}
  889. />
  890. </div>
  891. {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
  892. {isEditing && spool && !spoolmanMode && (
  893. <div>
  894. <SpoolUsageHistory spoolId={spool.id} />
  895. </div>
  896. )}
  897. </div>
  898. ) : (
  899. <PAProfileSection
  900. formData={formData}
  901. updateField={updateField}
  902. printersWithCalibrations={resolvedCalibrations}
  903. selectedProfiles={selectedProfiles}
  904. setSelectedProfiles={setSelectedProfiles}
  905. expandedPrinters={expandedPrinters}
  906. setExpandedPrinters={setExpandedPrinters}
  907. />
  908. )}
  909. </div>
  910. {/* Footer */}
  911. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  912. {isEditing && (
  913. <div className="flex gap-2 mr-auto">
  914. <Button
  915. variant="secondary"
  916. onClick={() => deleteTagMutation.mutate()}
  917. disabled={isPending || !spool?.tag_uid}
  918. >
  919. <Tag className="w-4 h-4" />
  920. {t('inventory.clearRfid', 'Clear RFID Tag')}
  921. </Button>
  922. <Button
  923. variant="secondary"
  924. onClick={() => unassignMutation.mutate()}
  925. disabled={isPending || !spoolAssignment}
  926. >
  927. <Unlink className="w-4 h-4" />
  928. {t('inventory.unassignSpool', 'Unassign')}
  929. </Button>
  930. </div>
  931. )}
  932. <div className="flex gap-2 ml-auto">
  933. <Button variant="secondary" onClick={onClose}>
  934. {t('common.cancel')}
  935. </Button>
  936. <Button
  937. onClick={handleSubmit}
  938. disabled={isPending}
  939. >
  940. {isPending ? (
  941. <>
  942. <Loader2 className="w-4 h-4 animate-spin" />
  943. {t('common.saving')}
  944. </>
  945. ) : (
  946. <>
  947. <Save className="w-4 h-4" />
  948. {isEditing ? t('common.save') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  949. </>
  950. )}
  951. </Button>
  952. </div>
  953. </div>
  954. </div>
  955. </div>
  956. );
  957. }