import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react'; import { api } from '../../api/client'; import { useFilamentMapping } from '../../hooks/useFilamentMapping'; import { getGlobalTrayId } from '../../utils/amsHelpers'; import { getColorName } from '../../utils/colors'; import { useFilamentLabels } from './useFilamentLabels'; import type { FilamentMappingProps } from './types'; /** * Filament mapping UI for comparing required filaments with loaded AMS slots. * Shows auto-matched and manually overridden slot assignments. */ export function FilamentMapping({ printerId, filamentReqs, manualMappings, onManualMappingChange, currencySymbol, defaultCostPerKg, defaultExpanded = false, forceColorMatch, onForceColorMatchChange, }: FilamentMappingProps & { defaultExpanded?: boolean }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [isRefreshing, setIsRefreshing] = useState(false); const [isExpanded, setIsExpanded] = useState(defaultExpanded); // Fetch printer status const { data: printerStatus } = useQuery({ queryKey: ['printer-status', printerId], queryFn: () => api.getPrinterStatus(printerId), enabled: !!printerId, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments', printerId], queryFn: () => api.getAssignments(printerId), enabled: !!printerId, }); const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } = useFilamentMapping(filamentReqs, printerStatus, manualMappings); // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same // shared hook the model-mode FilamentOverride uses so both panels render // the same sliced-3MF identity. Falls back to the raw type / generic // colour bucket when the SKU is unknown or the by-material lookup hasn't // resolved — never blanks out the required row. const filamentLabels = useFilamentLabels(filamentReqs?.filaments); const trayCostMap = useMemo(() => { const map = new Map(); for (const assignment of assignments || []) { const isExternal = assignment.ams_id === 255; const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal); map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null); } return map; }, [assignments]); const trayRemainingWeightMap = useMemo(() => { const map = new Map(); for (const assignment of assignments || []) { const isExternal = assignment.ams_id === 255; const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal); const spool = assignment.spool; if (!spool) { map.set(globalTrayId, null); continue; } map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0)))); } return map; }, [assignments]); const totalCost = useMemo(() => { let total = 0; for (const item of filamentComparison) { const trayId = item.loaded?.globalTrayId; if (trayId == null) continue; const assignedCost = trayCostMap.get(trayId) ?? null; const costPerKg = assignedCost ?? defaultCostPerKg; if (costPerKg > 0) { total += (item.used_grams / 1000) * costPerKg; } } return total; }, [filamentComparison, trayCostMap, defaultCostPerKg]); const hasAnyCost = useMemo( () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0), [trayCostMap] ); const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0; const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false; // Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic // (any slot can be routed to either extruder), so the per-nozzle dropdown // filter is suppressed. fila_switch.in_slots[track] = currently fed slot, // fila_switch.out_extruders[track] = extruder that track terminates at. See #1162. const ftsInstalled = printerStatus?.fila_switch?.installed === true; const ftsExtruderForSlot = (globalTrayId: number): number | null => { const fs = printerStatus?.fila_switch; if (!fs?.installed) return null; const track = fs.in_slots.indexOf(globalTrayId); if (track < 0) return null; return fs.out_extruders[track] ?? null; }; // Don't render if no filament requirements if (!hasFilamentReqs) { return null; } // Don't render until we have printer status to do the comparison if (!printerStatus) { return null; } // Determine status indicator color const statusColor = hasTypeMismatch ? '#f97316' // orange : hasColorMismatch ? '#facc15' // yellow : '#00ae42'; // green const handleSlotChange = (slotId: number, value: string) => { if (slotId > 0) { if (value === '') { // Clear manual override const next = { ...manualMappings }; delete next[slotId]; onManualMappingChange(next); } else { onManualMappingChange({ ...manualMappings, [slotId]: parseInt(value, 10), }); } } }; const handleRefresh = async () => { setIsRefreshing(true); try { // Request fresh data from printer via MQTT pushall command await api.refreshPrinterStatus(printerId); // Wait a moment for printer to respond, then refetch await new Promise((r) => setTimeout(r, 500)); await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] }); } finally { setIsRefreshing(false); } }; return (
{isExpanded && (
Click to change slot assignment
{filamentComparison.map((item, idx) => { // #1717: surface the same per-slot force-color-match checkbox here // that FilamentOverride exposes for model-mode dispatch. The // scheduler honors the flag in both modes; only the UI was missing. const slotId = item.slot_id ?? 0; const canForceMatch = slotId > 0 && onForceColorMatchChange != null; // #1718: same sub-brand + colour resolution as FilamentOverride. // Indexing is safe because ``useFilamentLabels`` mirrors the input // array shape; defensive fallback covers the empty-reqs render // path that shouldn't reach here anyway. const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) }; return (
{/* Required color */} {/* Required type + grams + nozzle badge */} {isDualNozzle && item.nozzle_id != null && ( {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')} )} {resolvedName} ({item.used_grams}g) {/* Arrow */} {/* Slot selector dropdown */} {/* Status icon */} {item.status === 'match' ? ( ) : item.status === 'type_only' ? ( ) : ( )}
{/* Force Color Match checkbox — matches FilamentOverride's layout. */} {canForceMatch && ( )}
); })}
{t('printModal.totalCost')}{' '} {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
{hasTypeMismatch && (

Required filament type not found in printer.

)}
)}
); }