SpoolFormModal.tsx 49 KB

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