import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { X, Loader2, 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 } from '../../utils/colors'; 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)}`; } // --- 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; spoolmanMode?: boolean; } export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); 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) { setStatusMessage(null); setStatusType(null); setShowMismatchConfirm(false); setMismatchDetails(null); setPendingSlot(null); } }, [isOpen]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }, [onClose]); useEffect(() => { if (isOpen) document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleKeyDown]); const { data: status } = useQuery({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId!), enabled: isOpen && printerId !== null, refetchInterval: 5000, }); const { data: printer } = useQuery({ queryKey: ['printer', printerId], queryFn: () => api.getPrinter(printerId!), enabled: isOpen && printerId !== null, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings(), enabled: isOpen, staleTime: 5 * 60 * 1000, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments', printerId], queryFn: () => api.getAssignments(printerId!), enabled: isOpen && printerId !== null, staleTime: 30 * 1000, }); const { data: spoolmanAssignments = [] } = useQuery({ queryKey: ['spoolman-slot-assignments', printerId], queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined), enabled: isOpen && !!spoolmanMode && printerId !== 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(() => { 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 (!printerId) throw new Error('No printer selected'); if (spoolmanMode) { return await api.assignSpoolmanSlot({ spoolman_spool_id: spool.id, printer_id: printerId, ams_id: amsId, tray_id: trayId, }); } return await api.assignSpool({ spool_id: spool.id, printer_id: printerId, 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: ['spoolman-slot-assignments'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] }); 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 (amsId === 254) return t('printers.extL', 'Ext-L'); return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext'); }, [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 handleSlotClick = 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 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 = getSwatchStyle(spool.rgba); return ( <>
{/* Header */}

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

{/* Status message */} {statusMessage && (
{statusType === 'info' && } {statusType === 'success' && } {statusType === 'error' && } {statusMessage}
)} {/* AMS slots */}
{!isConnected && printerId ? (

{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 = !!currentAssignment && currentAssignment.ams_id === amsId && currentAssignment.tray_id === trayId; return (
handleSlotClick(amsId, trayId)} className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${ isActive ? 'ring-2 ring-bambu-green' : '' } ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`} >
{isEmpty ? (
) : ( )}
{label} {nozzleSide && }
{isEmpty ? 'Empty' : tray.tray_type || '?'}
{!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
)}
); })}
)} )}
{/* Footer */}
{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); } }} /> ); })()} ); }