| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064 |
- import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
- import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, ThumbsUp, X } from 'lucide-react';
- import { useEffect, useMemo, useRef, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import type { CostCenterSummary, 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, isPrinterCurrentlyDispatchable } 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';
- import { NumberInput } from '../NumberInput';
- /** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */
- function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean {
- const hex = (c: string) => (c || '').replace('#', '').toLowerCase().slice(0, 6);
- return (a.type || '').toUpperCase() === (b.type || '').toUpperCase() && hex(a.color) === hex(b.color);
- }
- /**
- * The filament list as the tray matcher should see it: each overridden slot
- * asks for the override's type and colour, not the 3MF's (#3133). Mirrors the
- * scheduler's `_apply_filament_overrides` for a manual override — the 3MF's
- * `tray_info_idx` names the replaced spool's SKU, so it is dropped and matching
- * falls back to type + colour. An entry naming the slot's own filament is no
- * swap — a virtual printer's force-colour entries are exactly that — so the
- * slot keeps its idx and with it the PLA-variant pin (#2650). Slots with no
- * override pass through unchanged.
- */
- function withFilamentOverrides(
- reqs: FilamentReqsData | undefined,
- overrides: Record<number, { type: string; color: string }>,
- ): FilamentReqsData | undefined {
- const isSwap = (f: FilamentReqsData['filaments'][number]) => {
- const override = overrides[f.slot_id];
- return !!override && !isSameFilament(override, f);
- };
- if (!reqs?.filaments || !reqs.filaments.some(isSwap)) return reqs;
- return {
- ...reqs,
- filaments: reqs.filaments.map((f) =>
- isSwap(f) ? { ...f, type: overrides[f.slot_id].type, color: overrides[f.slot_id].color, tray_info_idx: '' } : f,
- ),
- };
- }
- /**
- * 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<VariantCandidate[]>(variantFiles ?? []);
- const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
- 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<number[]>(() => {
- // 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<Set<number>>(() => {
- 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<Record<number, number>>({});
- const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
- 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,
- confirm_outcome: queueItem.confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
- 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<ScheduleOptions>(() => {
- 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<Record<number, number>>(() => {
- if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
- const mappings: Record<number, number> = {};
- 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<Record<number, PerPrinterConfig>>({});
- // Assignment mode: 'printer' (specific) or 'model' (any of model)
- const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
- // 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<string | null>(() => {
- 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<string | null>(() => {
- if (mode === 'edit-queue-item' && queueItem?.target_location) {
- return queueItem.target_location;
- }
- return null;
- });
- const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
- mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null
- );
- const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
- const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
- // Filament overrides for model-based assignment: slot_id -> {type, color}
- const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
- if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
- const overrides: Record<number, { type: string; color: string }> = {};
- 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<Record<number, boolean>>(() => {
- if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
- const flags: Record<number, boolean> = {};
- 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<FilamentWarningItem[] | null>(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<Set<number>>(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,
- confirm_outcome: settings.default_confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
- 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<globalTrayId, gramsRemaining> 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,
- // Same key, same reason as FilamentMapping's copy — see the note there.
- // Concurrent mounts dedupe, so opening the dialog costs one fetch per
- // printer however many plate panels are on screen.
- refetchOnMount: 'always',
- enabled: selectedPrinters.length > 0,
- })),
- });
- const inventoryByTrayIdPerPrinter = useMemo(() => {
- const result = new Map<number, Map<number, number>>();
- selectedPrinters.forEach((printerId, idx) => {
- const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
- if (!data) return;
- const printerMap = new Map<number, number>();
- 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<number, Map<number, SlotMaterial>>();
- selectedPrinters.forEach((printerId, idx) => {
- const slots = inventoryRemainQueries[idx]?.data?.slot_materials;
- if (!slots) return;
- const printerMap = new Map<number, SlotMaterial>();
- 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;
- // What the tray matching works from. An override chosen in model mode stays
- // in force when the job is moved to a specific printer (#3133), so that
- // printer's trays are matched against the requested filament rather than the
- // one the 3MF was sliced with. The override panel keeps the original list —
- // it shows "sliced brown, print Bone White".
- const mappingFilamentReqs = useMemo(
- () => withFilamentOverrides(effectiveFilamentReqs, filamentOverrides),
- [effectiveFilamentReqs, filamentOverrides],
- );
- // 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<string>();
- const merged: NonNullable<typeof availableFilaments> = [];
- 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 asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
- 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(
- mappingFilamentReqs,
- 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<number, FilamentReqsData>();
- 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('|')]);
- // Per-plate twin of `mappingFilamentReqs`: slot ids are global to the file, so
- // one override applies to every plate that prints that slot (#3133).
- const mappingPerPlateReqs = useMemo(() => {
- const byPlate = new Map<number, FilamentReqsData>();
- for (const [plateId, reqs] of perPlateReqs) {
- byPlate.set(plateId, withFilamentOverrides(reqs, filamentOverrides) ?? reqs);
- }
- return byPlate;
- }, [perPlateReqs, filamentOverrides]);
- // 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<Record<number, Record<number, number>>>({});
- // 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<Record<number, number>>(() => {
- // 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<number, number> = {};
- 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<Record<number, Record<number, number>>>({});
- // 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<number, number[] | undefined>();
- 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 = mappingPerPlateReqs.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,
- mappingPerPlateReqs,
- manualMappingsByPlate,
- singlePrinterPreferLowest,
- selectedPrinters.length,
- ]);
- // Multi-printer filament mapping (for per-printer configuration)
- const multiPrinterMapping = useMultiPrinterFilamentMapping(
- selectedPrinters,
- printers,
- mappingFilamentReqs,
- 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]);
- // Cross-model: the candidate list owns plate choice, and `platesData` is the
- // primary file's. `selectedPlate` still keys the filament-requirements query,
- // so it has to follow that file's dropdown — otherwise the override panel
- // describes plate 1 while the job runs plate 3 (#3101). An untouched dropdown
- // renders its first plate, which is what the auto-select above already set.
- useEffect(() => {
- if (!isCrossModel || !libraryFileId) return;
- const chosen = candidatePlates[libraryFileId];
- if (chosen == null) return;
- setSelectedPlates((prev) => (prev.size === 1 && prev.has(chosen) ? prev : new Set([chosen])));
- }, [isCrossModel, libraryFileId, candidatePlates]);
- // 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).
- // `prevTargetModel` is the last model actually targeted, so it skips nulls:
- // "Any P2S" -> "Specific Printer" empties targetModel without naming another
- // model, and the override is the job's filament, not a tray on some printer —
- // it survives the switch and is matched against the chosen printer (#3133).
- // Going P2S -> (none) -> X1C still compares P2S with X1C and clears.
- const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
- const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
- useEffect(() => {
- const modelChanged = targetModel !== null && targetModel !== prevTargetModel;
- const plateChanged = selectedPlate !== prevPlateForOverrides;
- if (!modelChanged && !plateChanged) return;
- if (modelChanged) setPrevTargetModel(targetModel);
- if (plateChanged) setPrevPlateForOverrides(selectedPlate);
- // A first model after none is a choice, not a change: nothing was picked
- // against another model's filaments. That also covers the initial render in
- // edit mode, where the values are initialized from queueItem.
- if (modelChanged && !plateChanged && prevTargetModel === null) return;
- 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<number, number>();
- 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<string, number>();
- const pooledSlotCount = new Map<string, number>();
- const pooledRequired = new Map<string, number>();
- 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).
- // The dialog keeps an override as type + colour only, so an entry that comes
- // back unchanged gets the variant id the item already carried re-attached. A
- // virtual printer writes force-colour entries with the 3MF's tray_info_idx to
- // tell Basic, Matte and Silk PLA apart (#2650); saving the item — which a
- // specific-printer edit now does for the overrides too (#3133) — must not
- // quietly drop that pin. A changed entry is a swap and has no idx to keep.
- const storedOverrideBySlot = new Map(
- (mode === 'edit-queue-item' ? queueItem?.filament_overrides ?? [] : []).map((o) => [o.slot_id, o]),
- );
- const storedVariantFor = (slotId: number, type: string, color: string) => {
- const stored = storedOverrideBySlot.get(slotId);
- return stored?.tray_info_idx && isSameFilament(stored, { type, color }) ? { tray_info_idx: stored.tray_info_idx } : {};
- };
- const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
- const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; tray_info_idx?: 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), ...storedVariantFor(req.slot_id, effectiveType, 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), ...storedVariantFor(id, type, 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;
- // A specific-printer job carries only the slots the user actually changed
- // (#3133): the tray mapping was matched against them, and they are what the
- // scheduler needs if it has to recompute that mapping at dispatch. The
- // force-colour flags on their own stay behind — printer mode never sent
- // them, and changing that is not what this is for.
- const printerOverridesForPlate = (plateId: number | null) => {
- const entries = overridesForPlate(plateId)?.filter((o) => filamentOverrides[o.slot_id]);
- return entries && entries.length > 0 ? entries : undefined;
- };
- // 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;
- }
- }
- // ASAP items go to the top of the queue in the order this submit creates
- // them. One counter for the whole submit: positions are a single sequence
- // across every printer and model (#3200), so a per-printer counter would put
- // each printer's first item at position 1 and shuffle them.
- let asapInserted = 0;
- const applyAsapInsertion = (queueData: PrintQueueItemCreate, itemCount = 1) => {
- if (scheduleOptions.scheduleType !== 'asap') return;
- queueData.insert_at_top = true;
- queueData.insert_position = asapInserted + 1;
- asapInserted += itemCount;
- };
- // 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<number, number> | 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) : printerOverridesForPlate(plateId),
- // 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, 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,
- // null, not undefined: omitting the field left a model job's
- // overrides on the row after it moved to a printer, whatever the
- // user did with them here (#3133).
- filament_overrides: printerOverridesForPlate(plateId) ?? 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, 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.
- // Cross-model is excluded: its plate choice is per candidate and lives in
- // VariantCandidates, so there are no per-plate steppers to own the number
- // and the global Quantity field below is the only one there is (#3101).
- const usePerPlateQuantities =
- mode === 'create' && !isCrossModel && 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 (
- <div
- className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
- onClick={isSubmitting ? undefined : onClose}
- >
- {/* 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. */}
- <Card
- className="w-full max-w-4xl max-h-[90vh] overflow-y-auto"
- onClick={(e) => e.stopPropagation()}
- >
- <CardContent className="p-0">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-2">
- <TitleIcon className="w-5 h-5 text-bambu-green" />
- <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
- </div>
- <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
- <X className="w-5 h-5" />
- </Button>
- </div>
- <form onSubmit={handleSubmit} className="p-4 space-y-4">
- {/* Archive name */}
- <p className="text-sm text-bambu-gray">
- <span className="block text-bambu-gray mb-1">Print Job</span>
- <span className="text-white font-medium truncate block">{archiveName}</span>
- </p>
- {/* 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 (
- <p className="flex items-center gap-1.5 text-xs text-bambu-gray -mt-2" title={bed.label}>
- <img src={bed.icon} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
- <span className="truncate">{bed.label}</span>
- </p>
- );
- })()}
- {/* Plate selection - first so users know filament requirements before
- selecting printers. Cross-model has no use for it: the plate is
- chosen per candidate in the list below, and this selector's own
- choice never reached the request — it only decided which plate
- the filament panel described (#3101). */}
- {!isCrossModel && (
- <PlateSelector
- plates={plates}
- isMultiPlate={isMultiPlate}
- selectedPlates={selectedPlates}
- onToggle={(plateIndex) => {
- 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 && (
- <VariantCandidates
- candidates={candidates}
- onReorder={setCandidates}
- plateByFile={candidatePlates}
- onPlateChange={(fileId, plateId) =>
- setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
- }
- />
- )}
- {hasEditingVariants && (
- <VariantCandidates
- candidates={editingVariants}
- readOnly
- readOnlyNote={t('printModal.variants.editNote')}
- onReorder={() => {}}
- plateByFile={{}}
- onPlateChange={() => {}}
- />
- )}
- {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
- {!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
- <PrinterSelector
- printers={printers || []}
- selectedPrinterIds={selectedPrinters}
- onMultiSelect={setSelectedPrinters}
- isLoading={loadingPrinters}
- allowMultiple={true}
- showInactive={mode === 'edit-queue-item'}
- disableBusy={false}
- printerMappingResults={multiPrinterMapping.printerResults}
- // The per-printer tray editor inside the selector maps one filament
- // list onto each printer. Several plates have several lists, and a
- // fan-out across printers ships no mapping at all (the scheduler maps
- // each plate against the printer it picks), so the editor would be
- // collecting tray choices it then throws away. Withhold its input.
- filamentReqs={isMultiPlateSelection ? undefined : mappingFilamentReqs}
- onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
- onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
- assignmentMode={assignmentMode}
- onAssignmentModeChange={setAssignmentMode}
- targetModel={targetModel}
- onTargetModelChange={setTargetModel}
- targetLocation={targetLocation}
- onTargetLocationChange={setTargetLocation}
- slicedForModel={slicedForModel}
- />
- )}
- {/* Filament override - shown in model mode when filament requirements are available */}
- {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
- <FilamentOverride
- filamentReqs={effectiveFilamentReqs}
- availableFilaments={effectiveAvailableFilaments!}
- overrides={filamentOverrides}
- onChange={setFilamentOverrides}
- forceColorMatch={forceColorMatch}
- onForceColorMatchChange={(slotId, value) =>
- 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 (
- <FilamentOverride
- key={plateId}
- plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
- showHint={idx === 0}
- filamentReqs={plateReqs}
- availableFilaments={availableFilaments!}
- overrides={filamentOverrides}
- onChange={setFilamentOverrides}
- forceColorMatch={forceColorMatch}
- onForceColorMatchChange={(slotId, value) =>
- 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 (
- <div className="p-3 mb-2 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2">
- <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
- <span className="text-sm text-yellow-700 dark:text-yellow-400">
- File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
- </span>
- </div>
- );
- }
- return null;
- })()}
- {/* Warning when archive data couldn't be loaded */}
- {archiveDataMissing && (
- <div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
- <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
- <p className="text-orange-700 dark:text-orange-400">
- Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
- </p>
- </div>
- )}
- {/* 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 && (
- <div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
- <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
- <p className="text-orange-700 dark:text-orange-400">
- {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.",
- )}
- </p>
- </div>
- )}
- {/* Filament mapping - only show when single printer selected */}
- {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
- <FilamentMapping
- printerId={effectivePrinterId!}
- filamentReqs={mappingFilamentReqs}
- manualMappings={manualMappings}
- onManualMappingChange={setManualMappings}
- onEstimatedCostChange={setEstimatedCost}
- budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
- quantity={effectiveQuantity}
- defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
- currencySymbol={currencySymbol}
- defaultCostPerKg={defaultCostPerKg}
- forceColorMatch={forceColorMatch}
- onForceColorMatchChange={(slotId, value) =>
- 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 = mappingPerPlateReqs.get(plateId);
- if (!plateReqs) return null;
- return (
- <FilamentMapping
- key={plateId}
- printerId={effectivePrinterId!}
- plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
- filamentReqs={plateReqs}
- manualMappings={manualMappingsByPlate[plateId] ?? {}}
- onManualMappingChange={(mappings) =>
- 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)) && (
- <PrintOptionsPanel
- options={printOptions}
- onChange={setPrintOptions}
- defaultExpanded={!!initialSelectedPrinterIds?.length}
- showDualNozzleOptions={showDualNozzleOptions}
- />
- )}
- {billingEnabled && printableCostCenters.length > 0 && (
- <CostCenterSelect
- costCenters={printableCostCenters}
- selectedCostCenterId={selectedCostCenterId}
- onChange={setSelectedCostCenterId}
- />
- )}
- {billingEnabled && !loadingCostCenters && printableCostCenters.length === 0 && (
- <div
- role="alert"
- className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-800 dark:text-yellow-300 flex items-start gap-2"
- >
- <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
- {t('printModal.noPrintableCostCenters')}
- </div>
- )}
- {/* 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) && (
- <div className="flex items-center gap-3">
- <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
- {t('queue.quantity', 'Quantity')}
- </label>
- <NumberInput
- id="printQuantity"
- min={1}
- max={999}
- value={quantity}
- onChange={setQuantity}
- fallback={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 && (
- <span className="text-xs text-bambu-gray">
- {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
- </span>
- )}
- </div>
- )}
- {/* Schedule options */}
- <ScheduleOptionsPanel
- options={scheduleOptions}
- onChange={setScheduleOptions}
- dateFormat={settings?.date_format || 'system'}
- timeFormat={settings?.time_format || 'system'}
- canControlPrinter={hasPermission('printers:control')}
- showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
- printerCount={selectedPrinters.length}
- hasGcodeSnippets={!!settings?.gcode_snippets}
- />
- {/* Outcome prompt (#1898) sits outside the collapsed Print Options
- panel so it is discoverable; it edits the same printOptions
- field as the row inside the panel. */}
- <button
- type="button"
- aria-pressed={printOptions.confirm_outcome}
- title={t('printModal.askForOutcomeTitle')}
- onClick={() => setPrintOptions((prev) => ({ ...prev, confirm_outcome: !prev.confirm_outcome }))}
- className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors ${
- printOptions.confirm_outcome
- ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
- : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
- }`}
- >
- <ThumbsUp className="w-4 h-4" />
- {t('printModal.askForOutcome')}
- </button>
- {/* Error message */}
- {updateQueueMutation.isError && (
- <div className="mb-4 p-3 bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
- {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
- </div>
- )}
- {/* 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 && (
- <div className="mb-4 p-3 bg-blue-100 dark:bg-blue-500/20 border border-blue-500/50 rounded-lg text-sm text-blue-700 dark:text-blue-400 flex items-center gap-2">
- <Loader2 className="w-4 h-4 animate-spin" />
- {t('printModal.waitingForAmsStatus', {
- printer: printers?.find((p) => p.id === effectivePrinterId)?.name ?? '',
- })}
- </div>
- )}
- {/* Actions */}
- <div className="flex gap-3 pt-2">
- <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={!canSubmit}
- className="flex-1"
- >
- {isPending ? (
- <>
- <Loader2 className="w-4 h-4 animate-spin" />
- {modalConfig.loadingText}
- </>
- ) : (
- <>
- <SubmitIcon className="w-4 h-4" />
- {modalConfig.submitText}
- </>
- )}
- </Button>
- </div>
- </form>
- </CardContent>
- </Card>
- {filamentWarningItems && filamentWarningItems.length > 0 && (
- <ConfirmModal
- title={t('printModal.insufficientFilamentTitle')}
- message={filamentWarningMessage}
- confirmText={t('printModal.printAnyway')}
- cancelText={t('common.cancel')}
- variant="warning"
- onConfirm={() => {
- setFilamentWarningItems(null);
- void handleSubmit(undefined, { skipFilamentCheck: true });
- }}
- onCancel={() => setFilamentWarningItems(null)}
- />
- )}
- </div>
- );
- }
- // Re-export types for convenience
- export type { PrintModalMode, PrintModalProps } from './types';
|