import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useOutletContext } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react'; import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout'; import { api } from '../../api/client'; import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client'; import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers'; import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard'; import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard'; import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal'; import { AssignSpoolModal } from '../../components/AssignSpoolModal'; import { LinkSpoolModal } from '../../components/LinkSpoolModal'; import { useToast } from '../../contexts/ToastContext'; function getAmsName(amsId: number): string { if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`; if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`; return `AMS ${amsId}`; } function mapModelCode(ssdpModel: string | null): string { if (!ssdpModel) return ''; const modelMap: Record = { 'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1C2': 'H2C', 'O1S': 'H2S', 'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E', 'N6': 'X2D', 'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S', 'N2S': 'A1', 'N1': 'A1 Mini', 'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S', 'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S', }; return modelMap[ssdpModel] || ssdpModel; } 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)}`; } export function SpoolBuddyAmsPage() { const { selectedPrinterId, setAlert } = useOutletContext(); const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { data: status } = useQuery({ queryKey: ['printerStatus', selectedPrinterId], queryFn: () => api.getPrinterStatus(selectedPrinterId!), enabled: selectedPrinterId !== null, staleTime: 30 * 1000, }); const { data: printer } = useQuery({ queryKey: ['printer', selectedPrinterId], queryFn: () => api.getPrinter(selectedPrinterId!), enabled: selectedPrinterId !== null, staleTime: 60 * 1000, }); const { data: slotPresets } = useQuery({ queryKey: ['slotPresets', selectedPrinterId], queryFn: () => api.getSlotPresets(selectedPrinterId!), enabled: selectedPrinterId !== null, staleTime: 2 * 60 * 1000, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings(), staleTime: 5 * 60 * 1000, }); // Fetch Spoolman status to enable fill-level chain const { data: spoolmanStatus } = useQuery({ queryKey: ['spoolman-status'], queryFn: api.getSpoolmanStatus, staleTime: 60 * 1000, }); const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected; // Fetch linked spools map (tag -> spool info) for Spoolman fill levels const { data: linkedSpoolsData } = useQuery({ queryKey: ['linked-spools'], queryFn: api.getLinkedSpools, enabled: !!spoolmanEnabled, staleTime: 30 * 1000, }); const linkedSpools = linkedSpoolsData?.linked; // Fetch all Spoolman slot assignments + spool metadata so the fill-bar // and the Link/Unlink button reflect slot-assigned-only Spoolman spools // (spools assigned to a slot via AssignToAmsModal but not tag-linked). // Without these, AmsUnitCard shows an empty fill bar and the Link button // stays active even though the slot is occupied. const { data: spoolmanSlotAssignmentsAll = [] } = useQuery({ queryKey: ['spoolman-slot-assignments-all'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: !!spoolmanEnabled, staleTime: 30 * 1000, }); const { data: spoolmanInventorySpoolsCache = [] } = useQuery({ queryKey: ['spoolman-inventory-spools'], queryFn: () => api.getSpoolmanInventorySpools(false), enabled: !!spoolmanEnabled, staleTime: 30 * 1000, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments', selectedPrinterId], queryFn: () => api.getAssignments(selectedPrinterId!), enabled: selectedPrinterId !== null, staleTime: 30 * 1000, }); // Build fill-level override map from inventory assignments // Key: "amsId-trayId", Value: fill percentage (0-100) 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]); // Look up Spoolman fill level for a given tray const printerSerial = printer?.serial_number ?? ''; const getSpoolmanFillForSlot = useCallback((amsId: number, trayId: number, tray: AMSTray | null): number | null => { // Stage 1: slot-assigned Spoolman spool. The user's explicit, recent // action — must outrank the tag-link to avoid #1457, where a non-RFID // slot's deterministic fallback tag stayed bound to the previous spool // in Spoolman's extra.tag and the fill bar reported the old (stale) // spool's remaining weight instead of the freshly assigned one. if (selectedPrinterId !== null && spoolmanSlotAssignmentsAll.length && spoolmanInventorySpoolsCache.length) { const slotAssign = spoolmanSlotAssignmentsAll.find(a => a.printer_id === selectedPrinterId && a.ams_id === amsId && a.tray_id === trayId, ); if (slotAssign) { const spool = spoolmanInventorySpoolsCache.find(s => s.id === slotAssign.spoolman_spool_id); if (spool && (spool.label_weight ?? 0) > 0) { return Math.round(Math.max(0, spool.label_weight - spool.weight_used) / spool.label_weight * 100); } } } // Stage 2: tag-linked spool (linkedSpools map keyed by tag/UUID). if (linkedSpools && printerSerial) { const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase(); const linkedSpool = tag ? linkedSpools[tag] : undefined; const tagFill = getSpoolmanFillLevel(linkedSpool); if (tagFill !== null) return tagFill; } return null; }, [linkedSpools, printerSerial, selectedPrinterId, spoolmanSlotAssignmentsAll, spoolmanInventorySpoolsCache]); const isConnected = status?.connected ?? false; // Cache AMS data per printer to prevent it disappearing on idle/offline printers const cachedAmsData = useRef>({}); useEffect(() => { if (selectedPrinterId && status?.ams && status.ams.length > 0) { cachedAmsData.current[selectedPrinterId] = status.ams; } }, [status?.ams, selectedPrinterId]); const amsUnits = useMemo(() => { const live = status?.ams; if (live && live.length > 0) return live; return (selectedPrinterId ? cachedAmsData.current[selectedPrinterId] : null) ?? []; }, [status?.ams, selectedPrinterId]); const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]); const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]); // Build Spoolman fill-level override map for regular AMS cards const spoolmanFillOverrides = useMemo(() => { const map: Record = {}; if (!linkedSpools || !printerSerial) return map; for (const unit of regularAms) { for (let i = 0; i < (unit.tray?.length ?? 0); i++) { const tray = unit.tray![i]; const fill = getSpoolmanFillForSlot(unit.id, i, isTrayEmpty(tray) ? null : tray); if (fill !== null) map[`${unit.id}-${i}`] = fill; } } return map; }, [linkedSpools, printerSerial, regularAms, getSpoolmanFillForSlot]); // Cache tray_now to prevent flickering when undefined values come in // Valid tray IDs: 0-253 for AMS, 254 for external spool // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active const cachedTrayNow = useRef(undefined); const currentTrayNow = status?.tray_now; if (currentTrayNow !== undefined && currentTrayNow !== 255) { cachedTrayNow.current = currentTrayNow; } else if (currentTrayNow === 255) { cachedTrayNow.current = undefined; } const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255) ? currentTrayNow : cachedTrayNow.current; const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined; const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]); 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; // Cache ams_extruder_map to prevent L/R indicators bouncing on updates 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 getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => { if (!isDualNozzle) return null; const mappedExtruderId = amsExtruderMap[String(amsId)]; const normalizedId = amsId >= 128 ? amsId - 128 : amsId; const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId; // extruder 0 = right, 1 = left return extruderId === 1 ? 'L' : 'R'; }, [isDualNozzle, amsExtruderMap]); const [configureSlotModal, setConfigureSlotModal] = useState<{ amsId: number; trayId: number; trayCount: number; trayType?: string; trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; extruderId?: number; caliIdx?: number | null; savedPresetId?: string; } | null>(null); // Slot action picker: shown before opening configure or assign modal const [slotActionPicker, setSlotActionPicker] = useState<{ amsId: number; trayId: number; trayCount: number; tray: AMSTray | null; trayType?: string; trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; extruderId?: number; caliIdx?: number | null; savedPresetId?: string; location: string; } | null>(null); // Assign spool modal state (inventory) const [assignSpoolModal, setAssignSpoolModal] = useState<{ printerId: number; amsId: number; trayId: number; trayInfo: { type: string; material?: string; profile?: string; color: string; location: string }; } | null>(null); // Link spool modal state (Spoolman) const [linkSpoolModal, setLinkSpoolModal] = useState<{ tagUid: string; trayUuid: string; printerId: number; amsId: number; trayId: number; } | null>(null); const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => { return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId)); }, [assignments]); const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => { if (!linkedSpools || !printerSerial) return undefined; const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase(); return tag ? linkedSpools[tag] : undefined; }, [linkedSpools, printerSerial]); const unassignMutation = useMutation({ mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) => api.unassignSpool(printerId, amsId, trayId), onSuccess: () => { // Two cache-key shapes coexist for spool assignments: this page and a // few SpoolBuddy components key by printerId, while AssignSpoolModal // (and most of Bambuddy) keys without it. Both must be invalidated // here, otherwise after a SpoolBuddy unassign the modal opens with a // stale assignments list, sees the just-freed spool as still taken, // filters it out, and shows "no spools available" — even though it's // sitting in inventory ready to re-assign (#1133 follow-up). queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] }); queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success'); setSlotActionPicker(null); }, }); const unlinkSpoolMutation = useMutation({ mutationFn: (spoolId: number) => api.unlinkSpool(spoolId), onSuccess: (result) => { showToast(t('spoolman.unlinkSuccess') || result?.message, 'success'); queryClient.invalidateQueries({ queryKey: ['linked-spools'] }); queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] }); // Backend (spoolman.py:986) also deletes the SpoolmanSlotAssignment row, // so invalidate every cache that depends on slot assignments. Without // these PrintersPage / InventoryPage / SpoolBuddyDashboard keep showing // the spool as still assigned for ~30s until refetchInterval kicks in. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); setSlotActionPicker(null); }, onError: (error: Error) => { showToast(error.message || t('spoolman.unlinkFailed'), 'error'); }, }); // Unassign a Spoolman spool from an AMS slot (slot-only assignment, no tag link). // Distinct from unlinkSpoolMutation, which also clears the tag binding via // /spoolman/spools//unlink. This one only deletes the SpoolmanSlotAssignment // row so the spool remains tag-linked (if it was) but is no longer bound to a slot. const unassignSpoolmanSlotMutation = useMutation({ mutationFn: (spoolmanSpoolId: number) => api.unassignSpoolmanSlot(spoolmanSpoolId), onSuccess: () => { showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success'); // Invalidate every query that filters or counts spools by slot-assignment // state, otherwise re-opening LinkSpoolModal right after an unassign // shows a stale list that still treats the freed spool as taken. // unlinkSpoolMutation does the same set — keep them aligned so both // unbind paths refresh the same caches. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] }); queryClient.invalidateQueries({ queryKey: ['linked-spools'] }); setSlotActionPicker(null); }, onError: (error: Error) => { showToast(error.message || t('inventory.assignFailed', 'Failed to unassign spool'), 'error'); }, }); const getActiveSlotForAms = useCallback((amsId: number): number | null => { if (effectiveTrayNow === undefined) return null; if (amsId <= 3) { const activeAmsId = Math.floor(effectiveTrayNow / 4); if (activeAmsId === amsId) return effectiveTrayNow % 4; } if (amsId >= 128 && amsId <= 135) { // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...) if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0; } return null; }, [effectiveTrayNow]); const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => { const globalTrayId = getGlobalTrayId(amsId, trayId, false); const slotPreset = slotPresets?.[globalTrayId]; const mappedExtruderId = amsExtruderMap[String(amsId)]; const normalizedId = amsId >= 128 ? amsId - 128 : amsId; const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId; const slotData = { amsId, trayId, trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4, tray, trayType: tray?.tray_type || undefined, trayColor: tray?.tray_color || undefined, traySubBrands: tray?.tray_sub_brands || undefined, trayInfoIdx: tray?.tray_info_idx || undefined, extruderId: isDualNozzle ? extruderId : undefined, caliIdx: tray?.cali_idx, savedPresetId: slotPreset?.preset_id, location: `${getAmsName(amsId)} Slot ${trayId + 1}`, }; setSlotActionPicker(slotData); }, [slotPresets, amsExtruderMap, isDualNozzle]); const handleExtSlotClick = useCallback((extTray: AMSTray) => { const extTrayId = extTray.id ?? 254; const slotTrayId = extTrayId - 254; const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId]; const slotData = { amsId: 255, trayId: slotTrayId, trayCount: 1, tray: isTrayEmpty(extTray) ? null : extTray, trayType: extTray.tray_type || undefined, trayColor: extTray.tray_color || undefined, traySubBrands: extTray.tray_sub_brands || undefined, trayInfoIdx: extTray.tray_info_idx || undefined, extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined, caliIdx: extTray.cali_idx, savedPresetId: extSlotPreset?.preset_id, location: isDualNozzle ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R') : 'External', }; setSlotActionPicker(slotData); }, [slotPresets, isDualNozzle]); const openConfigureFromPicker = useCallback(() => { if (!slotActionPicker) return; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { tray, location, ...configData } = slotActionPicker; setSlotActionPicker(null); setConfigureSlotModal(configData); }, [slotActionPicker]); const openAssignFromPicker = useCallback(() => { if (!slotActionPicker || !selectedPrinterId) return; const { amsId, trayId, trayType, trayColor, location } = slotActionPicker; setSlotActionPicker(null); setAssignSpoolModal({ printerId: selectedPrinterId, amsId, trayId, trayInfo: { type: trayType || '', material: trayType, color: trayColor?.slice(0, 6) || '', location, }, }); }, [slotActionPicker, selectedPrinterId]); const openLinkFromPicker = useCallback(() => { if (!slotActionPicker || !selectedPrinterId) return; const { amsId, trayId, tray } = slotActionPicker; const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || ''; setSlotActionPicker(null); setLinkSpoolModal({ tagUid: tray?.tag_uid || linkTag, trayUuid: tray?.tray_uuid || '', printerId: selectedPrinterId, amsId, trayId, }); }, [slotActionPicker, selectedPrinterId, printerSerial]); const handleUnassignFromPicker = useCallback(() => { if (!slotActionPicker || !selectedPrinterId) return; const { amsId, trayId } = slotActionPicker; unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId }); }, [slotActionPicker, selectedPrinterId, unassignMutation]); // Set alert for low filament in status bar useEffect(() => { if (!isConnected && selectedPrinterId) { setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') }); return; } for (const unit of amsUnits) { const trays = unit.tray || []; for (let i = 0; i < trays.length; i++) { const tray = trays[i]; if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) { const isExternal = unit.id === 254 || unit.id === 255; const isHt = !isExternal && unit.id >= 128; const slot = formatSlotLabel(unit.id, i, isHt, isExternal); setAlert({ type: 'warning', message: `Low Filament: ${tray.tray_type} (${slot}) - ${tray.remain}% remaining`, }); return; } } } setAlert(null); }, [amsUnits, isConnected, selectedPrinterId, setAlert, t]); // Build list of single-slot items (AMS-HT + External) for compact rendering const singleSlots = useMemo(() => { const items: { key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean; temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null; effectiveFill: number | null; onClick: () => void; }[] = []; 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, }; // Fill level fallback chain: Spoolman → Inventory → AMS remain const spoolmanFill = getSpoolmanFillForSlot(unit.id, 0, isTrayEmpty(tray) ? null : tray); const invFill = fillOverrides[`${unit.id}-0`] ?? null; const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null; // If inventory says 0% but AMS reports positive remain, prefer AMS (#676) const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill; items.push({ key: `ht-${unit.id}`, label: getAmsName(unit.id), tray, isEmpty: isTrayEmpty(tray), isActive: getActiveSlotForAms(unit.id) === 0, temp: unit.temp, humidity: unit.humidity, nozzleSide: getNozzleSide(unit.id), effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill, onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray), }); } for (const extTray of vtTrays) { const extTrayId = extTray.id ?? 254; // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool" // generically — use active_extruder to determine L vs R: // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255) const isExtActive = isDualNozzle && effectiveTrayNow === 254 ? (extTrayId === 254 && status?.active_extruder === 1) || (extTrayId === 255 && status?.active_extruder === 0) : effectiveTrayNow === extTrayId; const extSlotTrayId = extTrayId - 254; // Fill level fallback chain: Spoolman → Inventory → AMS remain const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray); const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null; const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null; // If inventory says 0% but AMS reports positive remain, prefer AMS (#676) 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'), tray: extTray, isEmpty: isTrayEmpty(extTray), isActive: isExtActive, nozzleSide: null, effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill, onClick: () => handleExtSlotClick(extTray), }); } return items; }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]); return (
{!selectedPrinterId ? (

{t('spoolbuddy.ams.noPrinter', 'No printer selected')}

{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}

) : !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 cards — 4-slot, 2-col grid */}
{regularAms.map((unit) => ( ))}
{/* Third row: single-slot cards (AMS-HT + External) — half-width to align with AMS cards */} {singleSlots.length > 0 && (
{singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, effectiveFill, onClick }) => { const color = trayColorToCSS(tray.tray_color); return (
{/* Spool */}
{isEmpty ? (
) : ( )} {isActive && (
)}
{/* Info */}
{label} {nozzleSide && }
{isEmpty ? 'Empty' : tray.tray_type || '?'}
{(temp != null || humidity != null) && (
{temp != null && ( )} {humidity != null && ( )}
)}
{/* Fill bar */} {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
)}
); })}
)}
)}
{configureSlotModal && selectedPrinterId && ( setConfigureSlotModal(null)} printerId={selectedPrinterId} slotInfo={configureSlotModal} printerModel={mapModelCode(printer?.model ?? null) || undefined} fullScreen onSuccess={() => { queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] }); queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] }); }} /> )} {/* Slot action picker */} {slotActionPicker && selectedPrinterId && (() => { const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId); const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray); // Slot-only Spoolman assignment (no tag link). Resolves the spool details // from spoolmanInventorySpoolsCache so we can show "Assigned spool: …" // and an Unassign button — without this branch, picking a slot that was // assigned via the dashboard's Assign-to-AMS flow showed only "Configure" // with no info about which spool was bound. const spoolmanAssign = spoolmanEnabled ? spoolmanSlotAssignmentsAll.find(a => a.printer_id === selectedPrinterId && a.ams_id === slotActionPicker.amsId && a.tray_id === slotActionPicker.trayId, ) : undefined; const spoolmanAssignedSpool = spoolmanAssign ? spoolmanInventorySpoolsCache.find(s => s.id === spoolmanAssign.spoolman_spool_id) ?? null : null; return (
setSlotActionPicker(null)} />
{slotActionPicker.trayColor && ( )}

{slotActionPicker.location}

{slotActionPicker.traySubBrands && ( ({slotActionPicker.traySubBrands}) )}
{/* Currently assigned/linked spool info */} {!spoolmanEnabled && assignment?.spool && (

{t('inventory.assignedSpool', 'Assigned spool')}

{assignment.spool.rgba && ( )} {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material} {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''} #{assignment.spool.id}
)} {/* #1457: Assigned-spool block is rendered FIRST when a slot assignment exists, regardless of whether a (possibly stale) tag-link also exists. The tag-link block is the fallback for slots that have only a tag-link. */} {spoolmanEnabled && linked && !spoolmanAssignedSpool && (

{t('spoolman.linkedSpool', 'Linked spool')}

Spoolman #{linked.id} {linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}
)} {spoolmanEnabled && spoolmanAssignedSpool && (

{t('inventory.assignedSpool', 'Assigned spool')}

{spoolmanAssignedSpool.rgba && ( )} {spoolmanAssignedSpool.brand ? `${spoolmanAssignedSpool.brand} ` : ''}{spoolmanAssignedSpool.material} {spoolmanAssignedSpool.color_name ? ` - ${spoolmanAssignedSpool.color_name}` : ''}
)} {/* Inventory: Assign or Unassign in local mode. BL-RFID-detected slots are owned by the printer firmware — suppress assign/unassign there to keep parity with the Spoolman branch (Phase 14 A3). Manual changes would be overwritten on the next RFID re-read. */} {!spoolmanEnabled && (() => { if (isBambuLabSpool(slotActionPicker?.tray)) return null; if (assignment) { return ( ); } return ( ); })()} {/* Spoolman: Link / Unlink (tag-linked) or Unassign (slot-only) */} {spoolmanEnabled && (() => { if (linked?.id) { return ( ); } // Slot-only assignment (no tag link): show Unassign so the // user can clear it. Previously this branch returned null // and only the Configure button remained, hiding the fact // that a spool was bound to the slot at all. if (spoolmanAssignedSpool) { return ( ); } return ( ); })()}
); })()} {/* Assign spool modal (inventory) */} {assignSpoolModal && ( { setAssignSpoolModal(null); // Same dual-key invalidation as the unassign path — the AMS // status panel reads the printerId-keyed query while the // shared AssignSpoolModal reads the unkeyed one. queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] }); queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); }} printerId={assignSpoolModal.printerId} amsId={assignSpoolModal.amsId} trayId={assignSpoolModal.trayId} trayInfo={assignSpoolModal.trayInfo} spoolmanEnabled={!!spoolmanEnabled} /> )} {/* Link spool modal (Spoolman) */} {linkSpoolModal && ( setLinkSpoolModal(null)} tagUid={linkSpoolModal.tagUid} trayUuid={linkSpoolModal.trayUuid} printerId={linkSpoolModal.printerId} amsId={linkSpoolModal.amsId} trayId={linkSpoolModal.trayId} /> )}
); }