import { useState, useMemo } from 'react'; import { useQueryClient, useQueries } from '@tanstack/react-query'; import { Printer as PrinterIcon, Loader2, AlertCircle, AlertTriangle, Check, Circle, RefreshCw, Wand2, Users, } from 'lucide-react'; import { api, type PrinterStatus } from '../../api/client'; import { getColorName } from '../../utils/colors'; import { isGcodeCompatible } from '../../utils/printer'; import { normalizeColorForCompare, colorsAreSimilar, autoMatchFilament, filterFilamentsByNozzle, effectivePreferLowest, } from '../../utils/amsHelpers'; import type { PrinterSelectorProps, AssignmentMode } from './types'; import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping'; interface PrinterSelectorWithMappingProps extends PrinterSelectorProps { /** Per-printer mapping results (only used when multiple printers selected) */ printerMappingResults?: PrinterMappingResult[]; /** Filament requirements for the print */ filamentReqs?: { filaments: FilamentRequirement[] }; /** Callback to auto-configure a printer */ onAutoConfigurePrinter?: (printerId: number) => void; /** Callback to update printer config */ onUpdatePrinterConfig?: (printerId: number, config: Partial) => void; /** Current assignment mode */ assignmentMode?: AssignmentMode; /** Handler for assignment mode change */ onAssignmentModeChange?: (mode: AssignmentMode) => void; /** Selected target model (when assignmentMode is 'model') */ targetModel?: string | null; /** Handler for target model change */ onTargetModelChange?: (model: string | null) => void; /** Selected target location (when assignmentMode is 'model') */ targetLocation?: string | null; /** Handler for target location change */ onTargetLocationChange?: (location: string | null) => void; /** Suggested model from sliced file (for pre-selection) */ slicedForModel?: string | null; } /** States where the printer is available to accept a new print */ const AVAILABLE_STATES = new Set(['IDLE', 'FINISH', 'FAILED']); /** * Inline AMS mapping editor for a single printer. */ function InlineMappingEditor({ printerResult, filamentReqs, onUpdateConfig, }: { printerResult: PrinterMappingResult; filamentReqs: FilamentRequirement[]; onUpdateConfig: (config: Partial) => void; }) { const queryClient = useQueryClient(); const [isRefreshing, setIsRefreshing] = useState(false); const handleSlotChange = (slotId: number, value: string) => { if (slotId <= 0) return; const newMappings = { ...printerResult.config.manualMappings }; if (value === '') { delete newMappings[slotId]; } else { newMappings[slotId] = parseInt(value, 10); } onUpdateConfig({ useDefault: false, manualMappings: newMappings, autoConfigured: false, }); }; const handleRefresh = async () => { setIsRefreshing(true); try { await api.refreshPrinterStatus(printerResult.printerId); await new Promise((r) => setTimeout(r, 500)); await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] }); } finally { setIsRefreshing(false); } }; // Compute current slot assignments const slotAssignments = filamentReqs.map((req) => { const slotId = req.slot_id || 0; const currentMapping = printerResult.config.manualMappings[slotId]; let loaded: LoadedFilament | undefined; let isManual = false; if (currentMapping !== undefined) { loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping); isManual = true; } else { const usedTrayIds = new Set(Object.values(printerResult.config.manualMappings)); const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']); loaded = autoMatchFilament( req, printerResult.loadedFilaments, usedTrayIds, effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup), printerResult.inventoryByTrayId, ) as LoadedFilament | undefined; } // Determine status let status: 'match' | 'type_only' | 'mismatch' = 'mismatch'; if (loaded) { const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase(); const colorMatch = normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) || colorsAreSimilar(loaded.color, req.color); if (typeMatch && colorMatch) { status = 'match'; } else if (typeMatch) { status = 'type_only'; } } return { req, loaded, status, isManual }; }); return (
Custom slot mapping
{slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
{/* Only the name truncates; the gram usage is pinned (shrink-0) so it never clips on narrow/mobile widths (#2669). */} {req.type} ({req.used_grams}g) → {status === 'match' ? ( ) : status === 'type_only' ? ( ) : ( )}
))}
); } /** * Printer selection component with grid-based UI. * Supports single or multi-select modes. * When multiple printers are selected, shows per-printer mapping overrides. */ export function PrinterSelector({ printers, selectedPrinterIds, onMultiSelect, isLoading = false, allowMultiple = false, showInactive = false, disableBusy = false, printerMappingResults, filamentReqs, onAutoConfigurePrinter, onUpdatePrinterConfig, assignmentMode = 'printer', onAssignmentModeChange, targetModel, onTargetModelChange, targetLocation, onTargetLocationChange, slicedForModel, }: PrinterSelectorWithMappingProps) { // State for showing all printers vs only matching model const [showAllPrinters, setShowAllPrinters] = useState(false); // Filter printers based on showInactive flag const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active); // Fetch printer statuses to determine busy/idle state const statusQueries = useQueries({ queries: activePrinters.map((printer) => ({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), staleTime: 5000, })), }); // Build a map of printer ID -> status for quick lookup const printerStatusMap = useMemo(() => { const map = new Map(); activePrinters.forEach((printer, idx) => { const query = statusQueries[idx]; if (query?.data) { map.set(printer.id, query.data); } }); return map; }, [activePrinters, statusQueries]); const isPrinterBusy = (printerId: number): boolean => { const status = printerStatusMap.get(printerId); if (!status) return false; // Unknown state — don't block if (!status.connected) return true; return !AVAILABLE_STATES.has(status.state ?? ''); }; const getPrinterStateLabel = (printerId: number): string | null => { const status = printerStatusMap.get(printerId); if (!status) return null; if (!status.connected) return 'Offline'; const state = status.state; if (!state) return null; if (state === 'RUNNING') return status.stg_cur_name || 'Printing'; if (state === 'PREPARE') return 'Preparing'; if (state === 'PAUSE') return 'Paused'; if (state === 'IDLE') return 'Idle'; if (state === 'FINISH') return 'Finished'; if (state === 'FAILED') return 'Failed'; return state; }; // Filter by sliced model (only in printer mode, when slicedForModel is set) const displayPrinters = useMemo(() => { if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) { return activePrinters; } // Filter to only show printers matching the sliced model const matching = activePrinters.filter((p) => p.model === slicedForModel); // If no matching printers, show all return matching.length > 0 ? matching : activePrinters; }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]); // Check if there are hidden printers due to model filtering const hiddenPrinterCount = activePrinters.length - displayPrinters.length; // Get unique models from available printers (for model-based assignment) const uniqueModels = useMemo(() => { const models = activePrinters .map(p => p.model) .filter((m): m is string => Boolean(m)); return [...new Set(models)].sort(); }, [activePrinters]); // Get unique locations for the selected target model (for location filtering) const uniqueLocations = useMemo(() => { if (!targetModel) return []; const locations = activePrinters .filter(p => p.model === targetModel && p.location) .map(p => p.location) .filter((l): l is string => Boolean(l)); return [...new Set(locations)].sort(); }, [activePrinters, targetModel]); // Check if model-based assignment is available (need callbacks and multiple printers of same model) const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0; const showMappingOptions = allowMultiple && selectedPrinterIds.length > 1 && printerMappingResults && filamentReqs?.filaments && filamentReqs.filaments.length > 0 && onAutoConfigurePrinter && onUpdatePrinterConfig; if (isLoading) { return (
); } if (displayPrinters.length === 0) { return (
No {showInactive ? '' : 'active '}printers available
); } const handlePrinterClick = (printerId: number) => { if (disableBusy && isPrinterBusy(printerId)) return; if (allowMultiple) { if (selectedPrinterIds.includes(printerId)) { onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId)); } else { onMultiSelect([...selectedPrinterIds, printerId]); } } else { onMultiSelect([printerId]); } }; const handleSelectAll = () => { const selectable = disableBusy ? displayPrinters.filter((p) => !isPrinterBusy(p.id)) : displayPrinters; onMultiSelect(selectable.map((p) => p.id)); }; const handleDeselectAll = () => { onMultiSelect([]); }; const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => { e.stopPropagation(); if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return; if (enabled) { onAutoConfigurePrinter(printerId); } else { onUpdatePrinterConfig(printerId, { useDefault: true, manualMappings: {}, autoConfigured: false, }); } }; const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId); const selectedCount = selectedPrinterIds.length; const getPrinterMappingResult = (printerId: number) => { return printerMappingResults?.find((r) => r.printerId === printerId); }; return (
{/* Assignment mode toggle (model vs specific printer) */} {modelAssignmentAvailable && (
)} {/* Model selection and location filter (when in model mode) */} {assignmentMode === 'model' && modelAssignmentAvailable && (
{/* Model selector — always visible in model mode so a wrong target on an existing queue item can be seen and fixed (#2578). Incompatible models are disabled: G-code sliced for one model must only go to that model or its interchange family. */}
{/* Cross-model state on an existing row: same file/target mismatch the specific-printer path already warns about (#2578). */} {slicedForModel && targetModel && targetModel !== slicedForModel && ( isGcodeCompatible(slicedForModel, targetModel) ? (
File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
) : (
File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers — select a compatible model
) )} {/* Location filter (only show when target model is selected and locations exist) */} {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
)} {/* Info text */} {targetModel && (

Scheduler will assign to first available idle {targetModel} printer {targetLocation ? ` in ${targetLocation}` : ''}

)}
)} {/* Multi-select header (only in printer mode) */} {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
{selectedCount === 0 ? 'Select printers' : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
{selectedCount < displayPrinters.length && ( )} {selectedCount > 0 && ( )}
)} {/* Printer list (only in printer mode) */} {assignmentMode === 'printer' && displayPrinters.map((printer) => { const selected = isSelected(printer.id); const mappingResult = getPrinterMappingResult(printer.id); const hasOverride = mappingResult && !mappingResult.config.useDefault; const busy = isPrinterBusy(printer.id); const disabled = disableBusy && busy; const stateLabel = getPrinterStateLabel(printer.id); return (
{/* Printer selection button */} {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */} {selected && showMappingOptions && mappingResult && (
{/* Override checkbox row */}
{/* Match status indicator */} ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched) {/* Loading indicator */} {mappingResult.isLoading && ( )} {/* Auto-configure button (when override is enabled) */} {hasOverride && ( )}
{/* Inline mapping editor (shown when override is checked) */} {hasOverride && ( onUpdatePrinterConfig!(printer.id, config)} /> )}
)}
); })} {/* Show hidden printers toggle */} {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && ( )} {/* Show matching only toggle */} {assignmentMode === 'printer' && showAllPrinters && slicedForModel && ( )} {/* Warning when no printer selected (only in printer mode) */} {assignmentMode === 'printer' && selectedCount === 0 && (

Select at least one printer

)} {/* Warning when no model selected (only in model mode) */} {assignmentMode === 'model' && !targetModel && (

Select a target printer model

)}
); }