import { useEffect, useMemo, useRef, 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, effectivePreferLowest } 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, onEstimatedCostChange, budgetAvailable, quantity = 1, currencySymbol, defaultCostPerKg, defaultExpanded = false, forceColorMatch, onForceColorMatchChange, plateLabel, }: 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, }); // Settings + inventory map drive the same prefer-lowest + AMS-backup gate // the dispatcher uses (#1766). Without this, the per-slot dropdown's // auto-suggestion could disagree with what actually gets dispatched. const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const { data: inventoryRemain } = useQuery({ queryKey: ['printer-inventory-remain', printerId], queryFn: () => api.getInventoryRemain(printerId), enabled: !!printerId, staleTime: 30 * 1000, }); const inventoryByTrayId = useMemo(() => { if (!inventoryRemain?.inventory_remain_g) return undefined; const map = new Map(); Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => { const gtid = Number(key); if (!Number.isNaN(gtid)) map.set(gtid, grams); }); return map; }, [inventoryRemain]); const gatedPreferLowest = effectivePreferLowest( settings?.prefer_lowest_filament, printerStatus?.ams_filament_backup, ); const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } = useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId); // 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]); // Callers rendering one mapping per selected plate naturally create a // plate-scoped callback inline. Keep the latest callback in a ref so a new // function identity does not retrigger the cost effect and create a // parent/child render loop. const onEstimatedCostChangeRef = useRef(onEstimatedCostChange); useEffect(() => { onEstimatedCostChangeRef.current = onEstimatedCostChange; }, [onEstimatedCostChange]); useEffect(() => { onEstimatedCostChangeRef.current?.(totalCost > 0 ? totalCost : null); }, [totalCost]); const hasAnyCost = useMemo( () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0), [trayCostMap] ); const budgetCheckCost = totalCost * Math.max(1, quantity); const isBudgetInsufficient = budgetAvailable != null && budgetCheckCost > budgetAvailable; 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. Only the name truncates; the gram usage is pinned (shrink-0) so it never clips on narrow/mobile widths (#2669). */} {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'} {quantity > 1 && totalCost > 0 && ( {t('printModal.totalCostForQuantity', 'total: {{cost}}', { cost: `${currencySymbol}${budgetCheckCost.toFixed(2)}`, })} )}
{isBudgetInsufficient && (

{t('printModal.insufficientBudget', 'Insufficient budget for this cost center.')}

)} {hasTypeMismatch && (

Required filament type not found in printer.

)}
)}
); }