import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client'; import { api } from '../../api/client'; import { useAuth } from '../../contexts/AuthContext'; import { Card, CardContent } from '../Card'; import { Button } from '../Button'; import { ConfirmModal } from '../ConfirmModal'; import { useToast } from '../../contexts/ToastContext'; import { buildAmsMapping, buildFilamentComparison, buildLoadedFilaments, useFilamentMapping, } from '../../hooks/useFilamentMapping'; import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; import { getColorName } from '../../utils/colors'; import { isGcodeCompatible } from '../../utils/printer'; import { getCurrencySymbol } from '../../utils/currency'; import { getBedTypeInfo } from '../../utils/bedType'; import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date'; import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers'; import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping'; import { FilamentMapping } from './FilamentMapping'; import { FilamentOverride } from './FilamentOverride'; import { PlateSelector } from './PlateSelector'; import { PrinterSelector } from './PrinterSelector'; import { PrintOptionsPanel } from './PrintOptions'; import { ScheduleOptionsPanel } from './ScheduleOptions'; import { VariantCandidates, type VariantCandidate } from './VariantCandidates'; import { CostCenterSelect } from './CostCenterSelect'; import type { AssignmentMode, FilamentReqsData, PrintModalProps, PrintOptions, ScheduleOptions, ScheduleType, } from './types'; import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types'; /** * Unified PrintModal component that handles queue item creation and editing. * - 'create': Create a print queue item from an archive or library file * - 'edit-queue-item': Edit existing queue item * * Both archiveId and libraryFileId are supported. Library files are archived at * print start time by the scheduler, not when queued. */ export function PrintModal({ mode, archiveId, libraryFileId, archiveName, queueItem, initialSelectedPrinterIds, onClose, onSuccess, projectId, cleanupLibraryAfterDispatch, variantFiles, }: PrintModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, user } = useAuth(); // Determine if we're printing a library file const isLibraryFile = !!libraryFileId && !archiveId; const isEditing = mode === 'edit-queue-item'; // Cross-model alternatives (#671). One candidate is not a choice, so a // single-entry list behaves exactly like an ordinary print. const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1; // Editing an already-queued cross-model item. The candidates are shown so the // dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it // did before, offering a printer picker whose Save would have left a row with // both variants and a printer_id. They are not editable here: changing the // set after queueing needs a variant-level API that doesn't exist, and the // backend refuses the printer/model change either way. const editingVariants: VariantCandidate[] = mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1 ? queueItem!.variants!.map((v) => ({ id: v.library_file_id, filename: v.filename, sliced_for_model: v.target_model, })) : []; const hasEditingVariants = editingVariants.length > 0; const [candidates, setCandidates] = useState(variantFiles ?? []); const [candidatePlates, setCandidatePlates] = useState>({}); type FilamentWarningItem = { printerName: string; slotLabel: string; requiredGrams: number; remainingGrams: number; /** True when AMS Filament Backup pooled more than one spool for this slot; * `requiredGrams` / `remainingGrams` are then the pooled totals. */ pooled?: boolean; }; // Multiple printer selection (used for all modes now) const [selectedPrinters, setSelectedPrinters] = useState(() => { // Initialize with the queue item's printer if editing if (mode === 'edit-queue-item' && queueItem?.printer_id) { return [queueItem.printer_id]; } if (initialSelectedPrinterIds?.length) { return initialSelectedPrinterIds; } return []; }); // Multi-select plates: create mode users can pick a subset of plates const [selectedPlates, setSelectedPlates] = useState>(() => { if (mode === 'edit-queue-item' && queueItem?.plate_id != null) { return new Set([queueItem.plate_id]); } return new Set(); }); // Derived single-plate value for filament queries and single-select contexts const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null; // Quantity — number of copies (creates a batch if > 1) const [quantity, setQuantity] = useState(1); // Per-plate quantities for multi-plate files (#342). Keyed by plate index; // a plate with no entry means one run. Only used in create mode on a // multi-plate file, where it replaces the single global Quantity field. const [plateQuantities, setPlateQuantities] = useState>({}); const [printOptions, setPrintOptions] = useState(() => { if (mode === 'edit-queue-item' && queueItem) { return { bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling, flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali, vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali, layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect, timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse, nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali, preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override, preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override, }; } return DEFAULT_PRINT_OPTIONS; }); const [scheduleOptions, setScheduleOptions] = useState(() => { if (mode === 'edit-queue-item' && queueItem) { let scheduleType: ScheduleType = 'queue'; if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) { scheduleType = 'scheduled'; } let scheduledTime = ''; if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) { const date = parseUTCDate(queueItem.scheduled_time) ?? new Date(); // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input scheduledTime = toDateTimeLocalValue(date); } return { scheduleType, scheduledTime, requireManualStart: queueItem.manual_start, requirePreviousSuccess: queueItem.require_previous_success, autoOffAfter: queueItem.auto_off_after, gcodeInjection: queueItem.gcode_injection ?? false, staggerEnabled: false, staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize, staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes, }; } return DEFAULT_SCHEDULE_OPTIONS; }); // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers) const [manualMappings, setManualMappings] = useState>(() => { if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) { const mappings: Record = {}; queueItem.ams_mapping.forEach((globalTrayId, idx) => { if (globalTrayId !== -1) { mappings[idx + 1] = globalTrayId; } }); return mappings; } return {}; }); // Per-printer override configs (for multi-printer selection) const [perPrinterConfigs, setPerPrinterConfigs] = useState>({}); // Assignment mode: 'printer' (specific) or 'model' (any of model) const [assignmentMode, setAssignmentMode] = useState(() => { // Cross-model alternatives are model-based by definition — naming one // printer would defeat the point of offering the other file. if (isCrossModel) { return 'model'; } // Initialize from queue item if editing with target_model if (mode === 'edit-queue-item' && queueItem?.target_model) { return 'model'; } return 'printer'; }); // Target model for model-based assignment const [targetModel, setTargetModel] = useState(() => { if (mode === 'edit-queue-item' && queueItem?.target_model) { return queueItem.target_model; } return null; }); // Target location for model-based assignment (optional filter) const [targetLocation, setTargetLocation] = useState(() => { if (mode === 'edit-queue-item' && queueItem?.target_location) { return queueItem.target_location; } return null; }); const [selectedCostCenterId, setSelectedCostCenterId] = useState(() => mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null ); const [estimatedCost, setEstimatedCost] = useState(queueItem?.estimated_cost ?? null); const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState>({}); // Filament overrides for model-based assignment: slot_id -> {type, color} const [filamentOverrides, setFilamentOverrides] = useState>(() => { if (mode === 'edit-queue-item' && queueItem?.filament_overrides) { const overrides: Record = {}; for (const o of queueItem.filament_overrides) { overrides[o.slot_id] = { type: o.type, color: o.color }; } return overrides; } return {}; }); // Per-slot force color match flags. Default is false (opt-in). const [forceColorMatch, setForceColorMatch] = useState>(() => { if (mode === 'edit-queue-item' && queueItem?.filament_overrides) { const flags: Record = {}; for (const o of queueItem.filament_overrides) { flags[o.slot_id] = o.force_color_match === true; } return flags; } return {}; }); // Track initial values for clearing mappings on change (edit mode only) const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : [])); const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null)); // Submission state for multi-printer const [isSubmitting, setIsSubmitting] = useState(false); const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 }); const [filamentWarningItems, setFilamentWarningItems] = useState(null); // Track which printers have had the "Expand custom mapping by default" setting applied // This ensures the setting only affects initial state, not preventing unchecking const [initialExpandApplied, setInitialExpandApplied] = useState>(new Set()); // Printer counts and effective printer for filament mapping const effectivePrinterCount = selectedPrinters.length; // For filament mapping, use first selected printer (mapping applies to all) const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null; // Queries const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); // Sync print option defaults from settings once available const printDefaultsApplied = useRef(false); useEffect(() => { if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return; printDefaultsApplied.current = true; setPrintOptions({ bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling, flow_cali: settings.default_flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali, vibration_cali: settings.default_vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali, layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect, timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse, nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali, preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override, preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override, }); }, [settings, mode]); // Sync stagger defaults from settings once available const staggerDefaultsApplied = useRef(false); useEffect(() => { if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return; staggerDefaultsApplied.current = true; setScheduleOptions((prev) => ({ ...prev, staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize, staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes, })); }, [settings, mode]); const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); const defaultCostPerKg = settings?.default_filament_cost ?? 0; const billingEnabled = settings?.billing_enabled === true; const { data: printers, isLoading: loadingPrinters } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({ queryKey: ['finance', 'cost-centers', 'mine'], queryFn: api.getMyCostCenters, enabled: !!user && billingEnabled, }); const printableCostCenters = useMemo( () => (myCostCenters || []).filter((center: CostCenterSummary) => center.can_print && center.is_active), [myCostCenters], ); const selectedCostCenter = useMemo( () => printableCostCenters.find((center) => center.id === selectedCostCenterId) ?? null, [printableCostCenters, selectedCostCenterId], ); useEffect(() => { if (printableCostCenters.length === 0) return; if (selectedCostCenterId != null && printableCostCenters.some((center) => center.id === selectedCostCenterId)) { return; } const preferredPrivate = printableCostCenters.find((center) => center.is_private); setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id); }, [printableCostCenters, selectedCostCenterId]); // Fetch per-printer Map via the dedicated // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides` // so internal and Spoolman modes both work uniformly, VT/external slots are // excluded, and negative grams are clamped — single source of truth between // the client-side preview and dispatch-time picks. const inventoryRemainQueries = useQueries({ queries: selectedPrinters.map((printerId) => ({ queryKey: ['printer-inventory-remain', printerId], queryFn: () => api.getInventoryRemain(printerId), staleTime: 30 * 1000, enabled: selectedPrinters.length > 0, })), }); const inventoryByTrayIdPerPrinter = useMemo(() => { const result = new Map>(); selectedPrinters.forEach((printerId, idx) => { const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g; if (!data) return; const printerMap = new Map(); Object.entries(data).forEach(([key, grams]) => { const gtid = Number(key); if (!Number.isNaN(gtid)) printerMap.set(gtid, grams); }); result.set(printerId, printerMap); }); return result; }, [selectedPrinters, inventoryRemainQueries]); // Same endpoint, the other half of its payload: every inventory-bound slot on // the printer with the backend's material identity and extruder side. The // pre-flight filament check groups on these instead of resolving spools // itself, which is what makes it agree with the dispatcher and work in // Spoolman mode (where the modal has no assignment rows of its own). const slotMaterialsPerPrinter = useMemo(() => { const result = new Map>(); selectedPrinters.forEach((printerId, idx) => { const slots = inventoryRemainQueries[idx]?.data?.slot_materials; if (!slots) return; const printerMap = new Map(); slots.forEach((slot) => printerMap.set(slot.global_tray_id, slot)); result.set(printerId, printerMap); }); return result; }, [selectedPrinters, inventoryRemainQueries]); // Fetch archive details to get sliced_for_model const { data: archiveDetails } = useQuery({ queryKey: ['archive', archiveId], queryFn: () => api.getArchive(archiveId!), enabled: !!archiveId && !isLibraryFile, }); // Fetch library file details to get sliced_for_model const { data: libraryFileDetails } = useQuery({ queryKey: ['library-file', libraryFileId], queryFn: () => api.getLibraryFile(libraryFileId!), enabled: isLibraryFile && !!libraryFileId, }); // Get sliced_for_model from archive or library file const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null; // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS // mapping" virtual-printer setting) — undefined for library files or // archives that predate the feature / had it off at print time, and // deliberately undefined unless the selected printer is the one the mapping // was resolved against. See `resolveArchiveSlicerAmsMapping`. const archiveSlicerAmsMapping = useMemo( () => isLibraryFile ? undefined : resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId), [isLibraryFile, archiveDetails?.extra_data, effectivePrinterId], ); // Fetch plates for archives const { data: archivePlatesData, isError: archivePlatesError } = useQuery({ queryKey: ['archive-plates', archiveId], queryFn: () => api.getArchivePlates(archiveId!), enabled: !!archiveId && !isLibraryFile, retry: false, }); // Fetch plates for library files const { data: libraryPlatesData } = useQuery({ queryKey: ['library-file-plates', libraryFileId], queryFn: () => api.getLibraryFilePlates(libraryFileId!), enabled: isLibraryFile && !!libraryFileId, }); // Combine plates data from either source const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData; // Fetch filament requirements for archives const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({ queryKey: ['archive-filaments', archiveId, selectedPlate], queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined), enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate), retry: false, }); // Fetch filament requirements for library files (with plate support) const { data: libraryFilamentReqs } = useQuery({ queryKey: ['library-file-filaments', libraryFileId, selectedPlate], queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined), enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate), }); // Track if archive data couldn't be loaded (archive deleted or file missing) const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError); // Combine filament requirements from either source const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs; // Fetch available filaments for model-based assignment (for filament override UI) const { data: availableFilaments } = useQuery({ queryKey: ['available-filaments', targetModel, targetLocation], queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined), enabled: assignmentMode === 'model' && !!targetModel, }); // A cross-model job (#671) has no single target model, so the query above is // disabled and the override UI would silently vanish — leaving less control // than the ordinary "Any X1C" flow offers. Ask each candidate's model instead // and offer the union: the job can land on any of them, so anything loaded on // any of them is a legitimate choice. Picking one only some models have is // allowed and meaningful — it narrows which candidates can match. const candidateModels = useMemo( () => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))), [candidates], ); const candidateFilamentQueries = useQueries({ queries: isCrossModel ? candidateModels.map((model) => ({ queryKey: ['available-filaments', model, targetLocation], queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined), })) : [], }); const crossModelFilaments = useMemo(() => { const seen = new Set(); const merged: NonNullable = []; for (const query of candidateFilamentQueries) { for (const filament of query.data ?? []) { // Same type+colour loaded on two models is one choice, not two. const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`; if (!seen.has(key)) { seen.add(key); merged.push(filament); } } } return merged; }, [candidateFilamentQueries]); const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments; // Only fetch printer status when single printer selected (for filament mapping) const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({ queryKey: ['printer-status', effectivePrinterId], queryFn: () => api.getPrinterStatus(effectivePrinterId!), enabled: !!effectivePrinterId, }); // Single-printer flow: gate prefer_lowest on this printer's backup state. // Multi-printer flow gates per-printer inside the hook (different printers // may have different backup states), so we pass the raw setting down. const singlePrinterPreferLowest = effectivePreferLowest( settings?.prefer_lowest_filament, printerStatus?.ams_filament_backup, ); const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => { if (!status?.connected) return false; if (status.awaiting_plate_clear) return false; if (status.ams?.some((ams) => ams.dry_time > 0)) return false; return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? ''); }; const asapToastShouldPromiseLaterStart = async (): Promise => { if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false; if (selectedPrinters.length === 0) return false; try { const statuses = await Promise.all( selectedPrinters.map((printerId) => queryClient.fetchQuery({ queryKey: ['printer-status', printerId], queryFn: () => api.getPrinterStatus(printerId), staleTime: 0, }), ), ); return statuses.some((status) => !isPrinterCurrentlyDispatchable(status)); } catch { return true; } }; // Get AMS mapping from hook (only when single printer selected) const { amsMapping } = useFilamentMapping( effectiveFilamentReqs, printerStatus, manualMappings, singlePrinterPreferLowest, effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined, ); // --- Per-plate filament mapping (multi-plate submissions) --------------- // Each plate prints its own subset of the file's slots and needs its own AMS // mapping. `effectiveFilamentReqs` above is keyed on `selectedPlate`, which is // null the moment two plates are picked, so it holds the union of every plate's // filaments — matching against that union lets two plates that share a colour // on different slots compete for the same tray, and sends the loser to a worse // tray or to none. So when several plates are selected we fetch each plate's // requirements and map them separately (#2551 follow-up). const selectedPlateIds = useMemo(() => [...selectedPlates].sort((a, b) => a - b), [selectedPlates]); const isMultiPlateSelection = selectedPlates.size > 1; const perPlateReqQueries = useQueries({ queries: (isMultiPlateSelection ? selectedPlateIds : []).map((plateId) => ({ queryKey: isLibraryFile ? ['library-file-filaments', libraryFileId, plateId] : ['archive-filaments', archiveId, plateId], queryFn: () => isLibraryFile ? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId) : api.getArchiveFilamentRequirements(archiveId!, plateId), enabled: isLibraryFile ? !!libraryFileId : !!archiveId, // Same policy as the single-plate query above: these keys are shared, and a // retrying observer would leave the plate looking merely slow for seconds. retry: false, })), }); // A plate that has not answered yet and a plate whose 3MF cannot be read look // identical from here — both are simply absent from `perPlateReqs`. Neither may // be treated as "this plate needs no filament": that would queue it with no // mapping and no force-colour overrides, and it would print in whatever happens // to be loaded. Both states gate submission instead (see `canSubmit`). // `isPending` is "no data yet", not "a request is in flight" — a background // refetch of a plate we already have must not disable the button under the user. const perPlateReqsPending = perPlateReqQueries.some((q) => q.isPending); const perPlateReqsFailed = perPlateReqQueries.some((q) => q.isError); const perPlateReqs = useMemo(() => { const byPlate = new Map(); selectedPlateIds.forEach((plateId, i) => { const data = perPlateReqQueries[i]?.data; if (data) byPlate.set(plateId, data); }); return byPlate; // Keyed on each query's last update stamp, not on the query objects (fresh every // render) and not on a spread of their data (a dep array whose *length* changes // with the plate count, which React treats as always-changed and warns about). // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]); // Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2 // are different prints and may want different trays. const [manualMappingsByPlate, setManualMappingsByPlate] = useState>>({}); // Rack position per filament group (#1784), and one set per plate for the // per-plate panels — each plate has its own groups. const [nozzleRackChoice, setNozzleRackChoice] = useState>(() => { // Re-opening an item shows the positions it was queued with, so editing // one filament does not silently drop the rest. if (mode === 'edit-queue-item' && queueItem?.nozzle_rack_choice) { const seeded: Record = {}; for (const [groupId, position] of Object.entries(queueItem.nozzle_rack_choice)) { const group = Number(groupId); if (Number.isInteger(group) && Number.isInteger(position)) seeded[group] = position; } return seeded; } return {}; }); const [nozzleRackChoiceByPlate, setNozzleRackChoiceByPlate] = useState>>({}); // Only ever computed for a single target printer: a tray id means nothing on a // different printer, so a fan-out across printers must not reuse these. const perPlateAmsMappings = useMemo(() => { const byPlate = new Map(); if (!isMultiPlateSelection || !effectivePrinterId || selectedPrinters.length !== 1) return byPlate; const loaded = buildLoadedFilaments(printerStatus); const ftsActive = printerStatus?.fila_switch?.installed === true; const inventoryByTrayId = inventoryByTrayIdPerPrinter.get(effectivePrinterId); for (const plateId of selectedPlateIds) { const reqs = perPlateReqs.get(plateId); if (!reqs) continue; const comparison = buildFilamentComparison( reqs, loaded, manualMappingsByPlate[plateId] ?? {}, singlePrinterPreferLowest, inventoryByTrayId, ftsActive, ); byPlate.set(plateId, buildAmsMapping(comparison)); } return byPlate; }, [ isMultiPlateSelection, effectivePrinterId, printerStatus, inventoryByTrayIdPerPrinter, selectedPlateIds, perPlateReqs, manualMappingsByPlate, singlePrinterPreferLowest, selectedPrinters.length, ]); // Multi-printer filament mapping (for per-printer configuration) const multiPrinterMapping = useMultiPrinterFilamentMapping( selectedPrinters, printers, effectiveFilamentReqs, manualMappings, perPrinterConfigs, setPerPrinterConfigs, settings?.prefer_lowest_filament, inventoryByTrayIdPerPrinter, ); // Auto-select first plate when plates load (single or multi-plate) useEffect(() => { if (platesData?.plates && platesData.plates.length >= 1 && selectedPlates.size === 0) { setSelectedPlates(new Set([platesData.plates[0].index])); } }, [platesData, selectedPlates.size]); // Auto-select first printer when only one available useEffect(() => { // Skip auto-select for edit mode (already initialized from queueItem) if (mode === 'edit-queue-item') return; const activePrinters = printers?.filter(p => p.is_active) || []; if (activePrinters.length === 1 && selectedPrinters.length === 0) { setSelectedPrinters([activePrinters[0].id]); } }, [mode, printers, selectedPrinters.length]); // Clear manual mappings and per-printer configs when printer or plate changes. // The per-plate mappings go with them: a manual override holds a global tray id, // which names a different spool on a different printer. useEffect(() => { if (mode === 'edit-queue-item') { // For edit mode, clear mappings if printer selection or plate changed from initial const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort()); if (printersChanged || selectedPlate !== initialPlateId) { setManualMappings({}); setManualMappingsByPlate({}); setPerPrinterConfigs({}); setInitialExpandApplied(new Set()); } } else { setManualMappings({}); setManualMappingsByPlate({}); setPerPrinterConfigs({}); setInitialExpandApplied(new Set()); } }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]); // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode) const [prevTargetModel, setPrevTargetModel] = useState(targetModel); const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate); useEffect(() => { if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) { setPrevTargetModel(targetModel); setPrevPlateForOverrides(selectedPlate); // Don't clear on initial render in edit mode (values are initialized from queueItem) if (mode !== 'edit-queue-item' || prevTargetModel !== null) { setFilamentOverrides({}); setForceColorMatch({}); } } }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]); // The sliced-for metadata loads async. If the user switched to model mode // before it arrived, the target is still empty (we never silently default // to another model, #2578) — fill it with the sliced-for model once known, // provided an active printer of that model exists. useEffect(() => { if (assignmentMode !== 'model' || targetModel || !slicedForModel) return; if (printers?.some((p) => p.is_active && p.model === slicedForModel)) { setTargetModel(slicedForModel); } }, [assignmentMode, targetModel, slicedForModel, printers]); // Auto-expand per-printer mapping when setting is enabled and multiple printers selected // Only applies once per printer on initial selection, not when user unchecks useEffect(() => { if (!settings?.per_printer_mapping_expanded) return; if (selectedPrinters.length <= 1) return; // Only auto-configure printers that: // 1. Haven't had initial expand applied yet // 2. Have their status loaded (so auto-configure will actually work) const printersReadyForExpand = selectedPrinters.filter(printerId => { if (initialExpandApplied.has(printerId)) return false; // Check if this printer has status loaded const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId); return result && result.status && !result.isLoading; }); if (printersReadyForExpand.length > 0) { // Mark these printers as having been initially expanded setInitialExpandApplied(prev => { const next = new Set(prev); printersReadyForExpand.forEach(id => next.add(id)); return next; }); // Auto-configure printers printersReadyForExpand.forEach(printerId => { multiPrinterMapping.autoConfigurePrinter(printerId); }); } }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && !isSubmitting) onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose, isSubmitting]); const isMultiPlate = platesData?.is_multi_plate ?? false; const plates = platesData?.plates ?? []; const filamentWarningMessage = useMemo(() => { if (!filamentWarningItems || filamentWarningItems.length === 0) return ''; const lines = filamentWarningItems.map((item) => // Under AMS Filament Backup the shortfall is against the pooled spools, // not the one slot — quoting that slot's remaining next to a pooled // requirement reads as a contradiction ("needs 1441g, remaining 1000g" // while a second full spool sits next to it). item.pooled ? t('printModal.insufficientFilamentLinePooled', { printer: item.printerName, slot: item.slotLabel, required: Math.round(item.requiredGrams), remaining: Math.round(item.remainingGrams), }) : t('printModal.insufficientFilamentLine', { printer: item.printerName, slot: item.slotLabel, required: Math.round(item.requiredGrams), remaining: Math.round(item.remainingGrams), }) ); return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n'); }, [filamentWarningItems, t]); // Add to queue mutation (single printer) const addToQueueMutation = useMutation({ mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data), }); // Update queue item mutation const updateQueueMutation = useMutation({ mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast('Queue item updated'); onSuccess?.(); onClose(); }, onError: (error: Error) => { showToast(error.message || 'Failed to update queue item', 'error'); }, }); // Get mapping for a specific printer (per-printer override or default). // A multi-plate submission maps each plate on its own — `amsMapping` and the // per-printer mappings are both derived from the union of every selected // plate's filaments, which is not this plate's print (#2551 follow-up). // Without a per-plate mapping we send none at all and let the scheduler // compute one at dispatch, which it already does per plate; a union mapping // would be used verbatim and could feed a slot from the wrong tray. const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => { if (isMultiPlateSelection) { // Fanning several plates across several printers would be a mapping per // plate *per printer*; those items go out without one and the scheduler // maps each plate against the printer it actually picks. if (plateId === null || selectedPrinters.length !== 1) return undefined; return perPlateAmsMappings.get(plateId); } // For multi-printer selection, check if this printer has an override if (selectedPrinters.length > 1) { const printerConfig = perPrinterConfigs[printerId]; if (printerConfig && !printerConfig.useDefault) { return multiPrinterMapping.getFinalMapping(printerId); } } return amsMapping; }; const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => { e?.preventDefault(); if (billingEnabled && selectedCostCenter == null) { showToast(t('printModal.noPrintableCostCenters'), 'error'); return; } if ( !options?.skipFilamentCheck && !settings?.disable_filament_warnings && !isEditing && assignmentMode === 'printer' ) { const warningItems: FilamentWarningItem[] = []; // The spool check follows what is actually dispatched: one job per selected // plate, each with the mapping that plate's queue item carries. Two plates // can also draw on the same spool, so the demand is summed per tray before // it is weighed against what is left on it — 60 g left does not cover two // plates of 40 g, even though it covers either one of them (#2551). const plateJobs = isMultiPlateSelection ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] })) : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }]; if (plateJobs.some((job) => job.reqs.length > 0) && slotMaterialsPerPrinter.size > 0) { for (const printerId of selectedPrinters) { const printerStatusForWarning = selectedPrinters.length > 1 ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status : printerStatus; const loadedFilaments = buildLoadedFilaments(printerStatusForWarning); const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label])); // Slots the backend could price. A slot missing here is one with no // inventory binding, or one whose Spoolman spool it could not read — // both mean "nothing to weigh", never "empty". const slotMaterials = slotMaterialsPerPrinter.get(printerId); const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`; if (!slotMaterials || slotMaterials.size === 0) continue; const gramsByTray = new Map(); for (const job of plateJobs) { // No mapping means the scheduler picks the trays at dispatch, against // an AMS state we cannot see from here — nothing to weigh. const printerMapping = getMappingForPrinter(printerId, job.plateId); if (!printerMapping) continue; job.reqs.forEach((req) => { if (!req.slot_id || req.slot_id <= 0) return; const globalTrayId = printerMapping[req.slot_id - 1]; if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return; gramsByTray.set(globalTrayId, (gramsByTray.get(globalTrayId) ?? 0) + req.used_grams); }); } // With AMS Filament Backup ON the firmware switches to any other slot // holding the same material, so the print is only short when the whole // pool is (#1762). The dispatcher has accounted for this since #1762 — // this check did not, and blocked prints the dispatcher would have run. // Dual-extruder printers pool per side: the firmware cannot cross // nozzles even with the backup bit set, which is why `extruder` is part // of the key the backend hands us. const backupOn = printerStatusForWarning?.ams_filament_backup === true; const poolKey = (slot: SlotMaterial) => `${slot.material_key}#${slot.extruder}`; const pooledGrams = new Map(); const pooledSlotCount = new Map(); const pooledRequired = new Map(); if (backupOn) { slotMaterials.forEach((slot) => { const key = poolKey(slot); pooledGrams.set(key, (pooledGrams.get(key) ?? 0) + slot.remaining_g); pooledSlotCount.set(key, (pooledSlotCount.get(key) ?? 0) + 1); }); for (const [globalTrayId, requiredGrams] of gramsByTray) { const slot = slotMaterials.get(globalTrayId); if (!slot) continue; const key = poolKey(slot); pooledRequired.set(key, (pooledRequired.get(key) ?? 0) + requiredGrams); } } for (const [globalTrayId, requiredGrams] of gramsByTray) { const slot = slotMaterials.get(globalTrayId); if (!slot) continue; const slotLabel = slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`; if (backupOn) { const key = poolKey(slot); const available = pooledGrams.get(key) ?? 0; const needed = pooledRequired.get(key) ?? 0; if (available >= needed) continue; warningItems.push({ printerName, slotLabel, requiredGrams: needed, remainingGrams: available, // A pool of one is just the slot itself — same numbers, so use // the plain wording rather than talk about spools that aren't there. pooled: (pooledSlotCount.get(key) ?? 1) > 1, }); continue; } if (slot.remaining_g >= requiredGrams) continue; warningItems.push({ printerName, slotLabel, requiredGrams, remainingGrams: slot.remaining_g, }); } } } if (warningItems.length > 0) { setFilamentWarningItems(warningItems); return; } } // Validate printer/model selection if (assignmentMode === 'printer' && selectedPrinters.length === 0) { showToast('Please select at least one printer', 'error'); return; } // A cross-model job has no single target model — each candidate carries its // own, and the backend gates each of them separately. Both checks below are // about the one-model case only. if (!isCrossModel && assignmentMode === 'model' && !targetModel) { showToast('Please select a target printer model', 'error'); return; } // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user // gets inline feedback instead of a failed request. if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) { showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error'); return; } setIsSubmitting(true); // Calculate total API calls: plates × printers (or 1 for model-based) const platesToQueue = selectedPlates.size > 1 ? plates.filter(p => selectedPlates.has(p.index)) : [null]; const totalCount = assignmentMode === 'model' ? platesToQueue.length : selectedPrinters.length * platesToQueue.length; setSubmitProgress({ current: 0, total: totalCount }); const results: { success: number; failed: number; errors: string[] } = { success: 0, failed: 0, errors: [], }; // Convert filament overrides from Record to array format for API. // Include all slots that either have a user override or have force_color_match enabled // (which is the default for model-based assignment). const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => { const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = []; // Process all slots from filament requirements (to capture force_color_match defaults) if (reqs?.filaments) { for (const req of reqs.filaments) { const userOverride = filamentOverrides[req.slot_id]; const isForceColor = forceColorMatch[req.slot_id] ?? false; const effectiveType = userOverride?.type ?? req.type; const effectiveColor = userOverride?.color ?? req.color; // Include slot if user changed the filament OR force_color_match is enabled if (userOverride || isForceColor) { entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor }); } } } else { // Fallback: no filament requirements data — only include explicit user overrides for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) { const id = parseInt(slotId, 10); const isForceColor = forceColorMatch[id] ?? false; entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor }); } } return entries.length > 0 ? entries : undefined; }; const filamentOverridesArray = buildFilamentOverridesArray(effectiveFilamentReqs); // A plate only carries the slots it prints (#2552). Slot ids are global to the // file, so an override on slot 3 means the same filament in every plate that // uses slot 3 — the per-plate list is a subset of the shared state, not a // rewrite of it. No fallback to the whole-file list: it holds slots this plate // never prints, and submission is gated on every selected plate having answered, // so a plate is never missing here. const overridesForPlate = (plateId: number | null) => isMultiPlateSelection && plateId !== null ? buildFilamentOverridesArray(perPlateReqs.get(plateId)) : filamentOverridesArray; // Cross-model alternatives (#671): ONE item carrying a candidate per file, // in the order the user arranged. This returns before the plate/printer // fan-out below because it deliberately fans out to nothing — the whole // point is that exactly one of these candidates ever runs. // // Filament overrides are shared rather than per-candidate, matching how // single-model assignment already behaves: the printer is unknown at queue // time, so what is expressed here is "this job needs PETG", which is true of // every slice of the same job. The AMS mapping is likewise absent — the // scheduler computes it against the printer it actually picks. if (isCrossModel) { try { await api.addToQueue({ variants: candidates.map((c) => ({ library_file_id: c.id, plate_id: candidatePlates[c.id] ?? null, filament_overrides: filamentOverridesArray, })), target_location: targetLocation, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, gcode_injection: scheduleOptions.gcodeInjection, manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() : undefined, quantity, ...printOptions, project_id: projectId ?? undefined, }); showToast(t('printModal.variants.queued', { count: candidates.length }), 'success'); queryClient.invalidateQueries({ queryKey: ['queue'] }); onSuccess?.(); onClose(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), 'error'); } finally { setIsSubmitting(false); } return; } // Batch order (#342): a create submission that produces more than one run // from one source is pre-created as a batch carrying per-plate targets, // and its id is passed to each subsequent addToQueue call. The targets are // what make the order able to say a failed run is still owed — without // them the batch only knows what it happened to queue. Only for // single-target submissions; multi-printer fan-out keeps the old per-item // shape, where "how many" is answered by the printer count. const plateTargets = platesToQueue.map((plate, index) => { const plateIndex = plate ? plate.index : selectedPlate; return { plate_id: plateIndex, plate_name: plate ? (plate.name || null) : null, quantity_target: quantityForPlate(plateIndex), sort_order: index, }; }); const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0); const shouldAutoBatch = mode === 'create' && (platesToQueue.length > 1 || totalRuns > 1) && (assignmentMode === 'model' || selectedPrinters.length === 1); let autoBatchId: number | null = null; if (shouldAutoBatch) { try { const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, ''); const batchName = platesToQueue.length > 1 ? `${baseName || 'Batch'} · ${platesToQueue.length} plates` : `${baseName || 'Batch'} ×${totalRuns}`; const batch = await api.createBatch({ name: batchName, archive_id: isLibraryFile ? undefined : archiveId, library_file_id: isLibraryFile ? libraryFileId : undefined, plates: plateTargets, }); autoBatchId = batch.id; } catch { // Non-fatal: fall back to ungrouped items so the queue still works. // The server still creates a plain batch when quantity > 1, so the // queue grouping survives even when the order layer doesn't. autoBatchId = null; } } const asapInsertionCounts = new Map(); const applyAsapInsertion = ( queueData: PrintQueueItemCreate, printerId: number | null, itemCount = 1, ) => { if (scheduleOptions.scheduleType !== 'asap') return; const scopeKey = printerId !== null ? `printer:${printerId}` : 'unassigned'; const insertPosition = (asapInsertionCounts.get(scopeKey) ?? 0) + 1; queueData.insert_at_top = true; queueData.insert_position = insertPosition; asapInsertionCounts.set(scopeKey, insertPosition + itemCount - 1); }; // Common queue data for create and edit modes // One panel per plate when several are selected, one shared panel // otherwise -- the same split the AMS mappings use above. const rackChoiceForPlate = (plateId: number | null): Record | undefined => { const choice = plateId != null && isMultiPlateSelection ? nozzleRackChoiceByPlate[plateId] : nozzleRackChoice; return choice && Object.keys(choice).length > 0 ? choice : undefined; }; const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => { const plateId = plateOverride !== undefined ? plateOverride : selectedPlate; const plateEstimatedCost = plateId != null && isMultiPlateSelection ? estimatedCostsByPlate[plateId] ?? null : estimatedCost; return { printer_id: assignmentMode === 'printer' ? printerId : null, target_model: assignmentMode === 'model' ? targetModel : null, target_location: assignmentMode === 'model' ? targetLocation : null, filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : undefined, // Use library_file_id for library files, archive_id for archives archive_id: isLibraryFile ? undefined : archiveId, library_file_id: isLibraryFile ? libraryFileId : undefined, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, gcode_injection: scheduleOptions.gcodeInjection, manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart, // When the user clicks "Print Anyway" on the frontend deficit warning, // persist that acknowledgement so the scheduler doesn't immediately // re-flag the item on its first dispatch tick (#1698-followup). skip_filament_check: options?.skipFilamentCheck === true ? true : undefined, ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined, // Rack positions per filament group (#1784). Only sent in printer mode: // in model mode the target printer is not known yet, and the rack it // will be dispatched to cannot be validated against here. The dispatcher // assigns them itself in that case. nozzle_rack_choice: printerId ? rackChoiceForPlate(plateId) : undefined, plate_id: plateId, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() : undefined, ...printOptions, project_id: projectId ?? undefined, cost_center_id: billingEnabled ? selectedCostCenterId : undefined, estimated_cost: billingEnabled && selectedCostCenterId != null ? plateEstimatedCost : undefined, batch_id: autoBatchId ?? undefined, cleanup_library_after_dispatch: cleanupLibraryAfterDispatch, }; }; // Model-based assignment if (assignmentMode === 'model') { let progressCounter = 0; for (const plate of platesToQueue) { progressCounter++; setSubmitProgress({ current: progressCounter, total: totalCount }); const plateId = plate ? plate.index : selectedPlate; try { if (mode === 'edit-queue-item' && !plate) { // Edit mode - update with target_model (only for single plate) const updateData: PrintQueueItemUpdate = { printer_id: null, target_model: targetModel, target_location: targetLocation, filament_overrides: filamentOverridesArray || null, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, gcode_injection: scheduleOptions.gcodeInjection, manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart, ams_mapping: undefined, plate_id: plateId, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() : null, ...printOptions, cost_center_id: billingEnabled ? selectedCostCenterId : undefined, estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined, }; await updateQueueMutation.mutateAsync(updateData); } else { // Add-to-queue mode with model-based assignment const queueData = getQueueData(null, plateId); const plateQuantity = quantityForPlate(plateId); if (plateQuantity > 1) queueData.quantity = plateQuantity; applyAsapInsertion(queueData, null, plateQuantity); await addToQueueMutation.mutateAsync(queueData); } results.success++; } catch (error) { results.failed++; const plateName = plate ? (plate.name || `Plate ${plate.index}`) : ''; results.errors.push(plateName ? `${plateName}: ${(error as Error).message}` : (error as Error).message); } } } else { // Printer-based assignment: loop through plates × printers // Compute stagger base time once before the loop const useStagger = scheduleOptions.staggerEnabled && !isEditing && selectedPrinters.length > 1; const staggerBaseTime = useStagger ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).getTime() : Date.now()) : 0; let progressCounter = 0; for (const plate of platesToQueue) { const plateId = plate ? plate.index : selectedPlate; for (let i = 0; i < selectedPrinters.length; i++) { const printerId = selectedPrinters[i]; progressCounter++; setSubmitProgress({ current: progressCounter, total: totalCount }); try { if (isEditing && progressCounter === 1) { // Edit mode - update the original queue item for the first entry const printerMapping = getMappingForPrinter(printerId, plateId); const updateData: PrintQueueItemUpdate = { printer_id: printerId, target_model: null, target_location: null, require_previous_success: scheduleOptions.requirePreviousSuccess, auto_off_after: scheduleOptions.autoOffAfter, gcode_injection: scheduleOptions.gcodeInjection, manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart, ams_mapping: printerMapping, // null, not undefined: an operator who cleared their picks // means "assign these again", and undefined would leave the // stale ones on the row (#1784). nozzle_rack_choice: rackChoiceForPlate(plateId) ?? null, plate_id: plateId, scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime ? new Date(scheduleOptions.scheduledTime).toISOString() : null, ...printOptions, cost_center_id: billingEnabled ? selectedCostCenterId : undefined, estimated_cost: billingEnabled && selectedCostCenterId != null ? (plateId != null && isMultiPlateSelection ? estimatedCostsByPlate[plateId] ?? null : estimatedCost) : undefined, }; await updateQueueMutation.mutateAsync(updateData); } else { // New print mode, staggered print, or edit mode with additional entries const queueData = getQueueData(printerId, plateId); const plateQuantity = quantityForPlate(plateId); if (plateQuantity > 1) queueData.quantity = plateQuantity; applyAsapInsertion(queueData, printerId, plateQuantity); // Apply stagger offset for groups after the first if (useStagger) { const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize); if (groupIndex > 0) { const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000; queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString(); } // Group 0 with ASAP: no scheduled_time (start immediately) // Group 0 with scheduled: keeps the scheduled_time from getQueueData } await addToQueueMutation.mutateAsync(queueData); } results.success++; } catch (error) { results.failed++; const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`; const plateName = plate ? (plate.name || `Plate ${plate.index}`) : ''; const label = plateName ? `${printerName} (${plateName})` : printerName; results.errors.push(`${label}: ${(error as Error).message}`); } } } } setIsSubmitting(false); // Show result toast if (results.failed === 0) { if (isEditing) { if (mode === 'edit-queue-item') { showToast('Queue item updated'); } } else if (results.success === 1) { const waitForIdleToast = await asapToastShouldPromiseLaterStart(); showToast( waitForIdleToast ? t('queue.printQueuedWillStartWhenIdle') : assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'), ); } else { const waitForIdleToast = await asapToastShouldPromiseLaterStart(); showToast( waitForIdleToast ? t('queue.printQueuedWillStartWhenIdle') : t('queue.itemsQueued', { count: results.success }), ); } queryClient.invalidateQueries({ queryKey: ['queue'] }); onSuccess?.(); onClose(); } else if (results.success === 0) { showToast(`Failed: ${results.errors[0]}`, 'error'); } else { showToast(`${results.success} succeeded, ${results.failed} failed`, 'error'); queryClient.invalidateQueries({ queryKey: ['queue'] }); } }; const isPending = isSubmitting || updateQueueMutation.isPending; const canSubmit = useMemo(() => { if (isPending) return false; // Billing requires a server-authorized cost center. Wait for the query and // keep submission disabled when the user has no printable center, rather // than letting the API fail with an unexplained 400. if (billingEnabled && (loadingCostCenters || selectedCostCenter == null)) return false; // Need valid printer/model selection if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false; // Both are about the single-model case. A cross-model job has no one target // model, and each candidate is gated against its own by the backend (#671). if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false; // Cross-model mismatch cannot be queued (#2578) if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false; // For multi-plate files, need at least one plate selected if (isMultiPlate && selectedPlates.size === 0) return false; // Every selected plate has to have answered before we can queue it: a plate // still in flight would be sent with no mapping and no overrides, and one that // failed to load cannot be mapped at all. Deselect the failing plate to queue // the rest — the banner above says which state we are in. if (perPlateReqsPending || perPlateReqsFailed) return false; // A single-printer AMS job must wait for the printer's live status before it // can resolve the filament mapping. Submitting mid-load matched against zero // known trays and serialized an all-[-1] mapping, which dispatched the print // to the empty external feed (#2589). if (assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading) return false; return true; }, [ selectedPrinters.length, assignmentMode, targetModel, slicedForModel, isMultiPlate, selectedPlates.size, isPending, perPlateReqsPending, perPlateReqsFailed, printerStatusLoading, isCrossModel, billingEnabled, loadingCostCenters, selectedCostCenter, ]); // Quantity only applies for single-printer or model-based assignment (not multi-printer) const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity; // On a multi-plate file the per-plate steppers own the quantity and the // global field is hidden (#342) — the reporter's case is "plate 1 once, // plate 2 twice", which one shared number cannot express. Single-plate // files, and edit mode, keep the single field exactly as before. const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1; /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */ const quantityForPlate = (plateIndex: number | null): number => { if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity; // Multi-printer fan-out already means one copy per printer; multiplying by // a per-plate count on top would silently produce plates × printers × n. if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1; return Math.max(1, plateQuantities[plateIndex] ?? 1); }; // Clear gcode_injection if the admin removes all snippets while the modal // is open — the checkbox itself hides via hasGcodeSnippets in // ScheduleOptions, but the boolean would otherwise stay true and ship to // the API. The previous gate also reset the flag whenever effectiveQuantity // dropped to <= 1, which silently un-ticked the checkbox on every single- // print create flow (#1852). The scheduler reads item.gcode_injection per // queue item regardless of batch size, so there's no underlying reason for // the quantity-1 case to be blocked. useEffect(() => { if (mode === 'create' && scheduleOptions.gcodeInjection && !settings?.gcode_snippets) { setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false })); } }, [mode, settings?.gcode_snippets, scheduleOptions.gcodeInjection]); // Modal title and action button text based on mode const getModalConfig = () => { if (!isEditing) { return { title: t('common.print'), icon: Printer, submitText: t('common.print'), submitIcon: Printer, loadingText: submitProgress.total > 1 ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total }) : t('queue.adding'), }; } // edit-queue-item mode return { title: t('queue.editQueueItem'), icon: Pencil, submitText: t('common.save'), submitIcon: Pencil, loadingText: submitProgress.total > 1 ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total }) : t('common.saving'), }; }; const modalConfig = getModalConfig(); const TitleIcon = modalConfig.icon; const SubmitIcon = modalConfig.submitIcon; // Show filament mapping when: // - Single printer selected // - For archives: plate is selected (for multi-plate) or not required (single-plate) // - For library files: always show (no plate selection) const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && ( isLibraryFile || (isMultiPlate ? selectedPlate !== null : true) ); useEffect(() => { if (!showFilamentMapping || archiveDataMissing || selectedPrinters.length !== 1) { setEstimatedCost(null); } }, [archiveDataMissing, selectedPrinters.length, showFilamentMapping]); // Several plates on one printer: one mapping panel per plate, each mapping only // the slots its own plate prints. Multi-printer fan-out would be a panel per // plate *per printer*, so those items ship without a mapping and the scheduler // computes one per plate when it picks the printer. const showPerPlateFilamentMapping = !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1; // Model mode has no printer and so no trays to map onto; what it offers instead // is the filament each slot must be printed in, which the scheduler matches // against whatever printer of the model it picks. Needs the model's loaded // filaments to offer as alternatives. // Cross-model items have no targetModel by design — their candidates each // carry their own — so gate on having somewhere to source choices from. const showFilamentOverride = assignmentMode === 'model' && (isCrossModel || !!targetModel) && !!effectiveAvailableFilaments && effectiveAvailableFilaments.length > 0; // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682). // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show // the toggle without a specific printer selected. For printer-mode we rely // on the canonical `nozzle_count` field auto-detected from MQTT. const DUAL_NOZZLE_MODELS = useMemo( () => new Set(['H2D', 'H2DPRO', 'H2C', 'X2D']), [], ); const showDualNozzleOptions = useMemo(() => { if (assignmentMode === 'model') { if (!targetModel) return false; return DUAL_NOZZLE_MODELS.has(targetModel.toUpperCase().replace(/[\s-]/g, '')); } if (!printers || selectedPrinters.length === 0) return false; return selectedPrinters.some(id => printers.find(p => p.id === id)?.nozzle_count === 2); }, [assignmentMode, targetModel, printers, selectedPrinters, DUAL_NOZZLE_MODELS]); return (
{/* 4xl rather than the 2xl this was: the filament rows carry the most horizontal content in the dialog — a required name, a nozzle picker on rack machines, and an AMS slot dropdown naming type, colour and remaining weight — and anything narrower truncated the name to "Bamb..." (#1784). 4xl is 896px, so it still fits a 1024-wide laptop with the surrounding padding, and `w-full` keeps it fluid below that. */} e.stopPropagation()} > {/* Header */}

{modalConfig.title}

{/* Archive name */}

Print Job {archiveName}

{/* Build-plate badge for the selected (or sole) plate — surfaced early so the user knows which plate to mount before scheduling (#1281). PlateSelector renders its own per-plate badges for multi-plate files; this badge covers the single-plate case and the multi-plate case where exactly one plate is selected. */} {(() => { if (!plates.length) return null; const target = selectedPlate != null ? plates.find(p => p.index === selectedPlate) : plates[0]; const bed = getBedTypeInfo(target?.bed_type); if (!bed) return null; return (

{bed.label}

); })()} {/* Plate selection - first so users know filament requirements before selecting printers */} { setSelectedPlates(prev => { const next = new Set(prev); if (!isEditing) { // Multi-select: toggle the plate if (next.has(plateIndex)) { next.delete(plateIndex); } else { next.add(plateIndex); } } else { // Single-select: replace selection next.clear(); next.add(plateIndex); } return next; }); }} onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined} onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined} multiSelect={!isEditing} quantities={usePerPlateQuantities ? plateQuantities : undefined} onQuantityChange={usePerPlateQuantities ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value })) : undefined} /> {/* Cross-model alternatives (#671) replace the printer picker entirely: the user already answered "which printer" by choosing these files, and the remaining question is only which they'd rather have. */} {isCrossModel && ( setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId })) } /> )} {hasEditingVariants && ( {}} plateByFile={{}} onPlateChange={() => {}} /> )} {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */} {!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && ( )} {/* Filament override - shown in model mode when filament requirements are available */} {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && ( setForceColorMatch((prev) => ({ ...prev, [slotId]: value })) } /> )} {/* Filament override, one panel per selected plate. `effectiveFilamentReqs` is keyed on `selectedPlate`, which is null as soon as two plates are picked, so a multi-plate selection used to render this panel from whatever the whole-file query had left in the cache — the union of every plate's filaments, or nothing at all once the plates query was warm and the whole-file query therefore never ran, which is why the section vanished on the second open of the dialog (#2552). */} {showFilamentOverride && isMultiPlateSelection && selectedPlateIds.map((plateId, idx) => { const plate = plates.find((p) => p.index === plateId); const plateReqs = perPlateReqs.get(plateId); if (!plateReqs) return null; return ( setForceColorMatch((prev) => ({ ...prev, [slotId]: value })) } /> ); })} {/* Compatibility warning when sliced model doesn't match selected printer */} {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => { const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]); if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) { return (
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
); } return null; })()} {/* Warning when archive data couldn't be loaded */} {archiveDataMissing && (

Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.

)} {/* A selected plate whose filaments could not be read cannot be mapped and cannot carry its forced colours, so it is not queued silently — say so and hold the button until the plate is deselected. */} {perPlateReqsFailed && (

{t( 'printModal.plateFilamentsUnreadable', "The filaments of a selected plate could not be read, so it can't be mapped. Deselect it to queue the others.", )}

)} {/* Filament mapping - only show when single printer selected */} {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && ( setForceColorMatch((prev) => ({ ...prev, [slotId]: value })) } archiveAmsMapping={archiveSlicerAmsMapping} nozzleRackChoice={nozzleRackChoice} onNozzleRackChoiceChange={setNozzleRackChoice} /> )} {/* Filament mapping, one panel per selected plate — each plate is its own print with its own slots, so it gets its own AMS mapping. */} {showPerPlateFilamentMapping && !archiveDataMissing && selectedPlateIds.map((plateId) => { const plate = plates.find((p) => p.index === plateId); const plateReqs = perPlateReqs.get(plateId); if (!plateReqs) return null; return ( setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: mappings })) } onEstimatedCostChange={(cost) => setEstimatedCostsByPlate((prev) => ({ ...prev, [plateId]: cost })) } budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null} quantity={quantityForPlate(plateId)} defaultExpanded={false} currencySymbol={currencySymbol} defaultCostPerKg={defaultCostPerKg} forceColorMatch={forceColorMatch} onForceColorMatchChange={(slotId, value) => setForceColorMatch((prev) => ({ ...prev, [slotId]: value })) } archiveAmsMapping={archiveSlicerAmsMapping} nozzleRackChoice={nozzleRackChoiceByPlate[plateId] ?? {}} onNozzleRackChoiceChange={(choice) => setNozzleRackChoiceByPlate((prev) => ({ ...prev, [plateId]: choice })) } /> ); })} {/* Print options */} {(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && ( )} {billingEnabled && printableCostCenters.length > 0 && ( )} {billingEnabled && !loadingCostCenters && printableCostCenters.length === 0 && (
{t('printModal.noPrintableCostCenters')}
)} {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection, and for multi-plate files where the per-plate steppers in PlateSelector own the number instead (#342). */} {mode !== 'edit-queue-item' && !usePerPlateQuantities && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))} className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green" /> {quantity > 1 && ( {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })} )}
)} {/* Schedule options */} 1} printerCount={selectedPrinters.length} hasGcodeSnippets={!!settings?.gcode_snippets} /> {/* Error message */} {updateQueueMutation.isError && (
{(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
)} {/* Waiting for the printer's AMS status: submitting now would map against zero known trays and dispatch to the empty external feed (#2589). */} {assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading && (
{t('printModal.waitingForAmsStatus', { printer: printers?.find((p) => p.id === effectivePrinterId)?.name ?? '', })}
)} {/* Actions */}
{filamentWarningItems && filamentWarningItems.length > 0 && ( { setFilamentWarningItems(null); void handleSubmit(undefined, { skipFilamentCheck: true }); }} onCancel={() => setFilamentWarningItems(null)} /> )}
); } // Re-export types for convenience export type { PrintModalMode, PrintModalProps } from './types';