SpoolFormModal.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  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, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
  10. import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
  11. import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
  12. import { MATERIALS } from './spool-form/constants';
  13. import { FilamentSection } from './spool-form/FilamentSection';
  14. import { ColorSection } from './spool-form/ColorSection';
  15. import { AdditionalSection } from './spool-form/AdditionalSection';
  16. import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
  17. import { PAProfileSection } from './spool-form/PAProfileSection';
  18. import { SpoolUsageHistory } from './SpoolUsageHistory';
  19. type TabId = 'filament' | 'pa-profile';
  20. const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
  21. interface SpoolFormModalProps {
  22. isOpen: boolean;
  23. onClose: () => void;
  24. spool?: InventorySpool | null;
  25. printersWithCalibrations?: PrinterWithCalibrations[];
  26. currencySymbol: string;
  27. onSpoolsCreated?: (spools: InventorySpool[]) => void;
  28. /** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
  29. spoolmanMode?: boolean;
  30. /** Query key to invalidate after mutations (differs for Spoolman vs local). */
  31. spoolsQueryKey?: string[];
  32. }
  33. export function SpoolFormModal({
  34. isOpen,
  35. onClose,
  36. spool,
  37. printersWithCalibrations = [],
  38. currencySymbol,
  39. onSpoolsCreated,
  40. spoolmanMode = false,
  41. spoolsQueryKey = ['inventory-spools'],
  42. }: SpoolFormModalProps) {
  43. const { t } = useTranslation();
  44. const queryClient = useQueryClient();
  45. const { showToast } = useToast();
  46. const isEditing = !!spool;
  47. // Form state
  48. const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
  49. const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
  50. const [activeTab, setActiveTab] = useState<TabId>('filament');
  51. const [weightTouched, setWeightTouched] = useState(false);
  52. const [storageLocationTouched, setStorageLocationTouched] = useState(false);
  53. const [quickAdd, setQuickAdd] = useState(false);
  54. const [quantity, setQuantity] = useState(1);
  55. // Cloud presets
  56. const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
  57. const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
  58. const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
  59. const [presetInputValue, setPresetInputValue] = useState('');
  60. // Spool catalog
  61. const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
  62. // Local presets (OrcaSlicer imports)
  63. const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
  64. // Color catalog
  65. const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
  66. // Color state
  67. const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
  68. // PA Profile state
  69. const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
  70. const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
  71. const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
  72. // Use prop if provided, otherwise use self-fetched data
  73. const resolvedCalibrations = printersWithCalibrations.length > 0
  74. ? printersWithCalibrations
  75. : fetchedCalibrations;
  76. // Count selected PA profiles for tab badge
  77. const selectedProfileCount = selectedProfiles.size;
  78. // Fetch Spoolman filament catalog when in Spoolman mode
  79. // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
  80. const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
  81. queryKey: ['spoolman-inventory-filaments'],
  82. queryFn: () => api.getSpoolmanInventoryFilaments(),
  83. enabled: spoolmanMode && isOpen,
  84. staleTime: 60_000,
  85. retry: false,
  86. });
  87. // Load recent colors on mount
  88. useEffect(() => {
  89. setRecentColors(loadRecentColors());
  90. }, []);
  91. // Fetch cloud presets and catalog when modal opens
  92. useEffect(() => {
  93. if (isOpen) {
  94. const fetchData = async () => {
  95. setLoadingCloudPresets(true);
  96. try {
  97. const status = await api.getCloudStatus();
  98. setCloudAuthenticated(status.is_authenticated);
  99. if (status.is_authenticated) {
  100. const presets = await api.getFilamentPresets();
  101. setCloudPresets(presets);
  102. }
  103. } catch (e) {
  104. console.error('Failed to fetch cloud presets:', e);
  105. setCloudAuthenticated(false);
  106. } finally {
  107. setLoadingCloudPresets(false);
  108. }
  109. };
  110. fetchData();
  111. if (!spoolmanMode) {
  112. api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
  113. }
  114. api.getColorCatalog().then(setColorCatalog).catch(console.error);
  115. api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
  116. // Fetch printer calibrations if not provided via props
  117. if (printersWithCalibrations.length === 0) {
  118. (async () => {
  119. try {
  120. const printers = await api.getPrinters();
  121. const statuses = await Promise.all(
  122. printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
  123. );
  124. const results: PrinterWithCalibrations[] = [];
  125. for (let i = 0; i < printers.length; i++) {
  126. const printer = printers[i];
  127. const status = statuses[i];
  128. const connected = status?.connected ?? false;
  129. let calibrations: PrinterWithCalibrations['calibrations'] = [];
  130. if (connected) {
  131. try {
  132. const kRes = await api.getKProfiles(printer.id);
  133. calibrations = kRes.profiles.map(p => ({
  134. cali_idx: p.slot_id,
  135. filament_id: p.filament_id,
  136. setting_id: p.setting_id || '',
  137. name: p.name,
  138. k_value: parseFloat(p.k_value) || 0,
  139. n_coef: parseFloat(p.n_coef) || 0,
  140. extruder_id: p.extruder_id,
  141. nozzle_diameter: p.nozzle_diameter,
  142. }));
  143. } catch {
  144. // Printer may not support K-profiles
  145. }
  146. }
  147. results.push({ printer: { ...printer, connected }, calibrations });
  148. }
  149. setFetchedCalibrations(results);
  150. } catch (e) {
  151. console.error('Failed to fetch printer calibrations:', e);
  152. }
  153. })();
  154. }
  155. }
  156. // The effect intentionally depends only on `isOpen` (and the prop-side
  157. // calibration count) — re-running on every spoolmanMode toggle would
  158. // race the in-flight async fetches with unmount/teardown and emit
  159. // "test environment was torn down" errors in vitest. spoolmanMode only
  160. // gates a single fetch (getSpoolCatalog) which is cheap enough to skip
  161. // when the modal opens in Spoolman mode.
  162. // eslint-disable-next-line react-hooks/exhaustive-deps
  163. }, [isOpen, printersWithCalibrations.length]);
  164. // Build filament options: cloud → local → fallback
  165. const filamentOptions = useMemo(
  166. () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
  167. [cloudPresets, localPresets],
  168. );
  169. // Extract brands from presets
  170. const baseAvailableBrands = useMemo(() => {
  171. const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
  172. const catalogBrands = colorCatalog
  173. .map(entry => entry.manufacturer?.trim())
  174. .filter((brand): brand is string => !!brand);
  175. const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
  176. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  177. }, [cloudPresets, localPresets, colorCatalog]);
  178. const baseAvailableMaterials = useMemo(() => {
  179. const catalogMaterials = colorCatalog
  180. .map(entry => entry.material?.trim())
  181. .filter((material): material is string => !!material);
  182. const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
  183. return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
  184. }, [colorCatalog]);
  185. const brandMaterialPairs = useMemo(() => {
  186. const pairs: Array<{ brand: string; material: string }> = [];
  187. for (const entry of colorCatalog) {
  188. const brand = entry.manufacturer?.trim();
  189. const material = entry.material?.trim();
  190. if (brand && material) pairs.push({ brand, material });
  191. }
  192. for (const preset of cloudPresets) {
  193. const parsed = parsePresetName(preset.name);
  194. if (parsed.brand && parsed.material) {
  195. pairs.push({ brand: parsed.brand, material: parsed.material });
  196. }
  197. }
  198. for (const preset of localPresets) {
  199. const parsed = parsePresetName(preset.name);
  200. const brand = preset.filament_vendor?.trim() || parsed.brand;
  201. const material = parsed.material;
  202. if (brand && material) {
  203. pairs.push({ brand, material });
  204. }
  205. }
  206. return pairs;
  207. }, [cloudPresets, colorCatalog, localPresets]);
  208. const brandToMaterials = useMemo(() => {
  209. const map = new Map<string, Set<string>>();
  210. for (const pair of brandMaterialPairs) {
  211. const brandKey = pair.brand.toLowerCase();
  212. const materialKey = pair.material.toLowerCase();
  213. if (!map.has(brandKey)) map.set(brandKey, new Set());
  214. map.get(brandKey)!.add(materialKey);
  215. }
  216. return map;
  217. }, [brandMaterialPairs]);
  218. const materialToBrands = useMemo(() => {
  219. const map = new Map<string, Set<string>>();
  220. for (const pair of brandMaterialPairs) {
  221. const brandKey = pair.brand.toLowerCase();
  222. const materialKey = pair.material.toLowerCase();
  223. if (!map.has(materialKey)) map.set(materialKey, new Set());
  224. map.get(materialKey)!.add(brandKey);
  225. }
  226. return map;
  227. }, [brandMaterialPairs]);
  228. const availableBrands = useMemo(() => {
  229. if (!formData.material) return baseAvailableBrands;
  230. const materialKey = formData.material.toLowerCase();
  231. const brandKeys = materialToBrands.get(materialKey);
  232. if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
  233. return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
  234. }, [baseAvailableBrands, formData.material, materialToBrands]);
  235. const availableMaterials = useMemo(() => {
  236. if (!formData.brand) return baseAvailableMaterials;
  237. const brandKey = formData.brand.toLowerCase();
  238. const materialKeys = brandToMaterials.get(brandKey);
  239. if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
  240. return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
  241. }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
  242. // Find selected preset option
  243. const selectedPresetOption = useMemo(
  244. () => findPresetOption(formData.slicer_filament, filamentOptions),
  245. [formData.slicer_filament, filamentOptions],
  246. );
  247. // Reset form when modal opens/closes or spool changes
  248. useEffect(() => {
  249. if (isOpen) {
  250. if (spool) {
  251. // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
  252. // from #1055 before the create/update pattern was enforced). The
  253. // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
  254. // re-submitting a malformed value would 422 every edit on that spool
  255. // — even edits that don't touch color. Normalize on load: any value
  256. // that isn't exactly 8 hex chars falls back to the default, so the
  257. // user can save unrelated fields (weight, material, note) without
  258. // first being forced to fix a color they may not even be aware is
  259. // broken. Saving also purges the bad value from the DB.
  260. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
  261. setFormData({
  262. material: spool.material || '',
  263. subtype: spool.subtype || '',
  264. brand: spool.brand || '',
  265. color_name: spool.color_name || '',
  266. rgba: validRgba,
  267. extra_colors: spool.extra_colors || '',
  268. effect_type: spool.effect_type || '',
  269. label_weight: spool.label_weight || 1000,
  270. core_weight: spool.core_weight || 250,
  271. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  272. weight_used: spool.weight_used || 0,
  273. slicer_filament: spool.slicer_filament || '',
  274. note: spool.note || '',
  275. cost_per_kg: spool.cost_per_kg ?? null,
  276. category: spool.category || '',
  277. low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
  278. storage_location: spool.storage_location || '',
  279. spoolman_filament_id: null,
  280. });
  281. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  282. // Load K-profiles for this spool
  283. if (spool.k_profiles && spool.k_profiles.length > 0) {
  284. const profileKeys = new Set<string>();
  285. for (const p of spool.k_profiles) {
  286. if (p.cali_idx !== null && p.cali_idx !== undefined) {
  287. profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
  288. }
  289. }
  290. setSelectedProfiles(profileKeys);
  291. } else {
  292. setSelectedProfiles(new Set());
  293. }
  294. } else {
  295. setFormData(defaultFormData);
  296. setPresetInputValue('');
  297. setSelectedProfiles(new Set());
  298. setQuickAdd(false);
  299. setQuantity(1);
  300. }
  301. setErrors({});
  302. setActiveTab('filament');
  303. setWeightTouched(false);
  304. setStorageLocationTouched(false);
  305. }
  306. }, [isOpen, spool]);
  307. // Expand all printers in PA profile section when calibrations are available
  308. useEffect(() => {
  309. if (isOpen && resolvedCalibrations.length > 0) {
  310. setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
  311. }
  312. }, [isOpen, resolvedCalibrations]);
  313. // Update field helper
  314. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  315. const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
  316. if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
  317. showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
  318. }
  319. setFormData(prev => ({
  320. ...prev,
  321. [key]: value,
  322. ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
  323. ? { spoolman_filament_id: null }
  324. : {}),
  325. }));
  326. if (key === 'weight_used') setWeightTouched(true);
  327. if (key === 'storage_location') setStorageLocationTouched(true);
  328. if (errors[key]) {
  329. setErrors(prev => ({ ...prev, [key]: undefined }));
  330. }
  331. };
  332. // Prefill form from a Spoolman filament catalog entry
  333. // subtype extraction mirrors _spoolman_helpers.py logic
  334. const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
  335. const material = filament.material || '';
  336. const name = filament.name || '';
  337. const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
  338. const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
  339. // Guard against short/malformed hex values — must be exactly 6 hex chars
  340. const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080';
  341. setFormData(prev => ({
  342. ...prev,
  343. spoolman_filament_id: filament.id,
  344. material,
  345. subtype,
  346. brand: filament.vendor?.name || '',
  347. rgba: `${colorHex}FF`,
  348. color_name: filament.color_name || '',
  349. label_weight: filament.weight ?? prev.label_weight,
  350. }));
  351. showToast(t('inventory.spoolmanFilamentSelected'), 'success');
  352. };
  353. // Handle color selection
  354. const handleColorUsed = (color: ColorPreset) => {
  355. setRecentColors(prev => saveRecentColor(color, prev));
  356. };
  357. // Mutations – dispatch to Spoolman proxy or local inventory based on mode
  358. const createMutation = useMutation({
  359. mutationFn: (data: Record<string, unknown>) =>
  360. spoolmanMode
  361. ? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
  362. : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  363. onSuccess: async (newSpool) => {
  364. if (newSpool?.id) {
  365. const ok = await saveKProfiles(newSpool.id);
  366. if (!ok) return;
  367. }
  368. await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
  369. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  370. showToast(t('inventory.spoolCreated'), 'success');
  371. onClose();
  372. },
  373. onError: (error: Error) => {
  374. if (error instanceof ApiError && error.status === 503) {
  375. showToast(t('inventory.spoolmanUnreachable'), 'error');
  376. } else {
  377. showToast(t('inventory.saveFailed'), 'error');
  378. }
  379. },
  380. });
  381. const bulkCreateMutation = useMutation<
  382. SpoolmanBulkCreateResult | InventorySpool[],
  383. Error,
  384. { data: Record<string, unknown>; qty: number }
  385. >({
  386. mutationFn: ({ data, qty }) =>
  387. spoolmanMode
  388. ? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
  389. : api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  390. onSuccess: async (result) => {
  391. // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
  392. // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
  393. // the duck-type check ('created' in result) before any property access.
  394. const spoolmanResult = (spoolmanMode && 'created' in result)
  395. ? (result as unknown as SpoolmanBulkCreateResult)
  396. : null;
  397. const createdSpools: InventorySpool[] = spoolmanResult
  398. ? spoolmanResult.created
  399. : (result as InventorySpool[]);
  400. if (selectedProfiles.size > 0) {
  401. for (const s of createdSpools) {
  402. await saveKProfiles(s.id);
  403. }
  404. }
  405. await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
  406. if (onSpoolsCreated) onSpoolsCreated(createdSpools);
  407. if (spoolmanResult && spoolmanResult.failed_count > 0) {
  408. showToast(
  409. t('inventory.spoolsPartiallyCreated', {
  410. created: createdSpools.length,
  411. total: spoolmanResult.requested_count,
  412. }),
  413. 'warning',
  414. );
  415. } else {
  416. showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
  417. }
  418. onClose();
  419. },
  420. onError: (error: Error) => {
  421. if (error instanceof ApiError && error.status === 503) {
  422. showToast(t('inventory.spoolmanUnreachable'), 'error');
  423. } else {
  424. showToast(t('inventory.saveFailed'), 'error');
  425. }
  426. },
  427. });
  428. const updateMutation = useMutation({
  429. mutationFn: (data: Record<string, unknown>) =>
  430. spoolmanMode
  431. ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
  432. : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  433. onSuccess: async () => {
  434. if (spool?.id) {
  435. const ok = await saveKProfiles(spool.id);
  436. if (!ok) return;
  437. }
  438. await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
  439. showToast(t('inventory.spoolUpdated'), 'success');
  440. onClose();
  441. },
  442. onError: (error: Error) => {
  443. if (error instanceof ApiError && error.status === 503) {
  444. showToast(t('inventory.spoolmanUnreachable'), 'error');
  445. } else {
  446. showToast(t('inventory.saveFailed'), 'error');
  447. }
  448. },
  449. });
  450. const deleteTagMutation = useMutation({
  451. mutationFn: () => {
  452. if (spoolmanMode) {
  453. return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
  454. }
  455. return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
  456. },
  457. onSuccess: async () => {
  458. await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
  459. showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
  460. onClose();
  461. },
  462. onError: (error: Error) => {
  463. if (error instanceof ApiError && error.status === 503) {
  464. showToast(t('inventory.spoolmanUnreachable'), 'error');
  465. } else {
  466. showToast(t('inventory.tagClearFailed'), 'error');
  467. }
  468. },
  469. });
  470. // Fetch assignment for this spool (to show Unassign button)
  471. const { data: assignments } = useQuery({
  472. queryKey: ['spool-assignments'],
  473. queryFn: () => api.getAssignments(),
  474. enabled: isOpen && isEditing,
  475. });
  476. const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
  477. // Read inventory + settings caches (already populated by InventoryPage) to
  478. // drive the category autocomplete and low-stock-threshold placeholder. #729
  479. const { data: allSpools } = useQuery({
  480. queryKey: ['inventory-spools'],
  481. queryFn: () => api.getSpools(true),
  482. enabled: isOpen,
  483. });
  484. const { data: settingsForForm } = useQuery({
  485. queryKey: ['settings'],
  486. queryFn: api.getSettings,
  487. enabled: isOpen,
  488. });
  489. const availableCategories = (() => {
  490. const set = new Set<string>();
  491. for (const s of allSpools ?? []) {
  492. const c = s.category?.trim();
  493. if (c) set.add(c);
  494. }
  495. return Array.from(set).sort((a, b) => a.localeCompare(b));
  496. })();
  497. const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
  498. const unassignMutation = useMutation({
  499. mutationFn: () => {
  500. if (!spoolAssignment) throw new Error('No assignment');
  501. return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  502. },
  503. onSuccess: async () => {
  504. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  505. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  506. onClose();
  507. },
  508. onError: (error: Error) => {
  509. showToast(error.message, 'error');
  510. },
  511. });
  512. // Save K-profiles for selected calibrations. Returns false if any error occurred.
  513. const saveKProfiles = async (spoolId: number): Promise<boolean> => {
  514. const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
  515. if (selectedProfiles.size === 0) {
  516. try {
  517. await saveApi(spoolId, []);
  518. return true;
  519. } catch (e) {
  520. console.error('Failed to save K-profiles:', e);
  521. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  522. return false;
  523. }
  524. }
  525. const profiles: SpoolKProfileInput[] = [];
  526. let dropped = 0;
  527. for (const key of selectedProfiles) {
  528. const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
  529. const printerId = parseInt(printerIdStr);
  530. const caliIdx = parseInt(caliIdxStr);
  531. const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
  532. const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
  533. if (pc) {
  534. const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
  535. if (cal) {
  536. profiles.push({
  537. printer_id: printerId,
  538. extruder,
  539. nozzle_diameter: cal.nozzle_diameter || '0.4',
  540. k_value: cal.k_value,
  541. name: cal.name || null,
  542. cali_idx: cal.cali_idx,
  543. setting_id: cal.setting_id || null,
  544. });
  545. } else {
  546. dropped++;
  547. }
  548. } else {
  549. dropped++;
  550. }
  551. }
  552. if (dropped > 0) {
  553. console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
  554. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  555. return false;
  556. }
  557. if (profiles.length > 0) {
  558. try {
  559. await saveApi(spoolId, profiles);
  560. return true;
  561. } catch (e) {
  562. console.error('Failed to save K-profiles:', e);
  563. showToast(t('inventory.kProfileSaveFailed'), 'warning');
  564. return false;
  565. }
  566. }
  567. return true;
  568. };
  569. // Close on Escape key
  570. useEffect(() => {
  571. if (!isOpen) return;
  572. const handleKeyDown = (e: KeyboardEvent) => {
  573. if (e.key === 'Escape') onClose();
  574. };
  575. document.addEventListener('keydown', handleKeyDown);
  576. return () => document.removeEventListener('keydown', handleKeyDown);
  577. }, [isOpen, onClose]);
  578. if (!isOpen) return null;
  579. const handleSubmit = () => {
  580. const validation = validateForm(formData, quickAdd, spoolmanMode);
  581. if (!validation.isValid) {
  582. setErrors(validation.errors);
  583. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  584. setActiveTab('filament');
  585. }
  586. return;
  587. }
  588. // Find preset name from selected option
  589. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  590. const data: Record<string, unknown> = {
  591. material: formData.material || null,
  592. subtype: formData.subtype || null,
  593. brand: formData.brand || null,
  594. color_name: formData.color_name || null,
  595. rgba: formData.rgba || null,
  596. extra_colors: formData.extra_colors || null,
  597. effect_type: formData.effect_type || null,
  598. label_weight: formData.label_weight,
  599. ...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
  600. slicer_filament: formData.slicer_filament || null,
  601. slicer_filament_name: presetName,
  602. nozzle_temp_min: null,
  603. nozzle_temp_max: null,
  604. note: formData.note || null,
  605. cost_per_kg: formData.cost_per_kg,
  606. category: formData.category.trim() || null,
  607. low_stock_threshold_pct: formData.low_stock_threshold_pct,
  608. ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
  609. };
  610. // Only send weight_used when creating or when explicitly changed by the user.
  611. // This prevents stale cached values from overwriting usage-tracker data.
  612. if (!isEditing || weightTouched) {
  613. data.weight_used = formData.weight_used;
  614. }
  615. // Only send storage_location when creating or when explicitly changed by the user.
  616. // This prevents the modal round-trip from overwriting the Spoolman location field
  617. // with a stale cached value when the user saves without touching this field.
  618. if (!isEditing || storageLocationTouched) {
  619. data.storage_location = formData.storage_location || null;
  620. }
  621. if (isEditing) {
  622. updateMutation.mutate(data);
  623. } else if (quantity > 1) {
  624. bulkCreateMutation.mutate({ data, qty: quantity });
  625. } else {
  626. createMutation.mutate(data);
  627. }
  628. };
  629. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  630. return (
  631. <div className="fixed inset-0 z-50 flex items-center justify-center">
  632. <div
  633. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  634. onClick={onClose}
  635. />
  636. <div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
  637. {/* Header */}
  638. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  639. <h2 className="text-lg font-semibold text-white">
  640. {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
  641. </h2>
  642. <button
  643. onClick={onClose}
  644. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  645. >
  646. <X className="w-5 h-5" />
  647. </button>
  648. </div>
  649. {/* Quick Add toggle — only in create mode */}
  650. {!isEditing && (
  651. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  652. <div className="flex items-center gap-2">
  653. <Zap className="w-4 h-4 text-amber-400" />
  654. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  655. </div>
  656. <button
  657. type="button"
  658. onClick={() => {
  659. setQuickAdd(!quickAdd);
  660. if (!quickAdd) setActiveTab('filament');
  661. }}
  662. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  663. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  664. }`}
  665. >
  666. <span
  667. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  668. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  669. }`}
  670. />
  671. </button>
  672. </div>
  673. )}
  674. {/* Tabs */}
  675. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  676. <button
  677. onClick={() => setActiveTab('filament')}
  678. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  679. activeTab === 'filament'
  680. ? 'text-bambu-green border-b-2 border-bambu-green'
  681. : 'text-bambu-gray hover:text-white'
  682. }`}
  683. >
  684. <Palette className="w-4 h-4" />
  685. {t('inventory.filamentInfoTab')}
  686. </button>
  687. {!quickAdd && (
  688. <button
  689. onClick={() => setActiveTab('pa-profile')}
  690. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  691. activeTab === 'pa-profile'
  692. ? 'text-bambu-green border-b-2 border-bambu-green'
  693. : 'text-bambu-gray hover:text-white'
  694. }`}
  695. >
  696. <Beaker className="w-4 h-4" />
  697. {t('inventory.paProfileTab')}
  698. {selectedProfileCount > 0 && (
  699. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  700. {selectedProfileCount}
  701. </span>
  702. )}
  703. </button>
  704. )}
  705. </div>
  706. {/* Content */}
  707. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  708. {activeTab === 'filament' ? (
  709. <div className="space-y-6">
  710. {/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
  711. {spoolmanMode && !isEditing && (
  712. <div>
  713. {filamentsError ? (
  714. <p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
  715. ) : (
  716. <SpoolmanFilamentPicker
  717. filaments={spoolmanFilaments}
  718. isLoading={isLoadingFilaments}
  719. selectedId={formData.spoolman_filament_id}
  720. onSelect={handleFilamentSelect}
  721. />
  722. )}
  723. </div>
  724. )}
  725. {/* Filament Info Section */}
  726. <div>
  727. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  728. {t('inventory.filamentInfo')}
  729. </h3>
  730. <FilamentSection
  731. formData={formData}
  732. updateField={updateField}
  733. cloudAuthenticated={cloudAuthenticated}
  734. loadingCloudPresets={loadingCloudPresets}
  735. presetInputValue={presetInputValue}
  736. setPresetInputValue={setPresetInputValue}
  737. selectedPresetOption={selectedPresetOption}
  738. filamentOptions={filamentOptions}
  739. availableBrands={availableBrands}
  740. availableMaterials={availableMaterials}
  741. quickAdd={quickAdd}
  742. quantity={quantity}
  743. onQuantityChange={setQuantity}
  744. errors={errors}
  745. />
  746. </div>
  747. {/* Color Section */}
  748. <div>
  749. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  750. {t('inventory.color')}
  751. </h3>
  752. <ColorSection
  753. formData={formData}
  754. updateField={updateField}
  755. recentColors={recentColors}
  756. onColorUsed={handleColorUsed}
  757. catalogColors={colorCatalog}
  758. />
  759. </div>
  760. {/* Additional Section */}
  761. <div>
  762. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  763. {t('inventory.additional')}
  764. </h3>
  765. <AdditionalSection
  766. formData={formData}
  767. updateField={updateField}
  768. spoolCatalog={spoolCatalog}
  769. currencySymbol={currencySymbol}
  770. availableCategories={availableCategories}
  771. globalLowStockThreshold={globalLowStockThreshold}
  772. spoolmanMode={spoolmanMode}
  773. />
  774. </div>
  775. {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
  776. {isEditing && spool && !spoolmanMode && (
  777. <div>
  778. <SpoolUsageHistory spoolId={spool.id} />
  779. </div>
  780. )}
  781. </div>
  782. ) : (
  783. <PAProfileSection
  784. formData={formData}
  785. updateField={updateField}
  786. printersWithCalibrations={resolvedCalibrations}
  787. selectedProfiles={selectedProfiles}
  788. setSelectedProfiles={setSelectedProfiles}
  789. expandedPrinters={expandedPrinters}
  790. setExpandedPrinters={setExpandedPrinters}
  791. />
  792. )}
  793. </div>
  794. {/* Footer */}
  795. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  796. {isEditing && (
  797. <div className="flex gap-2 mr-auto">
  798. <Button
  799. variant="secondary"
  800. onClick={() => deleteTagMutation.mutate()}
  801. disabled={isPending || !spool?.tag_uid}
  802. >
  803. <Tag className="w-4 h-4" />
  804. {t('inventory.clearRfid', 'Clear RFID Tag')}
  805. </Button>
  806. <Button
  807. variant="secondary"
  808. onClick={() => unassignMutation.mutate()}
  809. disabled={isPending || !spoolAssignment}
  810. >
  811. <Unlink className="w-4 h-4" />
  812. {t('inventory.unassignSpool', 'Unassign')}
  813. </Button>
  814. </div>
  815. )}
  816. <div className="flex gap-2 ml-auto">
  817. <Button variant="secondary" onClick={onClose}>
  818. {t('common.cancel')}
  819. </Button>
  820. <Button
  821. onClick={handleSubmit}
  822. disabled={isPending}
  823. >
  824. {isPending ? (
  825. <>
  826. <Loader2 className="w-4 h-4 animate-spin" />
  827. {t('common.saving')}
  828. </>
  829. ) : (
  830. <>
  831. <Save className="w-4 h-4" />
  832. {isEditing ? t('common.save') : t('inventory.addSpool')}
  833. </>
  834. )}
  835. </Button>
  836. </div>
  837. </div>
  838. </div>
  839. </div>
  840. );
  841. }