SpoolFormModal.tsx 57 KB

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