mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
1033 lines
42 KiB
TypeScript
1033 lines
42 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
||
import { api, ApiError } from '../api/client';
|
||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||
import { Button } from './Button';
|
||
import { useToast } from '../contexts/ToastContext';
|
||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
|
||
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
||
import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
|
||
import { MATERIALS } from './spool-form/constants';
|
||
import { FilamentSection } from './spool-form/FilamentSection';
|
||
import { ColorSection } from './spool-form/ColorSection';
|
||
import { AdditionalSection } from './spool-form/AdditionalSection';
|
||
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
||
import { PAProfileSection } from './spool-form/PAProfileSection';
|
||
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
||
import {
|
||
invalidateInventoryLocations,
|
||
invalidateSpoolAndLocationQueries,
|
||
} from '../utils/inventoryQueries';
|
||
|
||
type TabId = 'filament' | 'pa-profile';
|
||
|
||
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
||
|
||
export type SpoolFormMode = 'create' | 'edit' | 'copy';
|
||
|
||
interface SpoolFormModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
spool?: InventorySpool | null;
|
||
mode: SpoolFormMode;
|
||
printersWithCalibrations?: PrinterWithCalibrations[];
|
||
currencySymbol: string;
|
||
onSpoolsCreated?: (spools: InventorySpool[]) => void;
|
||
/** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
|
||
spoolmanMode?: boolean;
|
||
/** Query key to invalidate after mutations (differs for Spoolman vs local). */
|
||
spoolsQueryKey?: string[];
|
||
}
|
||
|
||
export function SpoolFormModal({
|
||
isOpen,
|
||
onClose,
|
||
spool,
|
||
mode,
|
||
printersWithCalibrations = [],
|
||
currencySymbol,
|
||
onSpoolsCreated,
|
||
spoolmanMode = false,
|
||
spoolsQueryKey = ['inventory-spools'],
|
||
}: SpoolFormModalProps) {
|
||
const { t } = useTranslation();
|
||
const queryClient = useQueryClient();
|
||
const { showToast } = useToast();
|
||
|
||
const refreshSpoolQueries = () =>
|
||
invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
|
||
|
||
const isEditing = mode === 'edit';
|
||
const isCopying = mode === 'copy';
|
||
|
||
// 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);
|
||
const [locationIdTouched, setLocationIdTouched] = useState(false);
|
||
const [quickAdd, setQuickAdd] = useState(false);
|
||
const [quantity, setQuantity] = useState(1);
|
||
|
||
// 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[]>([]);
|
||
const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
|
||
|
||
// Local presets (OrcaSlicer imports)
|
||
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
|
||
|
||
// Built-in filaments (static fallback)
|
||
const [builtinFilaments, setBuiltinFilaments] = useState<BuiltinFilament[]>([]);
|
||
|
||
// Color catalog
|
||
const [colorCatalog, setColorCatalog] = useState<{
|
||
manufacturer: string;
|
||
color_name: string;
|
||
hex_color: string;
|
||
material: string | null;
|
||
// #1340: gradient + effect carried from the catalog entry through to the
|
||
// color picker so they're applied alongside hex + name on selection.
|
||
extra_colors?: string | null;
|
||
effect_type?: 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 = selectedProfiles.size;
|
||
|
||
// Fetch Spoolman filament catalog when in Spoolman mode
|
||
// retry:false — Spoolman may be intentionally disabled (400); don't flood the server
|
||
const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
|
||
queryKey: ['spoolman-inventory-filaments'],
|
||
queryFn: () => api.getSpoolmanInventoryFilaments(),
|
||
enabled: spoolmanMode && isOpen,
|
||
staleTime: 60_000,
|
||
retry: false,
|
||
});
|
||
|
||
// Load recent colors on mount
|
||
useEffect(() => {
|
||
setRecentColors(loadRecentColors());
|
||
}, []);
|
||
|
||
// Fetch cloud presets and catalog when modal opens. Fetches Bambu Cloud
|
||
// and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
|
||
// since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
|
||
// (same fields, same semantics). ``cloudAuthenticated`` flips on if either
|
||
// cloud is connected — the UI only uses it to gate "no cloud" hints.
|
||
useEffect(() => {
|
||
// ``cancelled`` gates every state setter so a fetch that resolves AFTER
|
||
// the modal closes / unmounts can't fire setState on a torn-down
|
||
// component. Without this guard the parallel Promise.allSettled chain
|
||
// can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
|
||
// after vitest has dismantled the JSDOM window — surfaced as an
|
||
// "Unhandled Rejection: window is not defined" in CI runs.
|
||
let cancelled = false;
|
||
if (isOpen) {
|
||
const fetchData = async () => {
|
||
setLoadingCloudPresets(true);
|
||
try {
|
||
const [bambuResult, orcaResult] = await Promise.allSettled([
|
||
(async () => {
|
||
const status = await api.getCloudStatus();
|
||
if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
|
||
const presets = await api.getFilamentPresets();
|
||
return { connected: true, presets };
|
||
})(),
|
||
(async () => {
|
||
const status = await api.orcaCloudStatus();
|
||
if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
|
||
const list = await api.orcaCloudListProfiles();
|
||
// OrcaProfileMeta is structurally identical to SlicerSetting.
|
||
return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
|
||
})(),
|
||
]);
|
||
if (cancelled) return;
|
||
const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
|
||
const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
|
||
const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
|
||
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
||
setCloudAuthenticated(bambuConnected || orcaConnected);
|
||
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
||
} catch (e) {
|
||
if (cancelled) return;
|
||
console.error('Failed to fetch cloud presets:', e);
|
||
setCloudAuthenticated(false);
|
||
} finally {
|
||
if (!cancelled) setLoadingCloudPresets(false);
|
||
}
|
||
};
|
||
fetchData();
|
||
if (!spoolmanMode) {
|
||
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
|
||
}
|
||
api.getColorCatalog().then(setColorCatalog).catch(console.error);
|
||
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
|
||
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
|
||
api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).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);
|
||
}
|
||
})();
|
||
}
|
||
}
|
||
// The effect intentionally depends only on `isOpen` (and the prop-side
|
||
// calibration count) — re-running on every spoolmanMode toggle would
|
||
// race the in-flight async fetches with unmount/teardown and emit
|
||
// "test environment was torn down" errors in vitest. spoolmanMode only
|
||
// gates a single fetch (getSpoolCatalog) which is cheap enough to skip
|
||
// when the modal opens in Spoolman mode.
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isOpen, printersWithCalibrations.length]);
|
||
|
||
// Build filament options: cloud → local → fallback
|
||
const filamentOptions = useMemo(
|
||
() => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
|
||
[cloudPresets, localPresets, builtinFilaments],
|
||
);
|
||
|
||
// Extract brands from presets
|
||
const baseAvailableBrands = useMemo(() => {
|
||
const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
|
||
const catalogBrands = colorCatalog
|
||
.map(entry => entry.manufacturer?.trim())
|
||
.filter((brand): brand is string => !!brand);
|
||
const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
|
||
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
|
||
}, [cloudPresets, localPresets, colorCatalog]);
|
||
|
||
const baseAvailableMaterials = useMemo(() => {
|
||
const catalogMaterials = colorCatalog
|
||
.map(entry => entry.material?.trim())
|
||
.filter((material): material is string => !!material);
|
||
const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
|
||
return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
|
||
}, [colorCatalog]);
|
||
|
||
const brandMaterialPairs = useMemo(() => {
|
||
const pairs: Array<{ brand: string; material: string }> = [];
|
||
|
||
for (const entry of colorCatalog) {
|
||
const brand = entry.manufacturer?.trim();
|
||
const material = entry.material?.trim();
|
||
if (brand && material) pairs.push({ brand, material });
|
||
}
|
||
|
||
for (const preset of cloudPresets) {
|
||
const parsed = parsePresetName(preset.name);
|
||
if (parsed.brand && parsed.material) {
|
||
pairs.push({ brand: parsed.brand, material: parsed.material });
|
||
}
|
||
}
|
||
|
||
for (const preset of localPresets) {
|
||
const parsed = parsePresetName(preset.name);
|
||
const brand = preset.filament_vendor?.trim() || parsed.brand;
|
||
const material = parsed.material;
|
||
if (brand && material) {
|
||
pairs.push({ brand, material });
|
||
}
|
||
}
|
||
|
||
return pairs;
|
||
}, [cloudPresets, colorCatalog, localPresets]);
|
||
|
||
const brandToMaterials = useMemo(() => {
|
||
const map = new Map<string, Set<string>>();
|
||
for (const pair of brandMaterialPairs) {
|
||
const brandKey = pair.brand.toLowerCase();
|
||
const materialKey = pair.material.toLowerCase();
|
||
if (!map.has(brandKey)) map.set(brandKey, new Set());
|
||
map.get(brandKey)!.add(materialKey);
|
||
}
|
||
return map;
|
||
}, [brandMaterialPairs]);
|
||
|
||
const materialToBrands = useMemo(() => {
|
||
const map = new Map<string, Set<string>>();
|
||
for (const pair of brandMaterialPairs) {
|
||
const brandKey = pair.brand.toLowerCase();
|
||
const materialKey = pair.material.toLowerCase();
|
||
if (!map.has(materialKey)) map.set(materialKey, new Set());
|
||
map.get(materialKey)!.add(brandKey);
|
||
}
|
||
return map;
|
||
}, [brandMaterialPairs]);
|
||
|
||
const availableBrands = useMemo(() => {
|
||
if (!formData.material) return baseAvailableBrands;
|
||
const materialKey = formData.material.toLowerCase();
|
||
const brandKeys = materialToBrands.get(materialKey);
|
||
if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
|
||
return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
|
||
}, [baseAvailableBrands, formData.material, materialToBrands]);
|
||
|
||
const availableMaterials = useMemo(() => {
|
||
if (!formData.brand) return baseAvailableMaterials;
|
||
const brandKey = formData.brand.toLowerCase();
|
||
const materialKeys = brandToMaterials.get(brandKey);
|
||
if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
|
||
return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
|
||
}, [baseAvailableMaterials, formData.brand, brandToMaterials]);
|
||
|
||
// 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) {
|
||
// Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
|
||
// from #1055 before the create/update pattern was enforced). The
|
||
// backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
|
||
// re-submitting a malformed value would 422 every edit on that spool
|
||
// — even edits that don't touch color. Normalize on load: any value
|
||
// that isn't exactly 8 hex chars falls back to the default, so the
|
||
// user can save unrelated fields (weight, material, note) without
|
||
// first being forced to fix a color they may not even be aware is
|
||
// broken. Saving also purges the bad value from the DB.
|
||
const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
|
||
setFormData({
|
||
material: spool.material || '',
|
||
subtype: spool.subtype || '',
|
||
brand: spool.brand || '',
|
||
// #1319: leave color_name blank when the backend reports it was
|
||
// synthesised from subtype — otherwise the form would round-trip
|
||
// the synth value to Spoolman on save as if the user had set it,
|
||
// which is what produced the "color reverts to subtype" symptom.
|
||
color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''),
|
||
rgba: validRgba,
|
||
extra_colors: spool.extra_colors || '',
|
||
effect_type: spool.effect_type || '',
|
||
label_weight: spool.label_weight || 1000,
|
||
core_weight: spool.core_weight || 250,
|
||
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
|
||
weight_used: isCopying ? 0 : spool.weight_used || 0,
|
||
slicer_filament: spool.slicer_filament || '',
|
||
note: spool.note || '',
|
||
cost_per_kg: spool.cost_per_kg ?? null,
|
||
category: spool.category || '',
|
||
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
|
||
location_id: spool.location_id ?? null,
|
||
spoolman_filament_id: null,
|
||
});
|
||
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());
|
||
setQuickAdd(false);
|
||
setQuantity(1);
|
||
}
|
||
setErrors({});
|
||
setActiveTab('filament');
|
||
setWeightTouched(false);
|
||
setLocationIdTouched(false);
|
||
}
|
||
}, [isOpen, spool, mode, isCopying]);
|
||
|
||
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
|
||
useEffect(() => {
|
||
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
|
||
const legacy = spool.storage_location?.trim();
|
||
if (!legacy || storageLocations.length === 0) return;
|
||
const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
|
||
if (match) {
|
||
setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
|
||
}
|
||
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
|
||
|
||
// 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]) => {
|
||
const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
|
||
if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
|
||
showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
|
||
}
|
||
setFormData(prev => ({
|
||
...prev,
|
||
[key]: value,
|
||
...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
|
||
? { spoolman_filament_id: null }
|
||
: {}),
|
||
}));
|
||
if (key === 'weight_used') setWeightTouched(true);
|
||
if (key === 'location_id') setLocationIdTouched(true);
|
||
if (errors[key]) {
|
||
setErrors(prev => ({ ...prev, [key]: undefined }));
|
||
}
|
||
};
|
||
|
||
// Prefill form from a Spoolman filament catalog entry
|
||
// subtype extraction mirrors _spoolman_helpers.py logic
|
||
const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
|
||
const material = filament.material || '';
|
||
const name = filament.name || '';
|
||
const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
|
||
const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
|
||
// Guard against short/malformed hex values — must be exactly 6 hex chars
|
||
const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080';
|
||
setFormData(prev => ({
|
||
...prev,
|
||
spoolman_filament_id: filament.id,
|
||
material,
|
||
subtype,
|
||
brand: filament.vendor?.name || '',
|
||
rgba: `${colorHex}FF`,
|
||
color_name: filament.color_name || '',
|
||
label_weight: filament.weight ?? prev.label_weight,
|
||
}));
|
||
showToast(t('inventory.spoolmanFilamentSelected'), 'success');
|
||
};
|
||
|
||
// Handle color selection
|
||
const handleColorUsed = (color: ColorPreset) => {
|
||
setRecentColors(prev => saveRecentColor(color, prev));
|
||
};
|
||
|
||
// Mutations – dispatch to Spoolman proxy or local inventory based on mode
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: Record<string, unknown>) =>
|
||
spoolmanMode
|
||
? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
|
||
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
||
onSuccess: async (newSpool) => {
|
||
if (newSpool?.id) {
|
||
const ok = await saveKProfiles(newSpool.id);
|
||
if (!ok) return;
|
||
}
|
||
await refreshSpoolQueries();
|
||
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
|
||
showToast(t('inventory.spoolCreated'), 'success');
|
||
onClose();
|
||
},
|
||
onError: (error: Error) => {
|
||
if (error instanceof ApiError && error.status === 503) {
|
||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||
} else {
|
||
showToast(t('inventory.saveFailed'), 'error');
|
||
}
|
||
},
|
||
});
|
||
|
||
const bulkCreateMutation = useMutation<
|
||
SpoolmanBulkCreateResult | InventorySpool[],
|
||
Error,
|
||
{ data: Record<string, unknown>; qty: number }
|
||
>({
|
||
mutationFn: ({ data, qty }) =>
|
||
spoolmanMode
|
||
? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
|
||
: api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
|
||
onSuccess: async (result) => {
|
||
// Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
|
||
// Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
|
||
// the duck-type check ('created' in result) before any property access.
|
||
const spoolmanResult = (spoolmanMode && 'created' in result)
|
||
? (result as unknown as SpoolmanBulkCreateResult)
|
||
: null;
|
||
const createdSpools: InventorySpool[] = spoolmanResult
|
||
? spoolmanResult.created
|
||
: (result as InventorySpool[]);
|
||
|
||
if (selectedProfiles.size > 0) {
|
||
for (const s of createdSpools) {
|
||
await saveKProfiles(s.id);
|
||
}
|
||
}
|
||
await refreshSpoolQueries();
|
||
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
|
||
if (spoolmanResult && spoolmanResult.failed_count > 0) {
|
||
showToast(
|
||
t('inventory.spoolsPartiallyCreated', {
|
||
created: createdSpools.length,
|
||
total: spoolmanResult.requested_count,
|
||
}),
|
||
'warning',
|
||
);
|
||
} else {
|
||
showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
|
||
}
|
||
onClose();
|
||
},
|
||
onError: (error: Error) => {
|
||
if (error instanceof ApiError && error.status === 503) {
|
||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||
} else {
|
||
showToast(t('inventory.saveFailed'), 'error');
|
||
}
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: Record<string, unknown>) =>
|
||
spoolmanMode
|
||
? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
|
||
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||
onSuccess: async () => {
|
||
if (spool?.id) {
|
||
const ok = await saveKProfiles(spool.id);
|
||
if (!ok) return;
|
||
}
|
||
await refreshSpoolQueries();
|
||
showToast(t('inventory.spoolUpdated'), 'success');
|
||
onClose();
|
||
},
|
||
onError: (error: Error) => {
|
||
if (error instanceof ApiError && error.status === 503) {
|
||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||
} else {
|
||
showToast(t('inventory.saveFailed'), 'error');
|
||
}
|
||
},
|
||
});
|
||
|
||
const deleteTagMutation = useMutation({
|
||
mutationFn: () => {
|
||
if (spoolmanMode) {
|
||
return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
|
||
}
|
||
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
|
||
},
|
||
onSuccess: async () => {
|
||
await refreshSpoolQueries();
|
||
showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
|
||
onClose();
|
||
},
|
||
onError: (error: Error) => {
|
||
if (error instanceof ApiError && error.status === 503) {
|
||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||
} else {
|
||
showToast(t('inventory.tagClearFailed'), 'error');
|
||
}
|
||
},
|
||
});
|
||
|
||
// Fetch assignment for this spool (to show Unassign button). In Spoolman mode
|
||
// the slot assignment lives in the spoolman_slot_assignments table keyed by
|
||
// spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
|
||
// resulting "Unassign button is always disabled" report.
|
||
const { data: assignments } = useQuery({
|
||
queryKey: ['spool-assignments'],
|
||
queryFn: () => api.getAssignments(),
|
||
enabled: isOpen && isEditing && !spoolmanMode,
|
||
});
|
||
const { data: spoolmanSlotAssignments } = useQuery({
|
||
queryKey: ['spoolman-slot-assignments-all'],
|
||
queryFn: () => api.getSpoolmanSlotAssignments(),
|
||
enabled: isOpen && isEditing && spoolmanMode,
|
||
});
|
||
const spoolAssignment = (() => {
|
||
if (!spool) return undefined;
|
||
if (spoolmanMode) {
|
||
return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id);
|
||
}
|
||
return assignments?.find(a => a.spool_id === spool.id);
|
||
})();
|
||
|
||
// Read inventory + settings caches (already populated by InventoryPage) to
|
||
// drive the category autocomplete and low-stock-threshold placeholder. #729
|
||
const { data: allSpools } = useQuery({
|
||
queryKey: ['inventory-spools'],
|
||
queryFn: () => api.getSpools(true),
|
||
enabled: isOpen,
|
||
});
|
||
const { data: settingsForForm } = useQuery({
|
||
queryKey: ['settings'],
|
||
queryFn: api.getSettings,
|
||
enabled: isOpen,
|
||
});
|
||
const availableCategories = (() => {
|
||
const set = new Set<string>();
|
||
for (const s of allSpools ?? []) {
|
||
const c = s.category?.trim();
|
||
if (c) set.add(c);
|
||
}
|
||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||
})();
|
||
const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
|
||
|
||
const unassignMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!spoolAssignment) throw new Error('No assignment');
|
||
if (spoolmanMode) {
|
||
if (!spool) throw new Error('No spool');
|
||
await api.unassignSpoolmanSlot(spool.id);
|
||
return;
|
||
}
|
||
await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
|
||
},
|
||
onSuccess: async () => {
|
||
if (spoolmanMode) {
|
||
await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
|
||
await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
|
||
} else {
|
||
await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||
}
|
||
showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
|
||
onClose();
|
||
},
|
||
onError: (error: Error) => {
|
||
showToast(error.message, 'error');
|
||
},
|
||
});
|
||
|
||
// Save K-profiles for selected calibrations. Returns false if any error occurred.
|
||
const saveKProfiles = async (spoolId: number): Promise<boolean> => {
|
||
const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
|
||
|
||
if (selectedProfiles.size === 0) {
|
||
try {
|
||
await saveApi(spoolId, []);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Failed to save K-profiles:', e);
|
||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const profiles: SpoolKProfileInput[] = [];
|
||
let dropped = 0;
|
||
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);
|
||
|
||
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,
|
||
});
|
||
} else {
|
||
dropped++;
|
||
}
|
||
} else {
|
||
dropped++;
|
||
}
|
||
}
|
||
|
||
if (dropped > 0) {
|
||
console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
|
||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||
return false;
|
||
}
|
||
|
||
if (profiles.length > 0) {
|
||
try {
|
||
await saveApi(spoolId, profiles);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Failed to save K-profiles:', e);
|
||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
// 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, quickAdd, spoolmanMode);
|
||
if (!validation.isValid) {
|
||
setErrors(validation.errors);
|
||
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
|
||
setActiveTab('filament');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Find preset name from selected option
|
||
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
|
||
|
||
const data: Record<string, unknown> = {
|
||
material: formData.material || null,
|
||
subtype: formData.subtype || null,
|
||
brand: formData.brand || null,
|
||
color_name: formData.color_name || null,
|
||
rgba: formData.rgba || null,
|
||
extra_colors: formData.extra_colors || null,
|
||
effect_type: formData.effect_type || null,
|
||
label_weight: formData.label_weight,
|
||
...(spoolmanMode ? {} : { 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,
|
||
cost_per_kg: formData.cost_per_kg,
|
||
category: formData.category.trim() || null,
|
||
low_stock_threshold_pct: formData.low_stock_threshold_pct,
|
||
...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
|
||
};
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Only send location_id when creating or when explicitly changed by the user.
|
||
// Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
|
||
if (!isEditing || locationIdTouched) {
|
||
data.location_id = formData.location_id;
|
||
}
|
||
|
||
if (isEditing) {
|
||
updateMutation.mutate(data);
|
||
} else if (quantity > 1) {
|
||
bulkCreateMutation.mutate({ data, qty: quantity });
|
||
} else {
|
||
createMutation.mutate(data);
|
||
}
|
||
};
|
||
|
||
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.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-xl 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 flex items-baseline gap-2">
|
||
{isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
|
||
{isEditing && spool && (
|
||
<span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
|
||
)}
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Add toggle — only in create mode (not edit, not copy).
|
||
In copy mode the modal title is the singular "Copy Spool", so the
|
||
quantity-driven bulkCreateMutation path would silently produce N
|
||
copies under a misleading title — keep this toggle out of that
|
||
mode entirely. */}
|
||
{mode === 'create' && (
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<Zap className="w-4 h-4 text-amber-400" />
|
||
<span className="text-sm text-white">{t('inventory.quickAdd')}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setQuickAdd(!quickAdd);
|
||
if (!quickAdd) setActiveTab('filament');
|
||
}}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
|
||
quickAdd ? 'translate-x-4' : 'translate-x-0.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>
|
||
{!quickAdd && (
|
||
<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">
|
||
{/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
|
||
{spoolmanMode && !isEditing && (
|
||
<div>
|
||
{filamentsError ? (
|
||
<p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
|
||
) : (
|
||
<SpoolmanFilamentPicker
|
||
filaments={spoolmanFilaments}
|
||
isLoading={isLoadingFilaments}
|
||
selectedId={formData.spoolman_filament_id}
|
||
onSelect={handleFilamentSelect}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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}
|
||
availableMaterials={availableMaterials}
|
||
quickAdd={quickAdd}
|
||
quantity={quantity}
|
||
onQuantityChange={setQuantity}
|
||
errors={errors}
|
||
/>
|
||
</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}
|
||
currencySymbol={currencySymbol}
|
||
availableCategories={availableCategories}
|
||
availableLocations={storageLocations}
|
||
onCreateLocation={async (name) => {
|
||
try {
|
||
const created = await api.createLocation({ name });
|
||
setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
|
||
await invalidateInventoryLocations(queryClient);
|
||
return { id: created.id, name: created.name };
|
||
} catch (e) {
|
||
// Surface the backend's actual error so the user can
|
||
// distinguish 409 duplicate / 400 validation / 500 from
|
||
// a generic "save failed" message.
|
||
console.error(e);
|
||
const message = e instanceof Error ? e.message : t('locations.saveFailed');
|
||
showToast(message || t('locations.saveFailed'), 'error');
|
||
return null;
|
||
}
|
||
}}
|
||
globalLowStockThreshold={globalLowStockThreshold}
|
||
spoolmanMode={spoolmanMode}
|
||
/>
|
||
</div>
|
||
|
||
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
|
||
{isEditing && spool && !spoolmanMode && (
|
||
<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 gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
|
||
{isEditing && (
|
||
<div className="flex gap-2 mr-auto">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => deleteTagMutation.mutate()}
|
||
disabled={isPending || !spool?.tag_uid}
|
||
>
|
||
<Tag className="w-4 h-4" />
|
||
{t('inventory.clearRfid', 'Clear RFID Tag')}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => unassignMutation.mutate()}
|
||
disabled={isPending || !spoolAssignment}
|
||
>
|
||
<Unlink className="w-4 h-4" />
|
||
{t('inventory.unassignSpool', 'Unassign')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2 ml-auto">
|
||
<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') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|