| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- import { useState, useEffect, useMemo } from 'react';
- import { useMutation, useQueryClient } from '@tanstack/react-query';
- import { useTranslation } from 'react-i18next';
- import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
- import { api } from '../api/client';
- import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
- import { Button } from './Button';
- import { useToast } from '../contexts/ToastContext';
- import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
- import { defaultFormData, validateForm } from './spool-form/types';
- import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, saveRecentColor } from './spool-form/utils';
- import { FilamentSection } from './spool-form/FilamentSection';
- import { ColorSection } from './spool-form/ColorSection';
- import { AdditionalSection } from './spool-form/AdditionalSection';
- import { PAProfileSection } from './spool-form/PAProfileSection';
- import { SpoolUsageHistory } from './SpoolUsageHistory';
- type TabId = 'filament' | 'pa-profile';
- interface SpoolFormModalProps {
- isOpen: boolean;
- onClose: () => void;
- spool?: InventorySpool | null;
- printersWithCalibrations?: PrinterWithCalibrations[];
- }
- export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [] }: SpoolFormModalProps) {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const isEditing = !!spool;
- // Form state
- const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
- const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
- const [activeTab, setActiveTab] = useState<TabId>('filament');
- const [weightTouched, setWeightTouched] = useState(false);
- // Cloud presets
- const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
- const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
- const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
- const [presetInputValue, setPresetInputValue] = useState('');
- // Spool catalog
- const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
- // Local presets (OrcaSlicer imports)
- const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
- // Color catalog
- const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
- // Color state
- const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
- // PA Profile state
- const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
- const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
- const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
- // Use prop if provided, otherwise use self-fetched data
- const resolvedCalibrations = printersWithCalibrations.length > 0
- ? printersWithCalibrations
- : fetchedCalibrations;
- // Count selected PA profiles for tab badge
- const selectedProfileCount = useMemo(() => {
- return selectedProfiles.size;
- }, [selectedProfiles]);
- // Load recent colors on mount
- useEffect(() => {
- setRecentColors(loadRecentColors());
- }, []);
- // Fetch cloud presets and catalog when modal opens
- useEffect(() => {
- if (isOpen) {
- const fetchData = async () => {
- setLoadingCloudPresets(true);
- try {
- const status = await api.getCloudStatus();
- setCloudAuthenticated(status.is_authenticated);
- if (status.is_authenticated) {
- const presets = await api.getFilamentPresets();
- setCloudPresets(presets);
- }
- } catch (e) {
- console.error('Failed to fetch cloud presets:', e);
- setCloudAuthenticated(false);
- } finally {
- setLoadingCloudPresets(false);
- }
- };
- fetchData();
- api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
- api.getColorCatalog().then(setColorCatalog).catch(console.error);
- api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
- // Fetch printer calibrations if not provided via props
- if (printersWithCalibrations.length === 0) {
- (async () => {
- try {
- const printers = await api.getPrinters();
- const statuses = await Promise.all(
- printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
- );
- const results: PrinterWithCalibrations[] = [];
- for (let i = 0; i < printers.length; i++) {
- const printer = printers[i];
- const status = statuses[i];
- const connected = status?.connected ?? false;
- let calibrations: PrinterWithCalibrations['calibrations'] = [];
- if (connected) {
- try {
- const kRes = await api.getKProfiles(printer.id);
- calibrations = kRes.profiles.map(p => ({
- cali_idx: p.slot_id,
- filament_id: p.filament_id,
- setting_id: p.setting_id || '',
- name: p.name,
- k_value: parseFloat(p.k_value) || 0,
- n_coef: parseFloat(p.n_coef) || 0,
- extruder_id: p.extruder_id,
- nozzle_diameter: p.nozzle_diameter,
- }));
- } catch {
- // Printer may not support K-profiles
- }
- }
- results.push({ printer: { ...printer, connected }, calibrations });
- }
- setFetchedCalibrations(results);
- } catch (e) {
- console.error('Failed to fetch printer calibrations:', e);
- }
- })();
- }
- }
- }, [isOpen, printersWithCalibrations.length]);
- // Build filament options: cloud → local → fallback
- const filamentOptions = useMemo(
- () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
- [cloudPresets, localPresets],
- );
- // Extract brands from presets
- const availableBrands = useMemo(
- () => extractBrandsFromPresets(cloudPresets, localPresets),
- [cloudPresets, localPresets],
- );
- // Find selected preset option
- const selectedPresetOption = useMemo(
- () => findPresetOption(formData.slicer_filament, filamentOptions),
- [formData.slicer_filament, filamentOptions],
- );
- // Reset form when modal opens/closes or spool changes
- useEffect(() => {
- if (isOpen) {
- if (spool) {
- setFormData({
- material: spool.material || '',
- subtype: spool.subtype || '',
- brand: spool.brand || '',
- color_name: spool.color_name || '',
- rgba: spool.rgba || '808080FF',
- label_weight: spool.label_weight || 1000,
- core_weight: spool.core_weight || 250,
- core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
- weight_used: spool.weight_used || 0,
- slicer_filament: spool.slicer_filament || '',
- note: spool.note || '',
- });
- setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
- // Load K-profiles for this spool
- if (spool.k_profiles && spool.k_profiles.length > 0) {
- const profileKeys = new Set<string>();
- for (const p of spool.k_profiles) {
- if (p.cali_idx !== null && p.cali_idx !== undefined) {
- profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
- }
- }
- setSelectedProfiles(profileKeys);
- } else {
- setSelectedProfiles(new Set());
- }
- } else {
- setFormData(defaultFormData);
- setPresetInputValue('');
- setSelectedProfiles(new Set());
- }
- setErrors({});
- setActiveTab('filament');
- setWeightTouched(false);
- }
- }, [isOpen, spool]);
- // Expand all printers in PA profile section when calibrations are available
- useEffect(() => {
- if (isOpen && resolvedCalibrations.length > 0) {
- setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
- }
- }, [isOpen, resolvedCalibrations]);
- // Update field helper
- const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
- setFormData(prev => ({ ...prev, [key]: value }));
- if (key === 'weight_used') setWeightTouched(true);
- if (errors[key]) {
- setErrors(prev => ({ ...prev, [key]: undefined }));
- }
- };
- // Handle color selection
- const handleColorUsed = (color: ColorPreset) => {
- setRecentColors(prev => saveRecentColor(color, prev));
- };
- // Mutations
- const createMutation = useMutation({
- mutationFn: (data: Record<string, unknown>) =>
- api.createSpool(data as Parameters<typeof api.createSpool>[0]),
- onSuccess: async (newSpool) => {
- // Save K-profiles if any selected
- if (selectedProfiles.size > 0 && newSpool?.id) {
- await saveKProfiles(newSpool.id);
- }
- await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
- showToast(t('inventory.spoolCreated'), 'success');
- onClose();
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const updateMutation = useMutation({
- mutationFn: (data: Record<string, unknown>) =>
- api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
- onSuccess: async () => {
- // Save K-profiles
- if (spool?.id) {
- await saveKProfiles(spool.id);
- }
- await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
- showToast(t('inventory.spoolUpdated'), 'success');
- onClose();
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- // Save K-profiles for selected calibrations
- const saveKProfiles = async (spoolId: number) => {
- if (selectedProfiles.size === 0) {
- // Clear existing K-profiles
- try {
- await api.saveSpoolKProfiles(spoolId, []);
- } catch {
- // Ignore
- }
- return;
- }
- const profiles = [];
- for (const key of selectedProfiles) {
- const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
- const printerId = parseInt(printerIdStr);
- const caliIdx = parseInt(caliIdxStr);
- const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
- // Find the matching calibration
- const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
- if (pc) {
- const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
- if (cal) {
- profiles.push({
- printer_id: printerId,
- extruder,
- nozzle_diameter: cal.nozzle_diameter || '0.4',
- k_value: cal.k_value,
- name: cal.name || null,
- cali_idx: cal.cali_idx,
- setting_id: cal.setting_id || null,
- });
- }
- }
- }
- if (profiles.length > 0) {
- try {
- await api.saveSpoolKProfiles(spoolId, profiles);
- } catch (e) {
- console.error('Failed to save K-profiles:', e);
- }
- }
- };
- // Close on Escape key
- useEffect(() => {
- if (!isOpen) return;
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [isOpen, onClose]);
- if (!isOpen) return null;
- const handleSubmit = () => {
- const validation = validateForm(formData);
- if (!validation.isValid) {
- setErrors(validation.errors);
- // Switch to filament tab if there are errors there
- if (validation.errors.slicer_filament || validation.errors.material) {
- setActiveTab('filament');
- }
- return;
- }
- // Find preset name from selected option
- const presetName = selectedPresetOption?.displayName || presetInputValue || null;
- const data: Record<string, unknown> = {
- material: formData.material,
- subtype: formData.subtype || null,
- brand: formData.brand || null,
- color_name: formData.color_name || null,
- rgba: formData.rgba || null,
- label_weight: formData.label_weight,
- core_weight: formData.core_weight,
- core_weight_catalog_id: formData.core_weight_catalog_id,
- slicer_filament: formData.slicer_filament || null,
- slicer_filament_name: presetName,
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: formData.note || null,
- };
- // Only send weight_used when creating or when explicitly changed by the user.
- // This prevents stale cached values from overwriting usage-tracker data.
- if (!isEditing || weightTouched) {
- data.weight_used = formData.weight_used;
- }
- if (isEditing) {
- updateMutation.mutate(data);
- } else {
- createMutation.mutate(data);
- }
- };
- const isPending = createMutation.isPending || updateMutation.isPending;
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center">
- <div
- className="absolute inset-0 bg-black/60 backdrop-blur-sm"
- onClick={onClose}
- />
- <div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
- <h2 className="text-lg font-semibold text-white">
- {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
- </h2>
- <button
- onClick={onClose}
- className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Tabs */}
- <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
- <button
- onClick={() => setActiveTab('filament')}
- className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
- activeTab === 'filament'
- ? 'text-bambu-green border-b-2 border-bambu-green'
- : 'text-bambu-gray hover:text-white'
- }`}
- >
- <Palette className="w-4 h-4" />
- {t('inventory.filamentInfoTab')}
- </button>
- <button
- onClick={() => setActiveTab('pa-profile')}
- className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
- activeTab === 'pa-profile'
- ? 'text-bambu-green border-b-2 border-bambu-green'
- : 'text-bambu-gray hover:text-white'
- }`}
- >
- <Beaker className="w-4 h-4" />
- {t('inventory.paProfileTab')}
- {selectedProfileCount > 0 && (
- <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
- {selectedProfileCount}
- </span>
- )}
- </button>
- </div>
- {/* Content */}
- <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
- {activeTab === 'filament' ? (
- <div className="space-y-6">
- {/* Filament Info Section */}
- <div>
- <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
- {t('inventory.filamentInfo')}
- </h3>
- <FilamentSection
- formData={formData}
- updateField={updateField}
- cloudAuthenticated={cloudAuthenticated}
- loadingCloudPresets={loadingCloudPresets}
- presetInputValue={presetInputValue}
- setPresetInputValue={setPresetInputValue}
- selectedPresetOption={selectedPresetOption}
- filamentOptions={filamentOptions}
- availableBrands={availableBrands}
- />
- {errors.slicer_filament && (
- <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
- )}
- {errors.material && (
- <p className="mt-1 text-xs text-red-400">{errors.material}</p>
- )}
- </div>
- {/* Color Section */}
- <div>
- <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
- {t('inventory.color')}
- </h3>
- <ColorSection
- formData={formData}
- updateField={updateField}
- recentColors={recentColors}
- onColorUsed={handleColorUsed}
- catalogColors={colorCatalog}
- />
- </div>
- {/* Additional Section */}
- <div>
- <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
- {t('inventory.additional')}
- </h3>
- <AdditionalSection
- formData={formData}
- updateField={updateField}
- spoolCatalog={spoolCatalog}
- />
- </div>
- {/* Usage History (only when editing) */}
- {isEditing && spool && (
- <div>
- <SpoolUsageHistory spoolId={spool.id} />
- </div>
- )}
- </div>
- ) : (
- <PAProfileSection
- formData={formData}
- updateField={updateField}
- printersWithCalibrations={resolvedCalibrations}
- selectedProfiles={selectedProfiles}
- setSelectedProfiles={setSelectedProfiles}
- expandedPrinters={expandedPrinters}
- setExpandedPrinters={setExpandedPrinters}
- />
- )}
- </div>
- {/* Footer */}
- <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
- <Button variant="secondary" onClick={onClose}>
- {t('common.cancel')}
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isPending}
- >
- {isPending ? (
- <>
- <Loader2 className="w-4 h-4 animate-spin" />
- {t('common.saving')}
- </>
- ) : (
- <>
- <Save className="w-4 h-4" />
- {isEditing ? t('common.save') : t('inventory.addSpool')}
- </>
- )}
- </Button>
- </div>
- </div>
- </div>
- );
- }
|