import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { X, Loader2, Check, CheckCircle, XCircle, Layers } from 'lucide-react'; import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client'; import { ConfirmModal } from '../ConfirmModal'; import { AmsUnitCard, NozzleBadge } from './AmsUnitCard'; import type { AmsThresholds } from './AmsUnitCard'; import { getFillBarColor } from '../../utils/amsHelpers'; import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors'; import { spoolSwatchStyle } from './spoolPaint'; function getAmsName(id: number): string { if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`; if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`; return `AMS ${id}`; } function isTrayEmpty(tray: AMSTray): boolean { return !tray.tray_type || tray.tray_type === ''; } function trayColorToCSS(color: string | null): string { if (!color) return '#808080'; return `#${color.slice(0, 6)}`; } const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); function getFocusableElements(container: HTMLElement): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) .filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true'); } // --- Material/profile mismatch helpers (pure functions, no component state) --- const normalizeValue = (value: string | undefined | null) => (value ?? '').trim().toUpperCase(); function checkMaterialMatch( spoolMaterial: string | undefined | null, trayMaterial: string | undefined | null ): 'exact' | 'partial' | 'none' { const normalizedSpool = normalizeValue(spoolMaterial); const normalizedTray = normalizeValue(trayMaterial); if (!normalizedSpool || !normalizedTray) return 'none'; if (normalizedSpool === normalizedTray) return 'exact'; if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) { return 'partial'; } return 'none'; } function checkProfileMatch( spoolProfile: string | undefined | null, trayProfile: string | undefined | null ): boolean { const normalizedSpoolProfile = normalizeValue(spoolProfile); const normalizedTrayProfile = normalizeValue(trayProfile); if (!normalizedSpoolProfile || !normalizedTrayProfile) return false; return normalizedSpoolProfile === normalizedTrayProfile; } interface AssignToAmsModalProps { isOpen: boolean; onClose: () => void; spool: InventorySpool; printerId: number | null; variant?: 'dialog' | 'kiosk'; spoolmanMode?: boolean; } export function AssignToAmsModal({ isOpen, onClose, spool, printerId, variant = 'dialog', spoolmanMode = false, }: AssignToAmsModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const dialogRef = useRef(null); const [selectedPrinterId, setSelectedPrinterId] = useState(printerId); const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null); const [statusMessage, setStatusMessage] = useState(null); const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null); const [showMismatchConfirm, setShowMismatchConfirm] = useState(false); // Profile-only mismatches no longer trigger the popup — the backend // pushes the spool's slicer profile to the AMS slot on every assign // anyway, so the warning was friction without benefit (#1552). Material // mismatch still warns because firmware can refuse a print when type // doesn't match. const [mismatchDetails, setMismatchDetails] = useState<{ type: 'material' | 'partial' | 'material_profile' | 'partial_profile'; spoolMaterial: string; trayMaterial: string; spoolProfile?: string; trayProfile?: string; location: string; } | null>(null); const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null); useEffect(() => { if (isOpen) { setSelectedPrinterId(printerId); setSelectedSlot(null); setStatusMessage(null); setStatusType(null); setShowMismatchConfirm(false); setMismatchDetails(null); setPendingSlot(null); } }, [isOpen, printerId, spool.id]); // Printer selection and visual presentation are separate concerns: the // SpoolBuddy kiosk must stay full-screen even when no printer is online and // the picker is shown. const showPrinterPicker = printerId === null; const requiresSlotConfirmation = showPrinterPicker; const targetPrinterId = printerId ?? selectedPrinterId; const { data: selectablePrinters = [], isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), enabled: isOpen && showPrinterPicker, }); const activePrinters = useMemo( () => selectablePrinters.filter((candidate) => candidate.is_active !== false), [selectablePrinters], ); useEffect(() => { if (!isOpen) return; const previousOverflow = document.body.style.overflow; const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; document.body.style.overflow = 'hidden'; const dialog = dialogRef.current; const firstFocusable = dialog ? getFocusableElements(dialog)[0] : null; (firstFocusable ?? dialog)?.focus(); return () => { document.body.style.overflow = previousOverflow; if (previouslyFocused?.isConnected) previouslyFocused.focus(); }; }, [isOpen]); const handleKeyDown = useCallback((event: KeyboardEvent) => { // ConfirmModal is the active modal while a material warning is visible. // Let it own Escape and keyboard focus until it closes. if (showMismatchConfirm) return; if (event.key === 'Escape') { event.preventDefault(); onClose(); return; } if (event.key !== 'Tab') return; const dialog = dialogRef.current; if (!dialog) return; const focusable = getFocusableElements(dialog); if (focusable.length === 0) { event.preventDefault(); dialog.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey && (active === first || !dialog.contains(active))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (active === last || !dialog.contains(active))) { event.preventDefault(); first.focus(); } }, [onClose, showMismatchConfirm]); useEffect(() => { if (!isOpen) return; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleKeyDown]); const { data: status, isLoading: statusLoading } = useQuery({ queryKey: ['printerStatus', targetPrinterId], queryFn: () => api.getPrinterStatus(targetPrinterId!), enabled: isOpen && targetPrinterId !== null, refetchInterval: 5000, }); const { data: printer } = useQuery({ queryKey: ['printer', targetPrinterId], queryFn: () => api.getPrinter(targetPrinterId!), enabled: isOpen && targetPrinterId !== null, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings(), enabled: isOpen, staleTime: 5 * 60 * 1000, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments', targetPrinterId], queryFn: () => api.getAssignments(targetPrinterId!), enabled: isOpen && targetPrinterId !== null, staleTime: 30 * 1000, }); const { data: spoolmanAssignments = [] } = useQuery({ queryKey: ['spoolman-slot-assignments', targetPrinterId], queryFn: () => api.getSpoolmanSlotAssignments(targetPrinterId ?? undefined), enabled: isOpen && !!spoolmanMode && targetPrinterId !== null, staleTime: 30 * 1000, }); const currentAssignment = spoolmanMode ? spoolmanAssignments.find(a => a.spoolman_spool_id === spool.id) : undefined; // Build fill-level override map from inventory assignments const fillOverrides = useMemo(() => { const map: Record = {}; if (!assignments) return map; for (const a of assignments) { const sp = a.spool; if (sp && sp.label_weight > 0 && sp.weight_used != null) { const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); map[`${a.ams_id}-${a.tray_id}`] = fill; } } return map; }, [assignments]); const amsThresholds: AmsThresholds | undefined = settings ? { humidityGood: Number(settings.ams_humidity_good) || 40, humidityFair: Number(settings.ams_humidity_fair) || 60, tempGood: Number(settings.ams_temp_good) || 28, tempFair: Number(settings.ams_temp_fair) || 35, } : undefined; const isConnected = status?.connected ?? false; const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]); const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]); const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]); const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]); const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined; const cachedAmsExtruderMap = useRef>({}); useEffect(() => { cachedAmsExtruderMap.current = {}; }, [targetPrinterId]); useEffect(() => { if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) { cachedAmsExtruderMap.current = status.ams_extruder_map; } }, [status?.ams_extruder_map]); const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) ? status.ams_extruder_map : cachedAmsExtruderMap.current; const ftsInstalled = status?.fila_switch?.installed === true; const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => { if (!isDualNozzle) return null; const mappedExtruderId = amsExtruderMap[String(amsId)]; if (mappedExtruderId !== undefined) return mappedExtruderId === 1 ? 'L' : 'R'; // With a Filament Track Switch every AMS reports extruder 0xE and reaches // both nozzles through the switch, so there is no side to show. The unit-id // guess below would label them all "R" — it exists only for dual-nozzle // printers that never sent a map at all. See PrintersPage.amsSideBadge, // which shows the switch inlet in place of L/R on the printer card. if (ftsInstalled) return null; const normalizedId = amsId >= 128 ? amsId - 128 : amsId; return normalizedId === 1 ? 'L' : 'R'; }, [isDualNozzle, amsExtruderMap, ftsInstalled]); // Assign spool to AMS slot — single API call, backend handles both DB record // AND MQTT auto-configuration. When the target slot is currently empty, the // backend persists the assignment and skips the MQTT publish (firmware drops // it anyway); on_ams_change re-fires the full configuration when filament is // later inserted. The response's `pending_config` flag distinguishes that // from the immediate-apply path so we can adjust the success toast. const configureMutation = useMutation({ mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => { if (!targetPrinterId) throw new Error('No printer selected'); if (spoolmanMode) { return await api.assignSpoolmanSlot({ spoolman_spool_id: spool.id, printer_id: targetPrinterId, ams_id: amsId, tray_id: trayId, }); } return await api.assignSpool({ spool_id: spool.id, printer_id: targetPrinterId, ams_id: amsId, tray_id: trayId, }); }, onSuccess: (assignment) => { setStatusType('success'); // pending_config only exists on SpoolAssignment (the local-inventory path); // the Spoolman path returns InventorySpool which always implies immediate apply. const pendingConfig = assignment && 'pending_config' in assignment && assignment.pending_config; if (pendingConfig) { setStatusMessage( t( 'spoolbuddy.modal.assignPendingInsert', 'Assigned. Slot will configure when you insert the spool.', ), ); } else { setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!')); } queryClient.invalidateQueries({ queryKey: ['slotPresets'] }); queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] }); queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); setTimeout(() => onClose(), pendingConfig ? 2500 : 1500); }, onError: (err) => { setStatusType('error'); setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.')); }, }); const isWaiting = configureMutation.isPending; const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => { if (amsId === 254 || amsId === 255) { const extTrayId = amsId === 254 ? 254 : 254 + trayId; return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null; } const unit = amsUnits.find(u => u.id === amsId); return unit?.tray?.find(t => t.id === trayId) || null; }, [amsUnits, vtTrays]); const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => { if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`; if (amsId >= 128 && amsId <= 135) return getAmsName(amsId); if (!isDualNozzle) return t('printers.ext', 'Ext'); // Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R; // AMS 254 is retained for compatibility with older assignment records. if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L'); return t('printers.extR', 'Ext-R'); }, [t, isDualNozzle]); const doAssign = useCallback((amsId: number, trayId: number) => { setStatusType('info'); setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...')); configureMutation.mutate({ amsId, trayId }); }, [configureMutation, t]); const prepareAssignment = useCallback((amsId: number, trayId: number) => { if (isWaiting) return; if (!settings?.disable_filament_warnings) { const tray = getTrayForSlot(amsId, trayId); if (tray && !isTrayEmpty(tray)) { const trayMaterial = tray.tray_sub_brands || tray.tray_type || ''; const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial); const spoolProfile = spool.slicer_filament_name || spool.slicer_filament; const trayProfile = tray.tray_type || ''; const profileMatches = checkProfileMatch(spoolProfile, trayProfile); if (materialMatchResult !== 'exact') { let mismatchType: 'material' | 'partial' | 'material_profile' | 'partial_profile'; if (materialMatchResult === 'none' && !profileMatches) { mismatchType = 'material_profile'; } else if (materialMatchResult === 'partial' && !profileMatches) { mismatchType = 'partial_profile'; } else if (materialMatchResult === 'none') { mismatchType = 'material'; } else { mismatchType = 'partial'; } const location = getSlotLocationLabel(amsId, trayId); setPendingSlot({ amsId, trayId }); setMismatchDetails({ type: mismatchType, spoolMaterial: spool.material || '', trayMaterial: trayMaterial || '', spoolProfile: spoolProfile || undefined, trayProfile: trayProfile || undefined, location, }); setShowMismatchConfirm(true); return; } } } doAssign(amsId, trayId); }, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]); const handleSlotClick = useCallback((amsId: number, trayId: number) => { if (isWaiting) return; if (requiresSlotConfirmation) { setSelectedSlot({ amsId, trayId }); setStatusMessage(null); setStatusType(null); return; } prepareAssignment(amsId, trayId); }, [requiresSlotConfirmation, isWaiting, prepareAssignment]); const handleAssignSelectedSlot = useCallback(() => { if (!selectedSlot) return; prepareAssignment(selectedSlot.amsId, selectedSlot.trayId); }, [prepareAssignment, selectedSlot]); const handleConfirmMismatch = useCallback(() => { if (!pendingSlot) return; setShowMismatchConfirm(false); setMismatchDetails(null); doAssign(pendingSlot.amsId, pendingSlot.trayId); setPendingSlot(null); }, [pendingSlot, doAssign]); // Build single-slot items (HT + External) const singleSlots = useMemo(() => { const items: { key: string; label: string; amsId: number; trayId: number; tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null; effectiveFill: number | null; }[] = []; for (const unit of htAms) { const tray = unit.tray?.[0] || { id: 0, tray_color: null, tray_type: '', tray_sub_brands: null, tray_id_name: null, tray_info_idx: null, remain: -1, k: null, cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null, }; const invFill = fillOverrides[`${unit.id}-0`] ?? null; const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null; const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill; items.push({ key: `ht-${unit.id}`, label: getAmsName(unit.id), amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray), nozzleSide: getNozzleSide(unit.id), effectiveFill: resolvedInvFill ?? amsFill, }); } for (const extTray of vtTrays) { const extTrayId = extTray.id ?? 254; const extSlotTrayId = extTrayId - 254; const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null; const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null; const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill; items.push({ key: `ext-${extTrayId}`, label: isDualNozzle ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R')) : t('printers.ext', 'Ext'), amsId: 255, trayId: extSlotTrayId, tray: extTray, isEmpty: isTrayEmpty(extTray), nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null, effectiveFill: extResolvedInvFill ?? extAmsFill, }); } return items; }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]); if (!isOpen) return null; const colorStyle = spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba); const selectedSlotLabel = selectedSlot ? getSlotLocationLabel(selectedSlot.amsId, selectedSlot.trayId) : null; const isDialogVariant = variant === 'dialog'; const overlayClasses = isDialogVariant ? 'fixed inset-0 z-[60] flex items-center justify-center bg-black/45 dark:bg-black/70 p-2 sm:p-4 backdrop-blur-sm animate-fade-in' : 'fixed inset-0 z-[60] bg-bambu-dark'; const dialogClasses = isDialogVariant ? 'bg-bambu-dark-secondary text-white font-sans w-full max-w-3xl h-[calc(100vh-1rem)] sm:h-[calc(100vh-2rem)] max-h-[820px] border border-[var(--border-color)] rounded-xl sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-slide-up' : 'w-full h-full bg-bambu-dark flex flex-col'; return ( <>
event.stopPropagation()} > {/* Header */}

{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')} {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || t('spoolbuddy.spool.unknownColor')} • {spool.brand} {spool.material} {spool.subtype && ` ${spool.subtype}`} #{spool.id}

{showPrinterPicker && (
)} {/* Status message */} {statusMessage && (
{statusType === 'info' && } {statusType === 'success' && } {statusType === 'error' && } {statusMessage}
)}
{/* AMS slots */}
{showPrinterPicker && (
2 {t('printModal.selectSlot')}
)} {!targetPrinterId ? (

{t('printModal.selectPrinter')}

) : statusLoading ? (
{t('common.loading')}
) : !isConnected ? (

{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}

) : amsUnits.length === 0 && vtTrays.length === 0 ? (

{t('spoolbuddy.ams.noData', 'No AMS detected')}

{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}

) : ( <> {/* Regular AMS — 2-col grid */} {regularAms.length > 0 && (
{regularAms.map((unit) => ( handleSlotClick(unit.id, trayId)} isDualNozzle={isDualNozzle} nozzleSide={getNozzleSide(unit.id)} thresholds={amsThresholds} fillOverrides={fillOverrides} /> ))}
)} {/* Single-slot items (HT + External) */} {singleSlots.length > 0 && (
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => { const color = trayColorToCSS(tray.tray_color); const isActive = selectedSlot ? selectedSlot.amsId === amsId && selectedSlot.trayId === trayId : !!currentAssignment && currentAssignment.ams_id === amsId && currentAssignment.tray_id === trayId; return ( ); })}
)} )}
{/* Footer */}
{requiresSlotConfirmation && (
{t('printModal.selectSlot')}: {selectedSlotLabel || '—'}
)} {requiresSlotConfirmation && ( )}
{showMismatchConfirm && mismatchDetails && (() => { let message = ''; if (mismatchDetails.type === 'material') { message = t('inventory.assignMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: mismatchDetails.location, }); } else if (mismatchDetails.type === 'partial') { message = t('inventory.assignPartialMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: mismatchDetails.location, }); } else if (mismatchDetails.type === 'material_profile') { message = `${t('inventory.assignMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: mismatchDetails.location, })}\n\n${t('inventory.assignProfileMismatchMessage', { spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'), trayProfile: mismatchDetails.trayProfile || t('common.unknown'), location: mismatchDetails.location, })}`; } else if (mismatchDetails.type === 'partial_profile') { message = `${t('inventory.assignPartialMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: mismatchDetails.location, })}\n\n${t('inventory.assignProfileMismatchMessage', { spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'), trayProfile: mismatchDetails.trayProfile || t('common.unknown'), location: mismatchDetails.location, })}`; } // Always tell the user the AMS slot will be reconfigured — without // this, "Assign Anyway" reads as a no-op confirmation when the // backend in fact pushes the spool profile on every assign (#1552). message = `${message}\n\n${t('inventory.assignReconfigureNote')}`; return ( { if (!configureMutation.isPending) { setShowMismatchConfirm(false); setPendingSlot(null); setMismatchDetails(null); } }} /> ); })()} ); }