SpoolFormModal.tsx 66 KB

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