SpoolFormModal.tsx 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339
  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, Link2, Unlink } from 'lucide-react';
  5. import { api, ApiError } from '../api/client';
  6. import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import type {
  10. CalibrationProfile,
  11. ColorPreset,
  12. PresetChoice,
  13. PrinterWithCalibrations,
  14. SpoolFormData,
  15. SpoolFormMode,
  16. } from './spool-form/types';
  17. import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
  18. import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
  19. import { MATERIALS } from './spool-form/constants';
  20. import { FilamentSection } from './spool-form/FilamentSection';
  21. import { ColorSection } from './spool-form/ColorSection';
  22. import { AdditionalSection } from './spool-form/AdditionalSection';
  23. import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
  24. import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
  25. import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
  26. import { normaliseFlow } from '../utils/nozzleFlow';
  27. import { parseUTCDate, toDateTimeLocalValue } from '../utils/date';
  28. import { SpoolUsageHistory } from './SpoolUsageHistory';
  29. import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
  30. import {
  31. invalidateInventoryLocations,
  32. invalidateSpoolAndLocationQueries,
  33. } from '../utils/inventoryQueries';
  34. import { useUiSettings } from '../hooks/useUiSettings';
  35. type TabId = 'filament' | 'appearance' | 'printers';
  36. const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
  37. export type { SpoolFormMode };
  38. interface SpoolFormModalProps {
  39. isOpen: boolean;
  40. onClose: () => void;
  41. spool?: InventorySpool | null;
  42. mode: SpoolFormMode;
  43. printersWithCalibrations?: PrinterWithCalibrations[];
  44. currencySymbol: string;
  45. onSpoolsCreated?: (spools: InventorySpool[]) => void;
  46. /** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
  47. spoolmanMode?: boolean;
  48. /** Query key to invalidate after mutations (differs for Spoolman vs local). */
  49. spoolsQueryKey?: string[];
  50. }
  51. export function SpoolFormModal({
  52. isOpen,
  53. onClose,
  54. spool,
  55. mode,
  56. printersWithCalibrations = [],
  57. currencySymbol,
  58. onSpoolsCreated,
  59. spoolmanMode = false,
  60. spoolsQueryKey = ['inventory-spools'],
  61. }: SpoolFormModalProps) {
  62. const { t } = useTranslation();
  63. const queryClient = useQueryClient();
  64. const { showToast } = useToast();
  65. const refreshSpoolQueries = () =>
  66. invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
  67. const isEditing = mode === 'edit';
  68. const isCopying = mode === 'copy';
  69. // Form state
  70. const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
  71. const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
  72. const [activeTab, setActiveTab] = useState<TabId>('filament');
  73. const [weightTouched, setWeightTouched] = useState(false);
  74. // Keyed on core_weight, not core_weight_catalog_id: SpoolWeightPicker selects
  75. // a catalogue entry by itself on mount when one matches the current weight,
  76. // so the id changes on forms nobody has touched. Both real user actions go
  77. // through core_weight.
  78. const [coreWeightTouched, setCoreWeightTouched] = useState(false);
  79. const [locationIdTouched, setLocationIdTouched] = useState(false);
  80. // Sent only when changed, so saving an unrelated edit cannot put back a
  81. // drying date that an AMS run stamped while the form was open (#2863).
  82. const [lastDriedTouched, setLastDriedTouched] = useState(false);
  83. // Supplier assignments (#2988). Held outside SpoolFormData — they are
  84. // relational and saved through their own replace-all endpoint. An untouched
  85. // create does not send them, so it keeps the backend's inherited
  86. // assignments instead of wiping them with an empty list; a copy always
  87. // sends them, see saveSupplierLinks.
  88. const [supplierLinks, setSupplierLinks] = useState<SupplierLinkDraft[]>([]);
  89. const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
  90. const [quickAdd, setQuickAdd] = useState(false);
  91. const [quantity, setQuantity] = useState(1);
  92. const [showAssignModal, setShowAssignModal] = useState(false);
  93. useEffect(() => {
  94. setShowAssignModal(false);
  95. }, [isOpen, spool?.id]);
  96. // Cloud presets
  97. const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
  98. const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
  99. const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
  100. const [orcaSettingIds, setOrcaSettingIds] = useState<Set<string>>(new Set());
  101. const [presetInputValue, setPresetInputValue] = useState('');
  102. // Spool catalog
  103. const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
  104. const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
  105. // Local presets (OrcaSlicer imports)
  106. const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
  107. // Built-in filaments (static fallback)
  108. const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
  109. // Color catalog
  110. const [colorCatalog, setColorCatalog] = useState<{
  111. manufacturer: string;
  112. color_name: string;
  113. hex_color: string;
  114. material: string | null;
  115. // #1340: gradient + effect carried from the catalog entry through to the
  116. // color picker so they're applied alongside hex + name on selection.
  117. extra_colors?: string | null;
  118. effect_type?: string | null;
  119. }[]>([]);
  120. // Color state
  121. const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
  122. // PA Profile state
  123. const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
  124. // Whether the calibration fetch above is still running. Without it the
  125. // Printers tab renders its "no printers configured" empty state while the
  126. // printers are still being asked -- which reads as a wrong answer rather
  127. // than as a wait, and the fetch is several round trips per machine.
  128. const [loadingCalibrations, setLoadingCalibrations] = useState(false);
  129. // One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather
  130. // than a Set of composite keys because the Printers tab presents each hotend
  131. // as a single-choice dropdown -- the shape makes "two profiles for one
  132. // hotend" unrepresentable instead of relying on eviction logic to prevent it.
  133. const [selectedProfiles, setSelectedProfiles] = useState<Map<string, CalibrationProfile>>(new Map());
  134. // Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`.
  135. // Only the models the user has actually overridden are present; an absent
  136. // entry means "inherit this spool's own preset", which is exactly what the
  137. // backend cascade does with a missing row.
  138. const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
  139. const [selectedGroupId, setSelectedGroupId] = useState<string>('');
  140. // Use prop if provided, otherwise use self-fetched data
  141. const resolvedCalibrations = printersWithCalibrations.length > 0
  142. ? printersWithCalibrations
  143. : fetchedCalibrations;
  144. // Tab badge: everything the user has configured under Printers, K profiles
  145. // and preset overrides alike, since both live on that tab now.
  146. const selectedProfileCount = selectedProfiles.size + modelPresets.size;
  147. // Fetch Spoolman filament catalog when in Spoolman mode
  148. // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
  149. const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
  150. queryKey: ['spoolman-inventory-filaments'],
  151. queryFn: () => api.getSpoolmanInventoryFilaments(),
  152. enabled: spoolmanMode && isOpen,
  153. staleTime: 60_000,
  154. retry: false,
  155. });
  156. // Load recent colors on mount
  157. useEffect(() => {
  158. setRecentColors(loadRecentColors());
  159. }, []);
  160. // Fetch cloud presets and catalog when modal opens. Fetches Bambu Cloud
  161. // and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
  162. // since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
  163. // (same fields, same semantics). ``cloudAuthenticated`` flips on if either
  164. // cloud is connected — the UI only uses it to gate "no cloud" hints.
  165. useEffect(() => {
  166. // ``cancelled`` gates every state setter so a fetch that resolves AFTER
  167. // the modal closes / unmounts can't fire setState on a torn-down
  168. // component. Without this guard the parallel Promise.allSettled chain
  169. // can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
  170. // after vitest has dismantled the JSDOM window — surfaced as an
  171. // "Unhandled Rejection: window is not defined" in CI runs.
  172. let cancelled = false;
  173. if (isOpen) {
  174. const fetchData = async () => {
  175. setLoadingCloudPresets(true);
  176. try {
  177. const [bambuResult, orcaResult] = await Promise.allSettled([
  178. (async () => {
  179. const status = await api.getCloudStatus();
  180. if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
  181. const presets = await api.getFilamentPresets();
  182. return { connected: true, presets };
  183. })(),
  184. (async () => {
  185. const status = await api.orcaCloudStatus();
  186. if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
  187. const list = await api.orcaCloudListProfiles();
  188. // OrcaProfileMeta is structurally identical to SlicerSetting.
  189. return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
  190. })(),
  191. ]);
  192. if (cancelled) return;
  193. const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
  194. const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
  195. const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
  196. const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
  197. setCloudAuthenticated(bambuConnected || orcaConnected);
  198. setCloudPresets([...bambuPresets, ...orcaPresets]);
  199. // The two clouds are merged into one list, so remember which ids came
  200. // from Orca -- it is the only way the origin badge can tell them
  201. // apart afterwards.
  202. setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id)));
  203. } catch (e) {
  204. if (cancelled) return;
  205. console.error('Failed to fetch cloud presets:', e);
  206. setCloudAuthenticated(false);
  207. } finally {
  208. if (!cancelled) setLoadingCloudPresets(false);
  209. }
  210. };
  211. fetchData();
  212. // Fetched in Spoolman mode too: the empty spool weight picker is shown
  213. // there now, and its catalogue is Bambuddy's own either way (#2908).
  214. api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
  215. api.getColorCatalog().then(setColorCatalog).catch(console.error);
  216. api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
  217. api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
  218. api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).catch(console.error);
  219. // Fetch printer calibrations if not provided via props
  220. if (printersWithCalibrations.length === 0) {
  221. (async () => {
  222. setLoadingCalibrations(true);
  223. try {
  224. const printers = await api.getPrinters();
  225. const statuses = await Promise.all(
  226. printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
  227. );
  228. // Printers in parallel, diameters within a printer in series.
  229. // Separate machines are separate MQTT connections and do not
  230. // interfere; it is one printer's own firmware that drops a
  231. // concurrent burst of calibration requests (see
  232. // fetchPrinterCalibrations). Walking the fleet one machine at a
  233. // time made the whole tab wait for the sum of every printer.
  234. const results = await Promise.all(
  235. printers.map(async (printer, i) => {
  236. const status = statuses[i];
  237. const connected = status?.connected ?? false;
  238. let calibrations: PrinterWithCalibrations['calibrations'] = [];
  239. if (connected) {
  240. // Across every nozzle size, so a profile for a size that is
  241. // not currently fitted is still offered (#2618 fetched only
  242. // the fitted ones).
  243. calibrations = await fetchPrinterCalibrations(printer.id, status);
  244. }
  245. // Keep the reported nozzle hardware: the Printers tab lists a
  246. // model's installed diameters from it. Read as a set of
  247. // diameters only -- never indexed by extruder.
  248. return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles };
  249. }),
  250. );
  251. if (!cancelled) setFetchedCalibrations(results);
  252. } catch (e) {
  253. console.error('Failed to fetch printer calibrations:', e);
  254. } finally {
  255. if (!cancelled) setLoadingCalibrations(false);
  256. }
  257. })();
  258. }
  259. }
  260. // Depends only on `isOpen` (and the prop-side calibration count). It used
  261. // to read spoolmanMode for the catalogue fetch and left it out of the deps
  262. // on purpose -- re-running on every toggle raced the in-flight fetches with
  263. // unmount and emitted "test environment was torn down" errors in vitest.
  264. // It no longer reads it, so the deps are complete as written.
  265. return () => {
  266. cancelled = true;
  267. };
  268. }, [isOpen, printersWithCalibrations.length]);
  269. // Build filament options: cloud → local → fallback
  270. const filamentOptions = useMemo(
  271. () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds),
  272. [cloudPresets, localPresets, builtinFilaments, orcaSettingIds],
  273. );
  274. // Extract brands from presets
  275. const baseAvailableBrands = useMemo(() => {
  276. const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
  277. const catalogBrands = colorCatalog
  278. .map(entry => entry.manufacturer?.trim())
  279. .filter((brand): brand is string => !!brand);
  280. const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
  281. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  282. }, [cloudPresets, localPresets, colorCatalog]);
  283. const baseAvailableMaterials = useMemo(() => {
  284. const catalogMaterials = colorCatalog
  285. .map(entry => entry.material?.trim())
  286. .filter((material): material is string => !!material);
  287. const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
  288. return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
  289. }, [colorCatalog]);
  290. const brandMaterialPairs = useMemo(() => {
  291. const pairs: Array<{ brand: string; material: string }> = [];
  292. for (const entry of colorCatalog) {
  293. const brand = entry.manufacturer?.trim();
  294. const material = entry.material?.trim();
  295. if (brand && material) pairs.push({ brand, material });
  296. }
  297. for (const preset of cloudPresets) {
  298. const parsed = parsePresetName(preset.name);
  299. if (parsed.brand && parsed.material) {
  300. pairs.push({ brand: parsed.brand, material: parsed.material });
  301. }
  302. }
  303. for (const preset of localPresets) {
  304. const parsed = parsePresetName(preset.name);
  305. const brand = preset.filament_vendor?.trim() || parsed.brand;
  306. const material = parsed.material;
  307. if (brand && material) {
  308. pairs.push({ brand, material });
  309. }
  310. }
  311. return pairs;
  312. }, [cloudPresets, colorCatalog, localPresets]);
  313. const brandToMaterials = useMemo(() => {
  314. const map = new Map<string, Set<string>>();
  315. for (const pair of brandMaterialPairs) {
  316. const brandKey = pair.brand.toLowerCase();
  317. const materialKey = pair.material.toLowerCase();
  318. if (!map.has(brandKey)) map.set(brandKey, new Set());
  319. map.get(brandKey)!.add(materialKey);
  320. }
  321. return map;
  322. }, [brandMaterialPairs]);
  323. const materialToBrands = useMemo(() => {
  324. const map = new Map<string, Set<string>>();
  325. for (const pair of brandMaterialPairs) {
  326. const brandKey = pair.brand.toLowerCase();
  327. const materialKey = pair.material.toLowerCase();
  328. if (!map.has(materialKey)) map.set(materialKey, new Set());
  329. map.get(materialKey)!.add(brandKey);
  330. }
  331. return map;
  332. }, [brandMaterialPairs]);
  333. // #1905: the brand and material dropdowns used to be filtered down to the
  334. // pairs seen in the color catalog / slicer presets, which hid perfectly valid
  335. // combinations — "Elegoo" exists (as a PLA brand) but vanished from the list
  336. // once ASA was selected, making the entry look impossible. Both lists now
  337. // always offer everything we know about, plus whatever the spool already has
  338. // stored (a custom brand saved earlier was missing from its own dropdown).
  339. // The pairing knowledge survives as `suggestedBrands`/`suggestedMaterials`,
  340. // which the dropdowns sort to the top instead of filtering by.
  341. const availableBrands = useMemo(
  342. () => withCurrentValue(baseAvailableBrands, formData.brand),
  343. [baseAvailableBrands, formData.brand],
  344. );
  345. const availableMaterials = useMemo(
  346. () => withCurrentValue(baseAvailableMaterials, formData.material),
  347. [baseAvailableMaterials, formData.material],
  348. );
  349. const suggestedBrands = useMemo(
  350. () => pairedOptions(availableBrands, formData.material, materialToBrands),
  351. [availableBrands, formData.material, materialToBrands],
  352. );
  353. const suggestedMaterials = useMemo(
  354. () => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
  355. [availableMaterials, formData.brand, brandToMaterials],
  356. );
  357. // Find selected preset option
  358. const selectedPresetOption = useMemo(
  359. () => findPresetOption(formData.slicer_filament, filamentOptions),
  360. [formData.slicer_filament, filamentOptions],
  361. );
  362. // Reset form when modal opens/closes or spool changes
  363. useEffect(() => {
  364. if (isOpen) {
  365. if (spool) {
  366. // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
  367. // from #1055 before the create/update pattern was enforced). The
  368. // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
  369. // re-submitting a malformed value would 422 every edit on that spool
  370. // — even edits that don't touch color. Normalize on load: any value
  371. // that isn't exactly 8 hex chars falls back to the default, so the
  372. // user can save unrelated fields (weight, material, note) without
  373. // first being forced to fix a color they may not even be aware is
  374. // broken. Saving also purges the bad value from the DB.
  375. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
  376. setFormData({
  377. material: spool.material || '',
  378. subtype: spool.subtype || '',
  379. brand: spool.brand || '',
  380. // #1319: leave color_name blank when the backend reports it was
  381. // synthesised from subtype — otherwise the form would round-trip
  382. // the synth value to Spoolman on save as if the user had set it,
  383. // which is what produced the "color reverts to subtype" symptom.
  384. color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''),
  385. rgba: validRgba,
  386. extra_colors: spool.extra_colors || '',
  387. effect_type: spool.effect_type || '',
  388. label_weight: spool.label_weight || 1000,
  389. // ?? not ||: 0 g is a real tare (a spool-less coil) and must load as 0.
  390. core_weight: spool.core_weight ?? 250,
  391. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  392. weight_used: isCopying ? 0 : spool.weight_used || 0,
  393. slicer_filament: spool.slicer_filament || '',
  394. note: spool.note || '',
  395. cost_per_kg: spool.cost_per_kg ?? null,
  396. category: spool.category || '',
  397. low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
  398. material_number: spool.material_number || '',
  399. location_id: spool.location_id ?? null,
  400. last_dried_at: (() => {
  401. const dried = isCopying ? null : parseUTCDate(spool.last_dried_at);
  402. return dried ? toDateTimeLocalValue(dried) : '';
  403. })(),
  404. spoolman_filament_id: null,
  405. });
  406. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  407. // Load K-profiles for this spool. The stored row carries everything
  408. // the picker needs to show the selection before the printer answers,
  409. // so an offline printer still renders what was chosen for it.
  410. if (spool.k_profiles && spool.k_profiles.length > 0) {
  411. const chosen = new Map<string, CalibrationProfile>();
  412. for (const p of spool.k_profiles) {
  413. if (p.cali_idx === null || p.cali_idx === undefined) continue;
  414. const diameter = (p.nozzle_diameter || '').trim() || '0.4';
  415. const extruder = p.extruder ?? 0;
  416. chosen.set(hotendKey(p.printer_id, extruder, diameter), {
  417. cali_idx: p.cali_idx,
  418. filament_id: '',
  419. setting_id: p.setting_id || '',
  420. name: p.name || '',
  421. k_value: p.k_value,
  422. n_coef: 0,
  423. extruder_id: extruder,
  424. nozzle_diameter: diameter,
  425. // Stored as the bare flow code; the picker re-derives its label
  426. // from the same field it reads off a live profile.
  427. nozzle_id: p.nozzle_type || '',
  428. });
  429. }
  430. setSelectedProfiles(chosen);
  431. } else {
  432. setSelectedProfiles(new Map());
  433. }
  434. // Supplier assignments (#2988) — copied on copy: they describe where
  435. // the product is bought, which is what a copy shares.
  436. setSupplierLinks(
  437. (spool.suppliers ?? []).map((link) => ({
  438. supplier_id: link.supplier_id,
  439. supplier_name: link.supplier_name,
  440. supplier_article_number: link.supplier_article_number ?? '',
  441. quoted_price_per_kg: link.quoted_price_per_kg,
  442. // Where a COPY was bought is unknown — only a real edit keeps it.
  443. is_purchase_source: isCopying ? false : link.is_purchase_source,
  444. }))
  445. );
  446. } else {
  447. setFormData(defaultFormData);
  448. setPresetInputValue('');
  449. setSelectedProfiles(new Map());
  450. setSupplierLinks([]);
  451. }
  452. // Reset on every open, not just the create path (#1905). The modal keeps
  453. // its state while closed, and the Quick Add toggle only renders in create
  454. // mode — so quick-adding a spool and then opening Edit left the edit form
  455. // stuck in quick-add layout (no preset field, no PA-profile tab) with no
  456. // control to switch back.
  457. setQuickAdd(false);
  458. setQuantity(1);
  459. setErrors({});
  460. setActiveTab('filament');
  461. setSelectedGroupId('');
  462. // Cleared on every open, both branches: the modal keeps its state while
  463. // closed, so editing spool B after spool A would otherwise show (and
  464. // save) A's per-model overrides on B. Refilled by the fetch below.
  465. setModelPresets(new Map());
  466. setWeightTouched(false);
  467. setLastDriedTouched(false);
  468. // A copy of a Spoolman spool with its own tare carries that tare, as it
  469. // would any other field shown in the form; one that inherits keeps
  470. // inheriting. Only Spoolman spools report the flag (#2908).
  471. setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
  472. setLocationIdTouched(false);
  473. setSupplierLinksTouched(false);
  474. }
  475. }, [isOpen, spool, mode, isCopying]);
  476. // Load this spool's per-printer-model preset overrides. Fetched rather than
  477. // read off the spool: they are deliberately not embedded in the spool
  478. // response, which the inventory list returns once per spool the user owns.
  479. // Copying a spool copies its overrides -- they describe the filament on the
  480. // spool, which is what a copy has too.
  481. useEffect(() => {
  482. if (!isOpen || !spool) return;
  483. let cancelled = false;
  484. const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets;
  485. load(spool.id)
  486. .then(rows => {
  487. if (cancelled) return;
  488. const next = new Map<string, PresetChoice>();
  489. for (const row of rows) {
  490. next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
  491. code: row.slicer_filament || '',
  492. name: row.slicer_filament_name || '',
  493. });
  494. }
  495. setModelPresets(next);
  496. })
  497. .catch(e => {
  498. // Non-fatal: the tab still works, it just starts with nothing
  499. // overridden. Saving from that state WOULD clear the stored rows, so
  500. // say so rather than letting the user save over what they cannot see.
  501. if (cancelled) return;
  502. console.error('Failed to load filament preset overrides:', e);
  503. showToast(t('inventory.filamentPresetsLoadFailed'), 'warning');
  504. });
  505. return () => {
  506. cancelled = true;
  507. };
  508. // eslint-disable-next-line react-hooks/exhaustive-deps
  509. }, [isOpen, spool?.id, spoolmanMode]);
  510. // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
  511. useEffect(() => {
  512. if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
  513. const legacy = spool.storage_location?.trim();
  514. if (!legacy || storageLocations.length === 0) return;
  515. const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
  516. if (match) {
  517. setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
  518. }
  519. }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
  520. // Update field helper
  521. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  522. const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
  523. if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
  524. showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
  525. }
  526. setFormData(prev => ({
  527. ...prev,
  528. [key]: value,
  529. ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
  530. ? { spoolman_filament_id: null }
  531. : {}),
  532. }));
  533. if (key === 'weight_used') setWeightTouched(true);
  534. if (key === 'core_weight') setCoreWeightTouched(true);
  535. if (key === 'location_id') setLocationIdTouched(true);
  536. if (key === 'last_dried_at') setLastDriedTouched(true);
  537. if (errors[key]) {
  538. setErrors(prev => ({ ...prev, [key]: undefined }));
  539. }
  540. };
  541. // Prefill form from a Spoolman filament catalog entry
  542. // subtype extraction mirrors _spoolman_helpers.py logic
  543. const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
  544. const material = filament.material || '';
  545. const name = filament.name || '';
  546. const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
  547. const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
  548. // Guard against short/malformed hex values — 6 chars (RRGGBB), or 8 when the
  549. // filament is translucent and carries its own alpha (#2912). Rejecting 8 here
  550. // prefilled a clear filament picked from the Spoolman catalogue as 808080FF.
  551. const colorHex = /^[0-9A-F]{6}(?:[0-9A-F]{2})?$/.test(rawHex) ? rawHex : '808080';
  552. const prefillRgba = colorHex.length === 8 ? colorHex : `${colorHex}FF`;
  553. setFormData(prev => ({
  554. ...prev,
  555. spoolman_filament_id: filament.id,
  556. material,
  557. subtype,
  558. brand: filament.vendor?.name || '',
  559. rgba: prefillRgba,
  560. color_name: filament.color_name || '',
  561. label_weight: filament.weight ?? prev.label_weight,
  562. }));
  563. showToast(t('inventory.spoolmanFilamentSelected'), 'success');
  564. };
  565. // Handle color selection
  566. const handleColorUsed = (color: ColorPreset) => {
  567. setRecentColors(prev => saveRecentColor(color, prev));
  568. };
  569. // Mutations – dispatch to Spoolman proxy or local inventory based on mode
  570. const createMutation = useMutation({
  571. mutationFn: (data: Record<string, unknown>) =>
  572. spoolmanMode
  573. ? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
  574. : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  575. onSuccess: async (newSpool) => {
  576. if (newSpool?.id) {
  577. const ok = await savePrinterProfiles(newSpool.id);
  578. if (!ok) return;
  579. const suppliersOk = await saveSupplierLinks(newSpool.id);
  580. if (!suppliersOk) return;
  581. }
  582. await refreshSpoolQueries();
  583. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  584. showToast(t('inventory.spoolCreated'), 'success');
  585. onClose();
  586. },
  587. onError: (error: Error) => {
  588. if (error instanceof ApiError && error.status === 503) {
  589. showToast(t('inventory.spoolmanUnreachable'), 'error');
  590. } else {
  591. showToast(t('inventory.saveFailed'), 'error');
  592. }
  593. },
  594. });
  595. const bulkCreateMutation = useMutation<
  596. SpoolmanBulkCreateResult | InventorySpool[],
  597. Error,
  598. { data: Record<string, unknown>; qty: number }
  599. >({
  600. mutationFn: ({ data, qty }) =>
  601. spoolmanMode
  602. ? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
  603. : api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  604. onSuccess: async (result) => {
  605. // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
  606. // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
  607. // the duck-type check ('created' in result) before any property access.
  608. const spoolmanResult = (spoolmanMode && 'created' in result)
  609. ? (result as unknown as SpoolmanBulkCreateResult)
  610. : null;
  611. const createdSpools: InventorySpool[] = spoolmanResult
  612. ? spoolmanResult.created
  613. : (result as InventorySpool[]);
  614. // Bulk create: every copy gets the same profiles and overrides. Skipped
  615. // entirely when the user configured neither, so a plain bulk add does
  616. // not fire two writes per spool.
  617. if (selectedProfiles.size > 0 || modelPresets.size > 0) {
  618. for (const s of createdSpools) {
  619. await savePrinterProfiles(s.id);
  620. }
  621. }
  622. // Every copy of a bulk add shares the same supplier assignments (#2988).
  623. if (shouldSaveSupplierLinks) {
  624. for (const s of createdSpools) {
  625. await saveSupplierLinks(s.id);
  626. }
  627. }
  628. await refreshSpoolQueries();
  629. if (onSpoolsCreated) onSpoolsCreated(createdSpools);
  630. if (spoolmanResult && spoolmanResult.failed_count > 0) {
  631. showToast(
  632. t('inventory.spoolsPartiallyCreated', {
  633. created: createdSpools.length,
  634. total: spoolmanResult.requested_count,
  635. }),
  636. 'warning',
  637. );
  638. } else {
  639. showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
  640. }
  641. onClose();
  642. },
  643. onError: (error: Error) => {
  644. if (error instanceof ApiError && error.status === 503) {
  645. showToast(t('inventory.spoolmanUnreachable'), 'error');
  646. } else {
  647. showToast(t('inventory.saveFailed'), 'error');
  648. }
  649. },
  650. });
  651. const updateMutation = useMutation({
  652. mutationFn: (data: Record<string, unknown>) =>
  653. spoolmanMode
  654. ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
  655. : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  656. onSuccess: async () => {
  657. if (spool?.id) {
  658. const ok = await savePrinterProfiles(spool.id);
  659. if (!ok) return;
  660. const suppliersOk = await saveSupplierLinks(spool.id);
  661. if (!suppliersOk) return;
  662. }
  663. await refreshSpoolQueries();
  664. showToast(t('inventory.spoolUpdated'), 'success');
  665. onClose();
  666. },
  667. onError: (error: Error) => {
  668. if (error instanceof ApiError && error.status === 503) {
  669. showToast(t('inventory.spoolmanUnreachable'), 'error');
  670. } else {
  671. showToast(t('inventory.saveFailed'), 'error');
  672. }
  673. },
  674. });
  675. const deleteTagMutation = useMutation({
  676. mutationFn: () => {
  677. if (spoolmanMode) {
  678. return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
  679. }
  680. return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
  681. },
  682. onSuccess: async () => {
  683. await refreshSpoolQueries();
  684. showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
  685. onClose();
  686. },
  687. onError: (error: Error) => {
  688. if (error instanceof ApiError && error.status === 503) {
  689. showToast(t('inventory.spoolmanUnreachable'), 'error');
  690. } else {
  691. showToast(t('inventory.tagClearFailed'), 'error');
  692. }
  693. },
  694. });
  695. // Fetch assignment for this spool (to show Unassign button). In Spoolman mode
  696. // the slot assignment lives in the spoolman_slot_assignments table keyed by
  697. // spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
  698. // resulting "Unassign button is always disabled" report.
  699. const { data: assignments, isLoading: assignmentsLoading } = useQuery({
  700. queryKey: ['spool-assignments'],
  701. queryFn: () => api.getAssignments(),
  702. enabled: isOpen && isEditing && !spoolmanMode,
  703. });
  704. const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({
  705. queryKey: ['spoolman-slot-assignments-all'],
  706. queryFn: () => api.getSpoolmanSlotAssignments(),
  707. enabled: isOpen && isEditing && spoolmanMode,
  708. });
  709. const spoolAssignment = (() => {
  710. if (!spool) return undefined;
  711. if (spoolmanMode) {
  712. return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id);
  713. }
  714. return assignments?.find(a => a.spool_id === spool.id);
  715. })();
  716. const assignmentLoading = spoolmanMode ? spoolmanAssignmentsLoading : assignmentsLoading;
  717. // Read inventory + settings caches (already populated by InventoryPage) to
  718. // drive the category autocomplete and low-stock-threshold placeholder. #729
  719. const { data: allSpools } = useQuery({
  720. queryKey: ['inventory-spools'],
  721. queryFn: () => api.getSpools(true),
  722. enabled: isOpen,
  723. });
  724. const { data: settingsForForm } = useUiSettings({ enabled: isOpen });
  725. // Backend Bambu printer-model registry, so the Printers tab can read the
  726. // model out of a preset name and offer each model only its own presets. The
  727. // same query key and staleTime the Configure AMS Slot modal uses -- the
  728. // registry only changes across backend releases, so this is a cache hit
  729. // whenever that modal has been opened.
  730. const { data: printerModelsData } = useQuery({
  731. queryKey: ['slicerPrinterModels'],
  732. queryFn: api.getSlicerPrinterModels,
  733. enabled: isOpen,
  734. staleTime: Infinity,
  735. });
  736. const availableCategories = (() => {
  737. const set = new Set<string>();
  738. for (const s of allSpools ?? []) {
  739. const c = s.category?.trim();
  740. if (c) set.add(c);
  741. }
  742. return Array.from(set).sort((a, b) => a.localeCompare(b));
  743. })();
  744. // Autocomplete for the internal material number (#2870), mirroring the
  745. // category datalist above.
  746. const availableMaterialNumbers = (() => {
  747. const set = new Set<string>();
  748. for (const s of allSpools ?? []) {
  749. const n = s.material_number?.trim();
  750. if (n) set.add(n);
  751. }
  752. return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
  753. })();
  754. const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
  755. const unassignMutation = useMutation({
  756. mutationFn: async () => {
  757. if (!spoolAssignment) throw new Error('No assignment');
  758. if (spoolmanMode) {
  759. if (!spool) throw new Error('No spool');
  760. await api.unassignSpoolmanSlot(spool.id);
  761. return;
  762. }
  763. await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  764. },
  765. onSuccess: async () => {
  766. if (spoolmanMode) {
  767. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  768. await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  769. } else {
  770. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  771. }
  772. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  773. onClose();
  774. },
  775. onError: (error: Error) => {
  776. showToast(error.message, 'error');
  777. },
  778. });
  779. // Supplier assignments (#2988): replace-all save.
  780. //
  781. // Skipped on an untouched create so the backend's inheritance can fill the
  782. // new spool in — an empty list would wipe what it just attached. A COPY is
  783. // the opposite case and always saves: the dialog seeded the chips from the
  784. // spool being copied and showed them, so they have to be what the copy
  785. // gets. Inheritance cannot stand in for that — it keys on the (material,
  786. // subtype, brand, color_name) tuple and so resolves to the NEWEST spool of
  787. // the product rather than the one on screen, and in Spoolman mode there is
  788. // no inheritance at all.
  789. const shouldSaveSupplierLinks = supplierLinksTouched || isCopying;
  790. const saveSupplierLinks = async (spoolId: number): Promise<boolean> => {
  791. if (!shouldSaveSupplierLinks) return true;
  792. // Spoolman parity (#2988): the assignment rows live Bambuddy-side either
  793. // way; only the endpoint differs (twin table keyed by the remote id).
  794. const save = spoolmanMode ? api.setSpoolmanSpoolSuppliers : api.setSpoolSuppliers;
  795. try {
  796. await save(
  797. spoolId,
  798. supplierLinks.map((link) => ({
  799. supplier_id: link.supplier_id,
  800. supplier_article_number: link.supplier_article_number.trim() || null,
  801. quoted_price_per_kg: link.quoted_price_per_kg,
  802. is_purchase_source: link.is_purchase_source,
  803. })),
  804. );
  805. return true;
  806. } catch (err) {
  807. console.error('SpoolFormModal.saveSupplierLinks failed:', err);
  808. showToast(t('inventory.suppliers.saveFailed'), 'error');
  809. return false;
  810. }
  811. };
  812. // Save everything the Printers tab holds: one K profile per hotend and the
  813. // per-printer-model preset overrides. Returns false if either write failed,
  814. // which keeps the modal open so the user does not lose what they picked.
  815. const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
  816. const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
  817. const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
  818. // The selection Map is keyed by hotend and holds the calibration itself,
  819. // so nothing has to be resolved back out of the printer's live list. That
  820. // also fixes a real defect in the old key-based lookup: it matched a
  821. // calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE --
  822. // on a dual-nozzle printer it could resolve the other hotend's entry and
  823. // persist that entry's K value and diameter.
  824. const profiles: SpoolKProfileInput[] = [];
  825. for (const [key, cal] of selectedProfiles) {
  826. const [printerIdStr, extruderStr, diameter] = key.split(':');
  827. profiles.push({
  828. printer_id: parseInt(printerIdStr),
  829. extruder: parseInt(extruderStr),
  830. nozzle_diameter: diameter || '0.4',
  831. // The flow the profile was measured on, when the printer declares one.
  832. // Null where it does not (an X1C declares none on any profile), which
  833. // the backend reads as "applies to whatever is fitted" -- the same rule
  834. // that keeps every profile stored before this working.
  835. nozzle_type: normaliseFlow(cal.nozzle_id),
  836. k_value: cal.k_value,
  837. name: cal.name || null,
  838. cali_idx: cal.cali_idx,
  839. setting_id: cal.setting_id || null,
  840. });
  841. }
  842. const presets: SpoolFilamentPresetInput[] = [];
  843. for (const [key, choice] of modelPresets) {
  844. const { model, diameter } = parsePresetKey(key);
  845. if (!model) continue;
  846. presets.push({
  847. printer_model: model,
  848. nozzle_diameter: diameter,
  849. slicer_filament: choice.code || null,
  850. slicer_filament_name: choice.name || null,
  851. });
  852. }
  853. // Both are full replacements, so both run even when empty -- that is how
  854. // the user clears the last profile or the last override.
  855. try {
  856. await saveKApi(spoolId, profiles);
  857. } catch (e) {
  858. console.error('Failed to save K-profiles:', e);
  859. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  860. return false;
  861. }
  862. try {
  863. await savePresetApi(spoolId, presets);
  864. } catch (e) {
  865. console.error('Failed to save filament preset overrides:', e);
  866. showToast(t('inventory.filamentPresetSaveFailed'), 'warning');
  867. return false;
  868. }
  869. return true;
  870. };
  871. // Close on Escape key
  872. useEffect(() => {
  873. if (!isOpen) return;
  874. const handleKeyDown = (e: KeyboardEvent) => {
  875. // The nested AMS assignment dialog owns Escape while it is open. Both
  876. // listeners live on document, so propagation control in the child would
  877. // not prevent this editor handler from running for the same key press.
  878. if (e.key === 'Escape' && !showAssignModal) onClose();
  879. };
  880. document.addEventListener('keydown', handleKeyDown);
  881. return () => document.removeEventListener('keydown', handleKeyDown);
  882. }, [isOpen, onClose, showAssignModal]);
  883. if (!isOpen) return null;
  884. const handleSubmit = () => {
  885. const validation = validateForm(formData, quickAdd, spoolmanMode, mode);
  886. if (!validation.isValid) {
  887. setErrors(validation.errors);
  888. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  889. setActiveTab('filament');
  890. }
  891. return;
  892. }
  893. // Find preset name from selected option
  894. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  895. const data: Record<string, unknown> = {
  896. material: formData.material || null,
  897. subtype: formData.subtype || null,
  898. brand: formData.brand || null,
  899. color_name: formData.color_name || null,
  900. rgba: formData.rgba || null,
  901. extra_colors: formData.extra_colors || null,
  902. effect_type: formData.effect_type || null,
  903. label_weight: formData.label_weight,
  904. // In Spoolman mode the picker opens on the tare the spool resolves to,
  905. // which is the filament type's unless the spool has its own. Sending it
  906. // untouched would copy that inherited value onto the spool and stop it
  907. // following the filament, so only a value the user set goes out (#2908).
  908. // The catalogue id has no field on the Spoolman side.
  909. ...(spoolmanMode
  910. ? coreWeightTouched
  911. ? { core_weight: formData.core_weight }
  912. : {}
  913. : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
  914. slicer_filament: formData.slicer_filament || null,
  915. slicer_filament_name: presetName,
  916. nozzle_temp_min: null,
  917. nozzle_temp_max: null,
  918. note: formData.note || null,
  919. cost_per_kg: formData.cost_per_kg,
  920. category: formData.category.trim() || null,
  921. low_stock_threshold_pct: formData.low_stock_threshold_pct,
  922. material_number: formData.material_number.trim() || null,
  923. ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
  924. };
  925. // Only send weight_used when creating or when explicitly changed by the user.
  926. // This prevents stale cached values from overwriting usage-tracker data.
  927. if (!isEditing || weightTouched) {
  928. data.weight_used = formData.weight_used;
  929. }
  930. // Only send location_id when creating or when explicitly changed by the user.
  931. // Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
  932. if (!isEditing || locationIdTouched) {
  933. data.location_id = formData.location_id;
  934. }
  935. if (isEditing && lastDriedTouched) {
  936. data.last_dried_at = formData.last_dried_at ? new Date(formData.last_dried_at).toISOString() : null;
  937. }
  938. if (isEditing) {
  939. updateMutation.mutate(data);
  940. } else if (quantity > 1) {
  941. bulkCreateMutation.mutate({ data, qty: quantity });
  942. } else {
  943. createMutation.mutate(data);
  944. }
  945. };
  946. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  947. return (
  948. <>
  949. <div className="fixed inset-0 z-50 flex items-center justify-center">
  950. <div
  951. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  952. onClick={onClose}
  953. />
  954. {/* Wider than the old max-w-xl: the Printers tab is a model list beside
  955. a detail pane holding a preset row per nozzle size and a hotend-by-
  956. size grid, which needs room for both. Held constant across tabs
  957. rather than sized per tab -- a modal that resizes as you switch tabs
  958. reads as a layout bug. */}
  959. <div className="relative w-full max-w-5xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
  960. {/* Header */}
  961. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  962. <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
  963. {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  964. {isEditing && spool && (
  965. <span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
  966. )}
  967. </h2>
  968. <button
  969. onClick={onClose}
  970. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  971. >
  972. <X className="w-5 h-5" />
  973. </button>
  974. </div>
  975. {/* Quick Add toggle — only in create mode (not edit, not copy).
  976. In copy mode the modal title is the singular "Copy Spool", so the
  977. quantity-driven bulkCreateMutation path would silently produce N
  978. copies under a misleading title — keep this toggle out of that
  979. mode entirely. */}
  980. {mode === 'create' && (
  981. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  982. <div className="flex items-center gap-2">
  983. <Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  984. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  985. </div>
  986. <button
  987. type="button"
  988. onClick={() => {
  989. setQuickAdd(!quickAdd);
  990. if (!quickAdd) setActiveTab('filament');
  991. }}
  992. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  993. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  994. }`}
  995. >
  996. <span
  997. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  998. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  999. }`}
  1000. />
  1001. </button>
  1002. </div>
  1003. )}
  1004. {/* Tabs */}
  1005. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  1006. <button
  1007. onClick={() => setActiveTab('filament')}
  1008. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  1009. activeTab === 'filament'
  1010. ? 'text-bambu-green border-b-2 border-bambu-green'
  1011. : 'text-bambu-gray hover:text-white'
  1012. }`}
  1013. >
  1014. <Beaker className="w-4 h-4" />
  1015. {t('inventory.filamentInfoTab')}
  1016. </button>
  1017. {!quickAdd && (
  1018. <button
  1019. onClick={() => setActiveTab('appearance')}
  1020. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  1021. activeTab === 'appearance'
  1022. ? 'text-bambu-green border-b-2 border-bambu-green'
  1023. : 'text-bambu-gray hover:text-white'
  1024. }`}
  1025. >
  1026. <Palette className="w-4 h-4" />
  1027. {t('inventory.colorAndCostTab')}
  1028. </button>
  1029. )}
  1030. {!quickAdd && (
  1031. <button
  1032. onClick={() => setActiveTab('printers')}
  1033. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  1034. activeTab === 'printers'
  1035. ? 'text-bambu-green border-b-2 border-bambu-green'
  1036. : 'text-bambu-gray hover:text-white'
  1037. }`}
  1038. >
  1039. <Zap className="w-4 h-4" />
  1040. {t('inventory.printersTab')}
  1041. {selectedProfileCount > 0 && (
  1042. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  1043. {selectedProfileCount}
  1044. </span>
  1045. )}
  1046. </button>
  1047. )}
  1048. </div>
  1049. {/* Content */}
  1050. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  1051. {activeTab === 'filament' ? (
  1052. <div className="space-y-6">
  1053. {/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
  1054. {spoolmanMode && !isEditing && (
  1055. <div>
  1056. {filamentsError ? (
  1057. <p className="text-sm text-red-700 dark:text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
  1058. ) : (
  1059. <SpoolmanFilamentPicker
  1060. filaments={spoolmanFilaments}
  1061. isLoading={isLoadingFilaments}
  1062. selectedId={formData.spoolman_filament_id}
  1063. onSelect={handleFilamentSelect}
  1064. />
  1065. )}
  1066. </div>
  1067. )}
  1068. {/* Filament Info Section */}
  1069. <div>
  1070. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  1071. {t('inventory.filamentInfo')}
  1072. </h3>
  1073. <FilamentSection
  1074. formData={formData}
  1075. updateField={updateField}
  1076. cloudAuthenticated={cloudAuthenticated}
  1077. loadingCloudPresets={loadingCloudPresets}
  1078. presetInputValue={presetInputValue}
  1079. setPresetInputValue={setPresetInputValue}
  1080. selectedPresetOption={selectedPresetOption}
  1081. filamentOptions={filamentOptions}
  1082. availableBrands={availableBrands}
  1083. availableMaterials={availableMaterials}
  1084. suggestedBrands={suggestedBrands}
  1085. suggestedMaterials={suggestedMaterials}
  1086. quickAdd={quickAdd}
  1087. detailsRequired={!quickAdd && !spoolmanMode && mode === 'create'}
  1088. quantity={quantity}
  1089. onQuantityChange={setQuantity}
  1090. errors={errors}
  1091. />
  1092. </div>
  1093. </div>
  1094. ) : activeTab === 'appearance' ? (
  1095. <div className="space-y-6">
  1096. {/* Color Section */}
  1097. <div>
  1098. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  1099. {t('inventory.color')}
  1100. </h3>
  1101. <ColorSection
  1102. formData={formData}
  1103. updateField={updateField}
  1104. recentColors={recentColors}
  1105. onColorUsed={handleColorUsed}
  1106. catalogColors={colorCatalog}
  1107. />
  1108. </div>
  1109. {/* Additional Section */}
  1110. <div>
  1111. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  1112. {t('inventory.additional')}
  1113. </h3>
  1114. <AdditionalSection
  1115. formData={formData}
  1116. updateField={updateField}
  1117. spoolCatalog={spoolCatalog}
  1118. currencySymbol={currencySymbol}
  1119. availableCategories={availableCategories}
  1120. availableMaterialNumbers={availableMaterialNumbers}
  1121. availableLocations={storageLocations}
  1122. showLastDried={isEditing}
  1123. onCreateLocation={async (name) => {
  1124. try {
  1125. const created = await api.createLocation({ name });
  1126. setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
  1127. await invalidateInventoryLocations(queryClient);
  1128. return { id: created.id, name: created.name };
  1129. } catch (e) {
  1130. // Surface the backend's actual error so the user can
  1131. // distinguish 409 duplicate / 400 validation / 500 from
  1132. // a generic "save failed" message.
  1133. console.error(e);
  1134. const message = e instanceof Error ? e.message : t('locations.saveFailed');
  1135. showToast(message || t('locations.saveFailed'), 'error');
  1136. return null;
  1137. }
  1138. }}
  1139. globalLowStockThreshold={globalLowStockThreshold}
  1140. spoolmanMode={spoolmanMode}
  1141. />
  1142. </div>
  1143. {/* Suppliers (#2988) — both inventories: the assignments live
  1144. Bambuddy-side either way (Spoolman's vendor is the
  1145. manufacturer, not the seller), so the same section renders
  1146. in Spoolman mode and saves to the twin endpoint. */}
  1147. <div>
  1148. <SupplierSection
  1149. links={supplierLinks}
  1150. onChange={(next) => {
  1151. setSupplierLinks(next);
  1152. setSupplierLinksTouched(true);
  1153. }}
  1154. currencySymbol={currencySymbol}
  1155. />
  1156. </div>
  1157. {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
  1158. {isEditing && spool && !spoolmanMode && (
  1159. <div>
  1160. <SpoolUsageHistory spoolId={spool.id} />
  1161. </div>
  1162. )}
  1163. </div>
  1164. ) : (
  1165. <PrinterProfilesSection
  1166. formData={formData}
  1167. printersWithCalibrations={resolvedCalibrations}
  1168. filamentOptions={filamentOptions}
  1169. modelPresets={modelPresets}
  1170. setModelPresets={setModelPresets}
  1171. selectedProfiles={selectedProfiles}
  1172. setSelectedProfiles={setSelectedProfiles}
  1173. selectedGroupId={selectedGroupId}
  1174. setSelectedGroupId={setSelectedGroupId}
  1175. printerModels={printerModelsData}
  1176. isLoading={loadingCalibrations}
  1177. />
  1178. )}
  1179. </div>
  1180. {/* Footer */}
  1181. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  1182. {isEditing && (
  1183. <div className="flex gap-2 mr-auto">
  1184. {/* Either identifier counts as "tagged". A Bambu Lab spool is
  1185. linked by its 32-char tray UUID and carries no tag_uid at all
  1186. -- in Spoolman mode that is every Bambu spool, because
  1187. _map_spoolman_spool splits extra.tag by length -- so gating on
  1188. tag_uid alone left this permanently greyed out for them, while
  1189. the Tag ID column beside it showed the UUID and the payload
  1190. below already cleared both fields (#3109). */}
  1191. <Button
  1192. variant="secondary"
  1193. onClick={() => deleteTagMutation.mutate()}
  1194. disabled={isPending || !(spool?.tag_uid || spool?.tray_uuid)}
  1195. >
  1196. <Tag className="w-4 h-4" />
  1197. {t('inventory.clearRfid', 'Clear RFID Tag')}
  1198. </Button>
  1199. {spoolAssignment ? (
  1200. <Button
  1201. variant="secondary"
  1202. onClick={() => unassignMutation.mutate()}
  1203. disabled={isPending || assignmentLoading}
  1204. >
  1205. <Unlink className="w-4 h-4" />
  1206. {t('inventory.unassignSpool', 'Unassign')}
  1207. </Button>
  1208. ) : (
  1209. <Button
  1210. variant="secondary"
  1211. onClick={() => setShowAssignModal(true)}
  1212. disabled={isPending || assignmentLoading}
  1213. >
  1214. <Link2 className="w-4 h-4" />
  1215. {t('inventory.assignSpool', 'Assign Spool')}
  1216. </Button>
  1217. )}
  1218. </div>
  1219. )}
  1220. <div className="flex gap-2 ml-auto">
  1221. <Button variant="secondary" onClick={onClose}>
  1222. {t('common.cancel')}
  1223. </Button>
  1224. <Button
  1225. onClick={handleSubmit}
  1226. disabled={isPending}
  1227. >
  1228. {isPending ? (
  1229. <>
  1230. <Loader2 className="w-4 h-4 animate-spin" />
  1231. {t('common.saving')}
  1232. </>
  1233. ) : (
  1234. <>
  1235. <Save className="w-4 h-4" />
  1236. {isEditing ? t('common.save') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
  1237. </>
  1238. )}
  1239. </Button>
  1240. </div>
  1241. </div>
  1242. </div>
  1243. </div>
  1244. {isEditing && spool && (
  1245. <AssignToAmsModal
  1246. isOpen={showAssignModal}
  1247. onClose={() => setShowAssignModal(false)}
  1248. spool={spool}
  1249. printerId={null}
  1250. variant="dialog"
  1251. spoolmanMode={spoolmanMode}
  1252. />
  1253. )}
  1254. </>
  1255. );
  1256. }