SpoolFormModal.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  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. setFormData({
  227. material: spool.material || '',
  228. subtype: spool.subtype || '',
  229. brand: spool.brand || '',
  230. color_name: spool.color_name || '',
  231. rgba: spool.rgba || '808080FF',
  232. label_weight: spool.label_weight || 1000,
  233. core_weight: spool.core_weight || 250,
  234. core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
  235. weight_used: spool.weight_used || 0,
  236. slicer_filament: spool.slicer_filament || '',
  237. note: spool.note || '',
  238. cost_per_kg: spool.cost_per_kg ?? null,
  239. });
  240. setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
  241. // Load K-profiles for this spool
  242. if (spool.k_profiles && spool.k_profiles.length > 0) {
  243. const profileKeys = new Set<string>();
  244. for (const p of spool.k_profiles) {
  245. if (p.cali_idx !== null && p.cali_idx !== undefined) {
  246. profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
  247. }
  248. }
  249. setSelectedProfiles(profileKeys);
  250. } else {
  251. setSelectedProfiles(new Set());
  252. }
  253. } else {
  254. setFormData(defaultFormData);
  255. setPresetInputValue('');
  256. setSelectedProfiles(new Set());
  257. setQuickAdd(false);
  258. setQuantity(1);
  259. }
  260. setErrors({});
  261. setActiveTab('filament');
  262. setWeightTouched(false);
  263. }
  264. }, [isOpen, spool]);
  265. // Expand all printers in PA profile section when calibrations are available
  266. useEffect(() => {
  267. if (isOpen && resolvedCalibrations.length > 0) {
  268. setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
  269. }
  270. }, [isOpen, resolvedCalibrations]);
  271. // Update field helper
  272. const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
  273. setFormData(prev => ({ ...prev, [key]: value }));
  274. if (key === 'weight_used') setWeightTouched(true);
  275. if (errors[key]) {
  276. setErrors(prev => ({ ...prev, [key]: undefined }));
  277. }
  278. };
  279. // Handle color selection
  280. const handleColorUsed = (color: ColorPreset) => {
  281. setRecentColors(prev => saveRecentColor(color, prev));
  282. };
  283. // Mutations
  284. const createMutation = useMutation({
  285. mutationFn: (data: Record<string, unknown>) =>
  286. api.createSpool(data as Parameters<typeof api.createSpool>[0]),
  287. onSuccess: async (newSpool) => {
  288. // Save K-profiles if any selected
  289. if (selectedProfiles.size > 0 && newSpool?.id) {
  290. await saveKProfiles(newSpool.id);
  291. }
  292. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  293. if (onSpoolsCreated) onSpoolsCreated([newSpool]);
  294. showToast(t('inventory.spoolCreated'), 'success');
  295. onClose();
  296. },
  297. onError: (error: Error) => {
  298. showToast(error.message, 'error');
  299. },
  300. });
  301. const bulkCreateMutation = useMutation({
  302. mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
  303. api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
  304. onSuccess: async (newSpools) => {
  305. if (selectedProfiles.size > 0) {
  306. for (const spool of newSpools) {
  307. await saveKProfiles(spool.id);
  308. }
  309. }
  310. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  311. if (onSpoolsCreated) onSpoolsCreated(newSpools);
  312. showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
  313. onClose();
  314. },
  315. onError: (error: Error) => {
  316. showToast(error.message, 'error');
  317. },
  318. });
  319. const updateMutation = useMutation({
  320. mutationFn: (data: Record<string, unknown>) =>
  321. api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
  322. onSuccess: async () => {
  323. // Save K-profiles
  324. if (spool?.id) {
  325. await saveKProfiles(spool.id);
  326. }
  327. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  328. showToast(t('inventory.spoolUpdated'), 'success');
  329. onClose();
  330. },
  331. onError: (error: Error) => {
  332. showToast(error.message, 'error');
  333. },
  334. });
  335. const deleteTagMutation = useMutation({
  336. mutationFn: () =>
  337. api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
  338. onSuccess: async () => {
  339. await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  340. showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
  341. onClose();
  342. },
  343. onError: (error: Error) => {
  344. showToast(error.message, 'error');
  345. },
  346. });
  347. // Fetch assignment for this spool (to show Unassign button)
  348. const { data: assignments } = useQuery({
  349. queryKey: ['spool-assignments'],
  350. queryFn: () => api.getAssignments(),
  351. enabled: isOpen && isEditing,
  352. });
  353. const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
  354. const unassignMutation = useMutation({
  355. mutationFn: () => {
  356. if (!spoolAssignment) throw new Error('No assignment');
  357. return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
  358. },
  359. onSuccess: async () => {
  360. await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  361. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  362. onClose();
  363. },
  364. onError: (error: Error) => {
  365. showToast(error.message, 'error');
  366. },
  367. });
  368. // Save K-profiles for selected calibrations
  369. const saveKProfiles = async (spoolId: number) => {
  370. if (selectedProfiles.size === 0) {
  371. // Clear existing K-profiles
  372. try {
  373. await api.saveSpoolKProfiles(spoolId, []);
  374. } catch {
  375. // Ignore
  376. }
  377. return;
  378. }
  379. const profiles = [];
  380. for (const key of selectedProfiles) {
  381. const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
  382. const printerId = parseInt(printerIdStr);
  383. const caliIdx = parseInt(caliIdxStr);
  384. const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
  385. // Find the matching calibration
  386. const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
  387. if (pc) {
  388. const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
  389. if (cal) {
  390. profiles.push({
  391. printer_id: printerId,
  392. extruder,
  393. nozzle_diameter: cal.nozzle_diameter || '0.4',
  394. k_value: cal.k_value,
  395. name: cal.name || null,
  396. cali_idx: cal.cali_idx,
  397. setting_id: cal.setting_id || null,
  398. });
  399. }
  400. }
  401. }
  402. if (profiles.length > 0) {
  403. try {
  404. await api.saveSpoolKProfiles(spoolId, profiles);
  405. } catch (e) {
  406. console.error('Failed to save K-profiles:', e);
  407. }
  408. }
  409. };
  410. // Close on Escape key
  411. useEffect(() => {
  412. if (!isOpen) return;
  413. const handleKeyDown = (e: KeyboardEvent) => {
  414. if (e.key === 'Escape') onClose();
  415. };
  416. document.addEventListener('keydown', handleKeyDown);
  417. return () => document.removeEventListener('keydown', handleKeyDown);
  418. }, [isOpen, onClose]);
  419. if (!isOpen) return null;
  420. const handleSubmit = () => {
  421. const validation = validateForm(formData, quickAdd);
  422. if (!validation.isValid) {
  423. setErrors(validation.errors);
  424. // Switch to filament tab if there are errors there
  425. if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
  426. setActiveTab('filament');
  427. }
  428. return;
  429. }
  430. // Find preset name from selected option
  431. const presetName = selectedPresetOption?.displayName || presetInputValue || null;
  432. const data: Record<string, unknown> = {
  433. material: formData.material,
  434. subtype: formData.subtype || null,
  435. brand: formData.brand || null,
  436. color_name: formData.color_name || null,
  437. rgba: formData.rgba || null,
  438. label_weight: formData.label_weight,
  439. core_weight: formData.core_weight,
  440. core_weight_catalog_id: formData.core_weight_catalog_id,
  441. slicer_filament: formData.slicer_filament || null,
  442. slicer_filament_name: presetName,
  443. nozzle_temp_min: null,
  444. nozzle_temp_max: null,
  445. note: formData.note || null,
  446. cost_per_kg: formData.cost_per_kg,
  447. };
  448. // Only send weight_used when creating or when explicitly changed by the user.
  449. // This prevents stale cached values from overwriting usage-tracker data.
  450. if (!isEditing || weightTouched) {
  451. data.weight_used = formData.weight_used;
  452. }
  453. if (isEditing) {
  454. updateMutation.mutate(data);
  455. } else if (quantity > 1) {
  456. bulkCreateMutation.mutate({ data, qty: quantity });
  457. } else {
  458. createMutation.mutate(data);
  459. }
  460. };
  461. const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
  462. return (
  463. <div className="fixed inset-0 z-50 flex items-center justify-center">
  464. <div
  465. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  466. onClick={onClose}
  467. />
  468. <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">
  469. {/* Header */}
  470. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  471. <h2 className="text-lg font-semibold text-white">
  472. {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
  473. </h2>
  474. <button
  475. onClick={onClose}
  476. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  477. >
  478. <X className="w-5 h-5" />
  479. </button>
  480. </div>
  481. {/* Quick Add toggle — only in create mode */}
  482. {!isEditing && (
  483. <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
  484. <div className="flex items-center gap-2">
  485. <Zap className="w-4 h-4 text-amber-400" />
  486. <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
  487. </div>
  488. <button
  489. type="button"
  490. onClick={() => {
  491. setQuickAdd(!quickAdd);
  492. if (!quickAdd) setActiveTab('filament');
  493. }}
  494. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  495. quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  496. }`}
  497. >
  498. <span
  499. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  500. quickAdd ? 'translate-x-4' : 'translate-x-0.5'
  501. }`}
  502. />
  503. </button>
  504. </div>
  505. )}
  506. {/* Tabs */}
  507. <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
  508. <button
  509. onClick={() => setActiveTab('filament')}
  510. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  511. activeTab === 'filament'
  512. ? 'text-bambu-green border-b-2 border-bambu-green'
  513. : 'text-bambu-gray hover:text-white'
  514. }`}
  515. >
  516. <Palette className="w-4 h-4" />
  517. {t('inventory.filamentInfoTab')}
  518. </button>
  519. {!quickAdd && (
  520. <button
  521. onClick={() => setActiveTab('pa-profile')}
  522. className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
  523. activeTab === 'pa-profile'
  524. ? 'text-bambu-green border-b-2 border-bambu-green'
  525. : 'text-bambu-gray hover:text-white'
  526. }`}
  527. >
  528. <Beaker className="w-4 h-4" />
  529. {t('inventory.paProfileTab')}
  530. {selectedProfileCount > 0 && (
  531. <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
  532. {selectedProfileCount}
  533. </span>
  534. )}
  535. </button>
  536. )}
  537. </div>
  538. {/* Content */}
  539. <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
  540. {activeTab === 'filament' ? (
  541. <div className="space-y-6">
  542. {/* Filament Info Section */}
  543. <div>
  544. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  545. {t('inventory.filamentInfo')}
  546. </h3>
  547. <FilamentSection
  548. formData={formData}
  549. updateField={updateField}
  550. cloudAuthenticated={cloudAuthenticated}
  551. loadingCloudPresets={loadingCloudPresets}
  552. presetInputValue={presetInputValue}
  553. setPresetInputValue={setPresetInputValue}
  554. selectedPresetOption={selectedPresetOption}
  555. filamentOptions={filamentOptions}
  556. availableBrands={availableBrands}
  557. availableMaterials={availableMaterials}
  558. quickAdd={quickAdd}
  559. quantity={quantity}
  560. onQuantityChange={setQuantity}
  561. errors={errors}
  562. />
  563. </div>
  564. {/* Color Section */}
  565. <div>
  566. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  567. {t('inventory.color')}
  568. </h3>
  569. <ColorSection
  570. formData={formData}
  571. updateField={updateField}
  572. recentColors={recentColors}
  573. onColorUsed={handleColorUsed}
  574. catalogColors={colorCatalog}
  575. />
  576. </div>
  577. {/* Additional Section */}
  578. <div>
  579. <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
  580. {t('inventory.additional')}
  581. </h3>
  582. <AdditionalSection
  583. formData={formData}
  584. updateField={updateField}
  585. spoolCatalog={spoolCatalog}
  586. currencySymbol={currencySymbol}
  587. />
  588. </div>
  589. {/* Usage History (only when editing) */}
  590. {isEditing && spool && (
  591. <div>
  592. <SpoolUsageHistory spoolId={spool.id} />
  593. </div>
  594. )}
  595. </div>
  596. ) : (
  597. <PAProfileSection
  598. formData={formData}
  599. updateField={updateField}
  600. printersWithCalibrations={resolvedCalibrations}
  601. selectedProfiles={selectedProfiles}
  602. setSelectedProfiles={setSelectedProfiles}
  603. expandedPrinters={expandedPrinters}
  604. setExpandedPrinters={setExpandedPrinters}
  605. />
  606. )}
  607. </div>
  608. {/* Footer */}
  609. <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  610. {isEditing && (
  611. <div className="flex gap-2 mr-auto">
  612. <Button
  613. variant="secondary"
  614. onClick={() => deleteTagMutation.mutate()}
  615. disabled={isPending || !spool?.tag_uid}
  616. >
  617. <Tag className="w-4 h-4" />
  618. {t('inventory.deleteTag', 'Delete Tag')}
  619. </Button>
  620. <Button
  621. variant="secondary"
  622. onClick={() => unassignMutation.mutate()}
  623. disabled={isPending || !spoolAssignment}
  624. >
  625. <Unlink className="w-4 h-4" />
  626. {t('inventory.unassignSpool', 'Unassign')}
  627. </Button>
  628. </div>
  629. )}
  630. <div className="flex gap-2 ml-auto">
  631. <Button variant="secondary" onClick={onClose}>
  632. {t('common.cancel')}
  633. </Button>
  634. <Button
  635. onClick={handleSubmit}
  636. disabled={isPending}
  637. >
  638. {isPending ? (
  639. <>
  640. <Loader2 className="w-4 h-4 animate-spin" />
  641. {t('common.saving')}
  642. </>
  643. ) : (
  644. <>
  645. <Save className="w-4 h-4" />
  646. {isEditing ? t('common.save') : t('inventory.addSpool')}
  647. </>
  648. )}
  649. </Button>
  650. </div>
  651. </div>
  652. </div>
  653. </div>
  654. );
  655. }