SpoolFormModal.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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 } from '../api/client';
  6. import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } 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 } 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 { PAProfileSection } from './spool-form/PAProfileSection';
  17. import { SpoolUsageHistory } from './SpoolUsageHistory';
  18. type TabId = 'filament' | 'pa-profile';
  19. interface SpoolFormModalProps {
  20. isOpen: boolean;
  21. onClose: () => void;
  22. spool?: InventorySpool | null;
  23. printersWithCalibrations?: PrinterWithCalibrations[];
  24. currencySymbol: string;
  25. onSpoolsCreated?: (spools: InventorySpool[]) => void;
  26. }
  27. export function SpoolFormModal({
  28. isOpen,
  29. onClose,
  30. spool,
  31. printersWithCalibrations = [],
  32. currencySymbol,
  33. onSpoolsCreated,
  34. }: SpoolFormModalProps) {
  35. const { t } = useTranslation();
  36. const queryClient = useQueryClient();
  37. const { showToast } = useToast();
  38. const isEditing = !!spool;
  39. // Form state
  40. const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
  41. const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
  42. const [activeTab, setActiveTab] = useState<TabId>('filament');
  43. const [weightTouched, setWeightTouched] = useState(false);
  44. const [quickAdd, setQuickAdd] = useState(false);
  45. const [quantity, setQuantity] = useState(1);
  46. // Cloud presets
  47. const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
  48. const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
  49. const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
  50. const [presetInputValue, setPresetInputValue] = useState('');
  51. // Spool catalog
  52. const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
  53. // Local presets (OrcaSlicer imports)
  54. const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
  55. // Color catalog
  56. const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
  57. // Color state
  58. const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
  59. // PA Profile state
  60. const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
  61. const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
  62. const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
  63. // Use prop if provided, otherwise use self-fetched data
  64. const resolvedCalibrations = printersWithCalibrations.length > 0
  65. ? printersWithCalibrations
  66. : fetchedCalibrations;
  67. // Count selected PA profiles for tab badge
  68. const selectedProfileCount = useMemo(() => {
  69. return selectedProfiles.size;
  70. }, [selectedProfiles]);
  71. // Load recent colors on mount
  72. useEffect(() => {
  73. setRecentColors(loadRecentColors());
  74. }, []);
  75. // Fetch cloud presets and catalog when modal opens
  76. useEffect(() => {
  77. if (isOpen) {
  78. const fetchData = async () => {
  79. setLoadingCloudPresets(true);
  80. try {
  81. const status = await api.getCloudStatus();
  82. setCloudAuthenticated(status.is_authenticated);
  83. if (status.is_authenticated) {
  84. const presets = await api.getFilamentPresets();
  85. setCloudPresets(presets);
  86. }
  87. } catch (e) {
  88. console.error('Failed to fetch cloud presets:', e);
  89. setCloudAuthenticated(false);
  90. } finally {
  91. setLoadingCloudPresets(false);
  92. }
  93. };
  94. fetchData();
  95. api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
  96. api.getColorCatalog().then(setColorCatalog).catch(console.error);
  97. api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
  98. // Fetch printer calibrations if not provided via props
  99. if (printersWithCalibrations.length === 0) {
  100. (async () => {
  101. try {
  102. const printers = await api.getPrinters();
  103. const statuses = await Promise.all(
  104. printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
  105. );
  106. const results: PrinterWithCalibrations[] = [];
  107. for (let i = 0; i < printers.length; i++) {
  108. const printer = printers[i];
  109. const status = statuses[i];
  110. const connected = status?.connected ?? false;
  111. let calibrations: PrinterWithCalibrations['calibrations'] = [];
  112. if (connected) {
  113. try {
  114. const kRes = await api.getKProfiles(printer.id);
  115. calibrations = kRes.profiles.map(p => ({
  116. cali_idx: p.slot_id,
  117. filament_id: p.filament_id,
  118. setting_id: p.setting_id || '',
  119. name: p.name,
  120. k_value: parseFloat(p.k_value) || 0,
  121. n_coef: parseFloat(p.n_coef) || 0,
  122. extruder_id: p.extruder_id,
  123. nozzle_diameter: p.nozzle_diameter,
  124. }));
  125. } catch {
  126. // Printer may not support K-profiles
  127. }
  128. }
  129. results.push({ printer: { ...printer, connected }, calibrations });
  130. }
  131. setFetchedCalibrations(results);
  132. } catch (e) {
  133. console.error('Failed to fetch printer calibrations:', e);
  134. }
  135. })();
  136. }
  137. }
  138. }, [isOpen, printersWithCalibrations.length]);
  139. // Build filament options: cloud → local → fallback
  140. const filamentOptions = useMemo(
  141. () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
  142. [cloudPresets, localPresets],
  143. );
  144. // Extract brands from presets
  145. const baseAvailableBrands = useMemo(() => {
  146. const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
  147. const catalogBrands = colorCatalog
  148. .map(entry => entry.manufacturer?.trim())
  149. .filter((brand): brand is string => !!brand);
  150. const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
  151. return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
  152. }, [cloudPresets, localPresets, colorCatalog]);
  153. const baseAvailableMaterials = useMemo(() => {
  154. const catalogMaterials = colorCatalog
  155. .map(entry => entry.material?.trim())
  156. .filter((material): material is string => !!material);
  157. const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
  158. return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
  159. }, [colorCatalog]);
  160. const brandMaterialPairs = useMemo(() => {
  161. const pairs: Array<{ brand: string; material: string }> = [];
  162. for (const entry of colorCatalog) {
  163. const brand = entry.manufacturer?.trim();
  164. const material = entry.material?.trim();
  165. if (brand && material) pairs.push({ brand, material });
  166. }
  167. for (const preset of cloudPresets) {
  168. const parsed = parsePresetName(preset.name);
  169. if (parsed.brand && parsed.material) {
  170. pairs.push({ brand: parsed.brand, material: parsed.material });
  171. }
  172. }
  173. for (const preset of localPresets) {
  174. const parsed = parsePresetName(preset.name);
  175. const brand = preset.filament_vendor?.trim() || parsed.brand;
  176. const material = parsed.material;
  177. if (brand && material) {
  178. pairs.push({ brand, material });
  179. }
  180. }
  181. return pairs;
  182. }, [cloudPresets, colorCatalog, localPresets]);
  183. const brandToMaterials = useMemo(() => {
  184. const map = new Map<string, Set<string>>();
  185. for (const pair of brandMaterialPairs) {
  186. const brandKey = pair.brand.toLowerCase();
  187. const materialKey = pair.material.toLowerCase();
  188. if (!map.has(brandKey)) map.set(brandKey, new Set());
  189. map.get(brandKey)!.add(materialKey);
  190. }
  191. return map;
  192. }, [brandMaterialPairs]);
  193. const materialToBrands = useMemo(() => {
  194. const map = new Map<string, Set<string>>();
  195. for (const pair of brandMaterialPairs) {
  196. const brandKey = pair.brand.toLowerCase();
  197. const materialKey = pair.material.toLowerCase();
  198. if (!map.has(materialKey)) map.set(materialKey, new Set());
  199. map.get(materialKey)!.add(brandKey);
  200. }
  201. return map;
  202. }, [brandMaterialPairs]);
  203. const availableBrands = useMemo(() => {
  204. if (!formData.material) return baseAvailableBrands;
  205. const materialKey = formData.material.toLowerCase();
  206. const brandKeys = materialToBrands.get(materialKey);
  207. if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
  208. return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
  209. }, [baseAvailableBrands, formData.material, materialToBrands]);
  210. const availableMaterials = useMemo(() => {
  211. if (!formData.brand) return baseAvailableMaterials;
  212. const brandKey = formData.brand.toLowerCase();
  213. const materialKeys = brandToMaterials.get(brandKey);
  214. if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
  215. return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
  216. }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
  217. // Find selected preset option
  218. const selectedPresetOption = useMemo(
  219. () => findPresetOption(formData.slicer_filament, filamentOptions),
  220. [formData.slicer_filament, filamentOptions],
  221. );
  222. // Reset form when modal opens/closes or spool changes
  223. useEffect(() => {
  224. if (isOpen) {
  225. if (spool) {
  226. // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
  227. // from #1055 before the create/update pattern was enforced). The
  228. // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
  229. // re-submitting a malformed value would 422 every edit on that spool
  230. // — even edits that don't touch color. Normalize on load: any value
  231. // that isn't exactly 8 hex chars falls back to the default, so the
  232. // user can save unrelated fields (weight, material, note) without
  233. // first being forced to fix a color they may not even be aware is
  234. // broken. Saving also purges the bad value from the DB.
  235. const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
  236. setFormData({
  237. material: spool.material || '',
  238. subtype: spool.subtype || '',
  239. brand: spool.brand || '',
  240. color_name: spool.color_name || '',
  241. rgba: validRgba,
  242. label_weight: spool.label_weight || 1000,
  243. core_weight: spool.core_weight || 250,
  244. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  245. weight_used: spool.weight_used || 0,
  246. slicer_filament: spool.slicer_filament || '',
  247. note: spool.note || '',
  248. cost_per_kg: spool.cost_per_kg ?? null,
  249. });
  250. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  251. // Load K-profiles for this spool
  252. if (spool.k_profiles && spool.k_profiles.length > 0) {
  253. const profileKeys = new Set<string>();
  254. for (const p of spool.k_profiles) {
  255. if (p.cali_idx !== null && p.cali_idx !== undefined) {
  256. profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
  257. }
  258. }
  259. setSelectedProfiles(profileKeys);
  260. } else {
  261. setSelectedProfiles(new Set());
  262. }
  263. } else {
  264. setFormData(defaultFormData);
  265. setPresetInputValue('');
  266. setSelectedProfiles(new Set());
  267. setQuickAdd(false);
  268. setQuantity(1);
  269. }
  270. setErrors({});
  271. setActiveTab('filament');
  272. setWeightTouched(false);
  273. }
  274. }, [isOpen, spool]);
  275. // Expand all printers in PA profile section when calibrations are available
  276. useEffect(() => {
  277. if (isOpen && resolvedCalibrations.length > 0) {
  278. setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
  279. }
  280. }, [isOpen, resolvedCalibrations]);
  281. // Update field helper
  282. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  283. setFormData(prev => ({ ...prev, [key]: value }));
  284. if (key === 'weight_used') setWeightTouched(true);
  285. if (errors[key]) {
  286. setErrors(prev => ({ ...prev, [key]: undefined }));
  287. }
  288. };
  289. // Handle color selection
  290. const handleColorUsed = (color: ColorPreset) => {
  291. setRecentColors(prev => saveRecentColor(color, prev));
  292. };
  293. // Mutations
  294. const createMutation = useMutation({
  295. mutationFn: (data: Record<string, unknown>) =>
  296. api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  297. onSuccess: async (newSpool) => {
  298. // Save K-profiles if any selected
  299. if (selectedProfiles.size > 0 && newSpool?.id) {
  300. await saveKProfiles(newSpool.id);
  301. }
  302. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  303. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  304. showToast(t('inventory.spoolCreated'), 'success');
  305. onClose();
  306. },
  307. onError: (error: Error) => {
  308. showToast(error.message, 'error');
  309. },
  310. });
  311. const bulkCreateMutation = useMutation({
  312. mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
  313. api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  314. onSuccess: async (newSpools) => {
  315. if (selectedProfiles.size > 0) {
  316. for (const spool of newSpools) {
  317. await saveKProfiles(spool.id);
  318. }
  319. }
  320. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  321. if (onSpoolsCreated) onSpoolsCreated(newSpools);
  322. showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
  323. onClose();
  324. },
  325. onError: (error: Error) => {
  326. showToast(error.message, 'error');
  327. },
  328. });
  329. const updateMutation = useMutation({
  330. mutationFn: (data: Record<string, unknown>) =>
  331. api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  332. onSuccess: async () => {
  333. // Save K-profiles
  334. if (spool?.id) {
  335. await saveKProfiles(spool.id);
  336. }
  337. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  338. showToast(t('inventory.spoolUpdated'), 'success');
  339. onClose();
  340. },
  341. onError: (error: Error) => {
  342. showToast(error.message, 'error');
  343. },
  344. });
  345. const deleteTagMutation = useMutation({
  346. mutationFn: () =>
  347. api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
  348. onSuccess: async () => {
  349. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  350. showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
  351. onClose();
  352. },
  353. onError: (error: Error) => {
  354. showToast(error.message, 'error');
  355. },
  356. });
  357. // Fetch assignment for this spool (to show Unassign button)
  358. const { data: assignments } = useQuery({
  359. queryKey: ['spool-assignments'],
  360. queryFn: () => api.getAssignments(),
  361. enabled: isOpen && isEditing,
  362. });
  363. const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
  364. const unassignMutation = useMutation({
  365. mutationFn: () => {
  366. if (!spoolAssignment) throw new Error('No assignment');
  367. return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  368. },
  369. onSuccess: async () => {
  370. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  371. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  372. onClose();
  373. },
  374. onError: (error: Error) => {
  375. showToast(error.message, 'error');
  376. },
  377. });
  378. // Save K-profiles for selected calibrations
  379. const saveKProfiles = async (spoolId: number) => {
  380. if (selectedProfiles.size === 0) {
  381. // Clear existing K-profiles
  382. try {
  383. await api.saveSpoolKProfiles(spoolId, []);
  384. } catch {
  385. // Ignore
  386. }
  387. return;
  388. }
  389. const profiles = [];
  390. for (const key of selectedProfiles) {
  391. const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
  392. const printerId = parseInt(printerIdStr);
  393. const caliIdx = parseInt(caliIdxStr);
  394. const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
  395. // Find the matching calibration
  396. const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
  397. if (pc) {
  398. const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
  399. if (cal) {
  400. profiles.push({
  401. printer_id: printerId,
  402. extruder,
  403. nozzle_diameter: cal.nozzle_diameter || '0.4',
  404. k_value: cal.k_value,
  405. name: cal.name || null,
  406. cali_idx: cal.cali_idx,
  407. setting_id: cal.setting_id || null,
  408. });
  409. }
  410. }
  411. }
  412. if (profiles.length > 0) {
  413. try {
  414. await api.saveSpoolKProfiles(spoolId, profiles);
  415. } catch (e) {
  416. console.error('Failed to save K-profiles:', e);
  417. }
  418. }
  419. };
  420. // Close on Escape key
  421. useEffect(() => {
  422. if (!isOpen) return;
  423. const handleKeyDown = (e: KeyboardEvent) => {
  424. if (e.key === 'Escape') onClose();
  425. };
  426. document.addEventListener('keydown', handleKeyDown);
  427. return () => document.removeEventListener('keydown', handleKeyDown);
  428. }, [isOpen, onClose]);
  429. if (!isOpen) return null;
  430. const handleSubmit = () => {
  431. const validation = validateForm(formData, quickAdd);
  432. if (!validation.isValid) {
  433. setErrors(validation.errors);
  434. // Switch to filament tab if there are errors there
  435. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  436. setActiveTab('filament');
  437. }
  438. return;
  439. }
  440. // Find preset name from selected option
  441. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  442. const data: Record<string, unknown> = {
  443. material: formData.material,
  444. subtype: formData.subtype || null,
  445. brand: formData.brand || null,
  446. color_name: formData.color_name || null,
  447. rgba: formData.rgba || null,
  448. label_weight: formData.label_weight,
  449. core_weight: formData.core_weight,
  450. core_weight_catalog_id: formData.core_weight_catalog_id,
  451. slicer_filament: formData.slicer_filament || null,
  452. slicer_filament_name: presetName,
  453. nozzle_temp_min: null,
  454. nozzle_temp_max: null,
  455. note: formData.note || null,
  456. cost_per_kg: formData.cost_per_kg,
  457. };
  458. // Only send weight_used when creating or when explicitly changed by the user.
  459. // This prevents stale cached values from overwriting usage-tracker data.
  460. if (!isEditing || weightTouched) {
  461. data.weight_used = formData.weight_used;
  462. }
  463. if (isEditing) {
  464. updateMutation.mutate(data);
  465. } else if (quantity > 1) {
  466. bulkCreateMutation.mutate({ data, qty: quantity });
  467. } else {
  468. createMutation.mutate(data);
  469. }
  470. };
  471. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  472. return (
  473. <div className="fixed inset-0 z-50 flex items-center justify-center">
  474. <div
  475. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  476. onClick={onClose}
  477. />
  478. <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">
  479. {/* Header */}
  480. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  481. <h2 className="text-lg font-semibold text-white">
  482. {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
  483. </h2>
  484. <button
  485. onClick={onClose}
  486. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  487. >
  488. <X className="w-5 h-5" />
  489. </button>
  490. </div>
  491. {/* Quick Add toggle — only in create mode */}
  492. {!isEditing && (
  493. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  494. <div className="flex items-center gap-2">
  495. <Zap className="w-4 h-4 text-amber-400" />
  496. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  497. </div>
  498. <button
  499. type="button"
  500. onClick={() => {
  501. setQuickAdd(!quickAdd);
  502. if (!quickAdd) setActiveTab('filament');
  503. }}
  504. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  505. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  506. }`}
  507. >
  508. <span
  509. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  510. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  511. }`}
  512. />
  513. </button>
  514. </div>
  515. )}
  516. {/* Tabs */}
  517. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  518. <button
  519. onClick={() => setActiveTab('filament')}
  520. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  521. activeTab === 'filament'
  522. ? 'text-bambu-green border-b-2 border-bambu-green'
  523. : 'text-bambu-gray hover:text-white'
  524. }`}
  525. >
  526. <Palette className="w-4 h-4" />
  527. {t('inventory.filamentInfoTab')}
  528. </button>
  529. {!quickAdd && (
  530. <button
  531. onClick={() => setActiveTab('pa-profile')}
  532. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  533. activeTab === 'pa-profile'
  534. ? 'text-bambu-green border-b-2 border-bambu-green'
  535. : 'text-bambu-gray hover:text-white'
  536. }`}
  537. >
  538. <Beaker className="w-4 h-4" />
  539. {t('inventory.paProfileTab')}
  540. {selectedProfileCount > 0 && (
  541. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  542. {selectedProfileCount}
  543. </span>
  544. )}
  545. </button>
  546. )}
  547. </div>
  548. {/* Content */}
  549. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  550. {activeTab === 'filament' ? (
  551. <div className="space-y-6">
  552. {/* Filament Info Section */}
  553. <div>
  554. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  555. {t('inventory.filamentInfo')}
  556. </h3>
  557. <FilamentSection
  558. formData={formData}
  559. updateField={updateField}
  560. cloudAuthenticated={cloudAuthenticated}
  561. loadingCloudPresets={loadingCloudPresets}
  562. presetInputValue={presetInputValue}
  563. setPresetInputValue={setPresetInputValue}
  564. selectedPresetOption={selectedPresetOption}
  565. filamentOptions={filamentOptions}
  566. availableBrands={availableBrands}
  567. availableMaterials={availableMaterials}
  568. quickAdd={quickAdd}
  569. quantity={quantity}
  570. onQuantityChange={setQuantity}
  571. errors={errors}
  572. />
  573. </div>
  574. {/* Color Section */}
  575. <div>
  576. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  577. {t('inventory.color')}
  578. </h3>
  579. <ColorSection
  580. formData={formData}
  581. updateField={updateField}
  582. recentColors={recentColors}
  583. onColorUsed={handleColorUsed}
  584. catalogColors={colorCatalog}
  585. />
  586. </div>
  587. {/* Additional Section */}
  588. <div>
  589. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  590. {t('inventory.additional')}
  591. </h3>
  592. <AdditionalSection
  593. formData={formData}
  594. updateField={updateField}
  595. spoolCatalog={spoolCatalog}
  596. currencySymbol={currencySymbol}
  597. />
  598. </div>
  599. {/* Usage History (only when editing) */}
  600. {isEditing && spool && (
  601. <div>
  602. <SpoolUsageHistory spoolId={spool.id} />
  603. </div>
  604. )}
  605. </div>
  606. ) : (
  607. <PAProfileSection
  608. formData={formData}
  609. updateField={updateField}
  610. printersWithCalibrations={resolvedCalibrations}
  611. selectedProfiles={selectedProfiles}
  612. setSelectedProfiles={setSelectedProfiles}
  613. expandedPrinters={expandedPrinters}
  614. setExpandedPrinters={setExpandedPrinters}
  615. />
  616. )}
  617. </div>
  618. {/* Footer */}
  619. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  620. {isEditing && (
  621. <div className="flex gap-2 mr-auto">
  622. <Button
  623. variant="secondary"
  624. onClick={() => deleteTagMutation.mutate()}
  625. disabled={isPending || !spool?.tag_uid}
  626. >
  627. <Tag className="w-4 h-4" />
  628. {t('inventory.deleteTag', 'Delete Tag')}
  629. </Button>
  630. <Button
  631. variant="secondary"
  632. onClick={() => unassignMutation.mutate()}
  633. disabled={isPending || !spoolAssignment}
  634. >
  635. <Unlink className="w-4 h-4" />
  636. {t('inventory.unassignSpool', 'Unassign')}
  637. </Button>
  638. </div>
  639. )}
  640. <div className="flex gap-2 ml-auto">
  641. <Button variant="secondary" onClick={onClose}>
  642. {t('common.cancel')}
  643. </Button>
  644. <Button
  645. onClick={handleSubmit}
  646. disabled={isPending}
  647. >
  648. {isPending ? (
  649. <>
  650. <Loader2 className="w-4 h-4 animate-spin" />
  651. {t('common.saving')}
  652. </>
  653. ) : (
  654. <>
  655. <Save className="w-4 h-4" />
  656. {isEditing ? t('common.save') : t('inventory.addSpool')}
  657. </>
  658. )}
  659. </Button>
  660. </div>
  661. </div>
  662. </div>
  663. </div>
  664. );
  665. }