import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings } from 'lucide-react'; import { api } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { getColorName } from '../utils/colors'; interface ReprintModalProps { archiveId: number; archiveName: string; onClose: () => void; onSuccess: () => void; } // Print options with defaults interface PrintOptions { timelapse: boolean; bed_levelling: boolean; flow_cali: boolean; vibration_cali: boolean; layer_inspect: boolean; } const DEFAULT_PRINT_OPTIONS: PrintOptions = { bed_levelling: true, flow_cali: false, vibration_cali: true, layer_inspect: false, timelapse: false, }; export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) { const queryClient = useQueryClient(); const [selectedPrinter, setSelectedPrinter] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [showOptions, setShowOptions] = useState(false); const [printOptions, setPrintOptions] = useState(DEFAULT_PRINT_OPTIONS); // Manual slot overrides: slot_id (1-indexed) -> globalTrayId const [manualMappings, setManualMappings] = useState>({}); // Clear manual mappings when printer changes useEffect(() => { setManualMappings({}); }, [selectedPrinter]); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const { data: printers, isLoading: loadingPrinters } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Fetch filament requirements from the archived 3MF const { data: filamentReqs } = useQuery({ queryKey: ['archive-filaments', archiveId], queryFn: () => api.getArchiveFilamentRequirements(archiveId), }); // Fetch printer status when a printer is selected const { data: printerStatus } = useQuery({ queryKey: ['printer-status', selectedPrinter], queryFn: () => api.getPrinterStatus(selectedPrinter!), enabled: !!selectedPrinter, }); const reprintMutation = useMutation({ mutationFn: () => { if (!selectedPrinter) throw new Error('No printer selected'); return api.reprintArchive(archiveId, selectedPrinter, { ams_mapping: amsMapping, ...printOptions, }); }, onSuccess: () => { onSuccess(); onClose(); }, }); const activePrinters = printers?.filter((p) => p.is_active) || []; // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB") const normalizeColor = (color: string | null | undefined): string => { if (!color) return '#808080'; // Remove alpha channel if present (8-char hex to 6-char) const hex = color.replace('#', '').substring(0, 6); return `#${hex}`; }; // Helper to format slot label for display const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => { if (isExternal) return 'External'; const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D if (isHt) return `HT-${letter}`; return `AMS-${letter} Slot ${trayId + 1}`; }; // Calculate global tray ID for MQTT command // Regular AMS: (ams_id * 4) + slot_id, External: 254 const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => { if (isExternal) return 254; return amsId * 4 + trayId; }; // Build a list of all loaded filaments from printer's AMS/HT/External with location info const loadedFilaments = useMemo(() => { const filaments: Array<{ type: string; color: string; colorName: string; amsId: number; trayId: number; isHt: boolean; isExternal: boolean; label: string; globalTrayId: number; }> = []; // Add filaments from all AMS units (regular and HT) printerStatus?.ams?.forEach((amsUnit) => { const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray amsUnit.tray.forEach((tray) => { if (tray.tray_type) { const color = normalizeColor(tray.tray_color); filaments.push({ type: tray.tray_type, color, colorName: getColorName(color), amsId: amsUnit.id, trayId: tray.id, isHt, isExternal: false, label: formatSlotLabel(amsUnit.id, tray.id, isHt, false), globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false), }); } }); }); // Add external spool if loaded if (printerStatus?.vt_tray?.tray_type) { const color = normalizeColor(printerStatus.vt_tray.tray_color); filaments.push({ type: printerStatus.vt_tray.tray_type, color, colorName: getColorName(color), amsId: -1, trayId: 0, isHt: false, isExternal: true, label: 'External', globalTrayId: 254, }); } return filaments; }, [printerStatus]); // Compare required filaments with loaded filaments // Match by filament TYPE (not slot), since the printer dynamically maps slots // Respects manual overrides when set const filamentComparison = useMemo(() => { if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return []; // Helper to normalize color for comparison (case-insensitive, strip #) const normalizeColorForCompare = (color: string | undefined): string => { if (!color) return ''; return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha }; // Helper to check if two colors are similar (within threshold) const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => { const hex1 = normalizeColorForCompare(color1); const hex2 = normalizeColorForCompare(color2); if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false; const r1 = parseInt(hex1.substring(0, 2), 16); const g1 = parseInt(hex1.substring(2, 4), 16); const b1 = parseInt(hex1.substring(4, 6), 16); const r2 = parseInt(hex2.substring(0, 2), 16); const g2 = parseInt(hex2.substring(2, 4), 16); const b2 = parseInt(hex2.substring(4, 6), 16); // Check if each RGB component is within threshold return Math.abs(r1 - r2) <= threshold && Math.abs(g1 - g2) <= threshold && Math.abs(b1 - b2) <= threshold; }; // Track which trays have been assigned to avoid duplicates // First, mark all manually assigned trays as used const usedTrayIds = new Set(Object.values(manualMappings)); return filamentReqs.filaments.map((req) => { const slotId = req.slot_id || 0; // Check if there's a manual override for this slot if (slotId > 0 && manualMappings[slotId] !== undefined) { const manualTrayId = manualMappings[slotId]; const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId); if (manualLoaded) { const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase(); const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) || colorsAreSimilar(manualLoaded.color, req.color); let status: 'match' | 'type_only' | 'mismatch' | 'empty'; if (typeMatch && colorMatch) { status = 'match'; } else if (typeMatch) { status = 'type_only'; } else { status = 'mismatch'; } return { ...req, loaded: manualLoaded, hasFilament: true, typeMatch, colorMatch, status, isManual: true, }; } } // Auto-match: Find a loaded filament that matches by TYPE // Priority: exact color match > similar color match > type-only match // IMPORTANT: Exclude trays that are already assigned (manually or auto) const exactMatch = loadedFilaments.find( (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color) ); const similarMatch = !exactMatch && loadedFilaments.find( (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && colorsAreSimilar(f.color, req.color) ); const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find( (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() ); const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined; // Mark this tray as used so it won't be assigned to another slot if (loaded) { usedTrayIds.add(loaded.globalTrayId); } const hasFilament = !!loaded; const typeMatch = hasFilament; const colorMatch = !!exactMatch || !!similarMatch; // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found) let status: 'match' | 'type_only' | 'mismatch' | 'empty'; if (exactMatch || similarMatch) { status = 'match'; } else if (typeOnlyMatch) { status = 'type_only'; } else { status = 'mismatch'; } return { ...req, loaded, hasFilament, typeMatch, colorMatch, status, isManual: false, }; }); }, [filamentReqs, loadedFilaments, manualMappings]); // Build AMS mapping from auto-matched filaments // Format: array matching 3MF filament slot structure // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1] const amsMapping = useMemo(() => { if (filamentComparison.length === 0) return undefined; // Find the max slot_id to determine array size const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0)); if (maxSlotId <= 0) return undefined; // Create array with -1 for all positions const mapping = new Array(maxSlotId).fill(-1); // Fill in tray IDs at correct positions (slot_id - 1) filamentComparison.forEach((f) => { if (f.slot_id && f.slot_id > 0) { mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1; } }); return mapping; }, [filamentComparison]); const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch'); return (
{/* Header */}

Re-print

Send {archiveName} to a printer

{/* Printer selection */} {loadingPrinters ? (
) : activePrinters.length === 0 ? (
No active printers available
) : (
{activePrinters.map((printer) => ( ))}
)} {/* Filament comparison - show when printer selected and has filament requirements */} {selectedPrinter && filamentComparison.length > 0 && (
Filament Check {hasTypeMismatch ? ( Type not found ) : filamentComparison.some((f) => f.status === 'type_only') ? ( Color mismatch ) : ( Ready )}
{filamentComparison.map((item, idx) => (
{/* Required color */} {/* Required type + grams */} {item.type} ({item.used_grams}g) {/* Arrow */} {/* Slot selector dropdown */} {/* Status icon */} {item.status === 'match' ? ( ) : item.status === 'type_only' ? ( ) : ( )}
))}
{hasTypeMismatch && (

Required filament type not found in printer.

)}
)} {/* Print Options */} {selectedPrinter && (
{showOptions && (
{[ { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' }, { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' }, { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' }, { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' }, { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' }, ].map(({ key, label, desc }) => (
)} {/* Error message */} {reprintMutation.isError && (
{(reprintMutation.error as Error).message || 'Failed to start print'}
)} {/* Actions */}
); }