index.tsx 96 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064
  1. import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
  2. import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, ThumbsUp, X } from 'lucide-react';
  3. import { useEffect, useMemo, useRef, useState } from 'react';
  4. import { useTranslation } from 'react-i18next';
  5. import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
  6. import { api } from '../../api/client';
  7. import { useAuth } from '../../contexts/AuthContext';
  8. import { Card, CardContent } from '../Card';
  9. import { Button } from '../Button';
  10. import { ConfirmModal } from '../ConfirmModal';
  11. import { useToast } from '../../contexts/ToastContext';
  12. import {
  13. buildAmsMapping,
  14. buildFilamentComparison,
  15. buildLoadedFilaments,
  16. useFilamentMapping,
  17. } from '../../hooks/useFilamentMapping';
  18. import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
  19. import { getColorName } from '../../utils/colors';
  20. import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/printer';
  21. import { getCurrencySymbol } from '../../utils/currency';
  22. import { getBedTypeInfo } from '../../utils/bedType';
  23. import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
  24. import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
  25. import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
  26. import { FilamentMapping } from './FilamentMapping';
  27. import { FilamentOverride } from './FilamentOverride';
  28. import { PlateSelector } from './PlateSelector';
  29. import { PrinterSelector } from './PrinterSelector';
  30. import { PrintOptionsPanel } from './PrintOptions';
  31. import { ScheduleOptionsPanel } from './ScheduleOptions';
  32. import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
  33. import { CostCenterSelect } from './CostCenterSelect';
  34. import type {
  35. AssignmentMode,
  36. FilamentReqsData,
  37. PrintModalProps,
  38. PrintOptions,
  39. ScheduleOptions,
  40. ScheduleType,
  41. } from './types';
  42. import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
  43. import { NumberInput } from '../NumberInput';
  44. /** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */
  45. function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean {
  46. const hex = (c: string) => (c || '').replace('#', '').toLowerCase().slice(0, 6);
  47. return (a.type || '').toUpperCase() === (b.type || '').toUpperCase() && hex(a.color) === hex(b.color);
  48. }
  49. /**
  50. * The filament list as the tray matcher should see it: each overridden slot
  51. * asks for the override's type and colour, not the 3MF's (#3133). Mirrors the
  52. * scheduler's `_apply_filament_overrides` for a manual override — the 3MF's
  53. * `tray_info_idx` names the replaced spool's SKU, so it is dropped and matching
  54. * falls back to type + colour. An entry naming the slot's own filament is no
  55. * swap — a virtual printer's force-colour entries are exactly that — so the
  56. * slot keeps its idx and with it the PLA-variant pin (#2650). Slots with no
  57. * override pass through unchanged.
  58. */
  59. function withFilamentOverrides(
  60. reqs: FilamentReqsData | undefined,
  61. overrides: Record<number, { type: string; color: string }>,
  62. ): FilamentReqsData | undefined {
  63. const isSwap = (f: FilamentReqsData['filaments'][number]) => {
  64. const override = overrides[f.slot_id];
  65. return !!override && !isSameFilament(override, f);
  66. };
  67. if (!reqs?.filaments || !reqs.filaments.some(isSwap)) return reqs;
  68. return {
  69. ...reqs,
  70. filaments: reqs.filaments.map((f) =>
  71. isSwap(f) ? { ...f, type: overrides[f.slot_id].type, color: overrides[f.slot_id].color, tray_info_idx: '' } : f,
  72. ),
  73. };
  74. }
  75. /**
  76. * Unified PrintModal component that handles queue item creation and editing.
  77. * - 'create': Create a print queue item from an archive or library file
  78. * - 'edit-queue-item': Edit existing queue item
  79. *
  80. * Both archiveId and libraryFileId are supported. Library files are archived at
  81. * print start time by the scheduler, not when queued.
  82. */
  83. export function PrintModal({
  84. mode,
  85. archiveId,
  86. libraryFileId,
  87. archiveName,
  88. queueItem,
  89. initialSelectedPrinterIds,
  90. onClose,
  91. onSuccess,
  92. projectId,
  93. cleanupLibraryAfterDispatch,
  94. variantFiles,
  95. }: PrintModalProps) {
  96. const { t } = useTranslation();
  97. const queryClient = useQueryClient();
  98. const { showToast } = useToast();
  99. const { hasPermission, user } = useAuth();
  100. // Determine if we're printing a library file
  101. const isLibraryFile = !!libraryFileId && !archiveId;
  102. const isEditing = mode === 'edit-queue-item';
  103. // Cross-model alternatives (#671). One candidate is not a choice, so a
  104. // single-entry list behaves exactly like an ordinary print.
  105. const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
  106. // Editing an already-queued cross-model item. The candidates are shown so the
  107. // dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
  108. // did before, offering a printer picker whose Save would have left a row with
  109. // both variants and a printer_id. They are not editable here: changing the
  110. // set after queueing needs a variant-level API that doesn't exist, and the
  111. // backend refuses the printer/model change either way.
  112. const editingVariants: VariantCandidate[] =
  113. mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
  114. ? queueItem!.variants!.map((v) => ({
  115. id: v.library_file_id,
  116. filename: v.filename,
  117. sliced_for_model: v.target_model,
  118. }))
  119. : [];
  120. const hasEditingVariants = editingVariants.length > 0;
  121. const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
  122. const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
  123. type FilamentWarningItem = {
  124. printerName: string;
  125. slotLabel: string;
  126. requiredGrams: number;
  127. remainingGrams: number;
  128. /** True when AMS Filament Backup pooled more than one spool for this slot;
  129. * `requiredGrams` / `remainingGrams` are then the pooled totals. */
  130. pooled?: boolean;
  131. };
  132. // Multiple printer selection (used for all modes now)
  133. const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
  134. // Initialize with the queue item's printer if editing
  135. if (mode === 'edit-queue-item' && queueItem?.printer_id) {
  136. return [queueItem.printer_id];
  137. }
  138. if (initialSelectedPrinterIds?.length) {
  139. return initialSelectedPrinterIds;
  140. }
  141. return [];
  142. });
  143. // Multi-select plates: create mode users can pick a subset of plates
  144. const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {
  145. if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
  146. return new Set([queueItem.plate_id]);
  147. }
  148. return new Set();
  149. });
  150. // Derived single-plate value for filament queries and single-select contexts
  151. const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
  152. // Quantity — number of copies (creates a batch if > 1)
  153. const [quantity, setQuantity] = useState(1);
  154. // Per-plate quantities for multi-plate files (#342). Keyed by plate index;
  155. // a plate with no entry means one run. Only used in create mode on a
  156. // multi-plate file, where it replaces the single global Quantity field.
  157. const [plateQuantities, setPlateQuantities] = useState<Record<number, number>>({});
  158. const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
  159. if (mode === 'edit-queue-item' && queueItem) {
  160. return {
  161. bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
  162. flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
  163. vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
  164. layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
  165. timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
  166. nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
  167. confirm_outcome: queueItem.confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
  168. preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
  169. preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
  170. };
  171. }
  172. return DEFAULT_PRINT_OPTIONS;
  173. });
  174. const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
  175. if (mode === 'edit-queue-item' && queueItem) {
  176. let scheduleType: ScheduleType = 'queue';
  177. if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  178. scheduleType = 'scheduled';
  179. }
  180. let scheduledTime = '';
  181. if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  182. const date = parseUTCDate(queueItem.scheduled_time) ?? new Date();
  183. // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
  184. scheduledTime = toDateTimeLocalValue(date);
  185. }
  186. return {
  187. scheduleType,
  188. scheduledTime,
  189. requireManualStart: queueItem.manual_start,
  190. requirePreviousSuccess: queueItem.require_previous_success,
  191. autoOffAfter: queueItem.auto_off_after,
  192. gcodeInjection: queueItem.gcode_injection ?? false,
  193. staggerEnabled: false,
  194. staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,
  195. staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
  196. };
  197. }
  198. return DEFAULT_SCHEDULE_OPTIONS;
  199. });
  200. // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
  201. const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
  202. if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
  203. const mappings: Record<number, number> = {};
  204. queueItem.ams_mapping.forEach((globalTrayId, idx) => {
  205. if (globalTrayId !== -1) {
  206. mappings[idx + 1] = globalTrayId;
  207. }
  208. });
  209. return mappings;
  210. }
  211. return {};
  212. });
  213. // Per-printer override configs (for multi-printer selection)
  214. const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
  215. // Assignment mode: 'printer' (specific) or 'model' (any of model)
  216. const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
  217. // Cross-model alternatives are model-based by definition — naming one
  218. // printer would defeat the point of offering the other file.
  219. if (isCrossModel) {
  220. return 'model';
  221. }
  222. // Initialize from queue item if editing with target_model
  223. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  224. return 'model';
  225. }
  226. return 'printer';
  227. });
  228. // Target model for model-based assignment
  229. const [targetModel, setTargetModel] = useState<string | null>(() => {
  230. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  231. return queueItem.target_model;
  232. }
  233. return null;
  234. });
  235. // Target location for model-based assignment (optional filter)
  236. const [targetLocation, setTargetLocation] = useState<string | null>(() => {
  237. if (mode === 'edit-queue-item' && queueItem?.target_location) {
  238. return queueItem.target_location;
  239. }
  240. return null;
  241. });
  242. const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
  243. mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null
  244. );
  245. const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
  246. const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
  247. // Filament overrides for model-based assignment: slot_id -> {type, color}
  248. const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
  249. if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
  250. const overrides: Record<number, { type: string; color: string }> = {};
  251. for (const o of queueItem.filament_overrides) {
  252. overrides[o.slot_id] = { type: o.type, color: o.color };
  253. }
  254. return overrides;
  255. }
  256. return {};
  257. });
  258. // Per-slot force color match flags. Default is false (opt-in).
  259. const [forceColorMatch, setForceColorMatch] = useState<Record<number, boolean>>(() => {
  260. if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
  261. const flags: Record<number, boolean> = {};
  262. for (const o of queueItem.filament_overrides) {
  263. flags[o.slot_id] = o.force_color_match === true;
  264. }
  265. return flags;
  266. }
  267. return {};
  268. });
  269. // Track initial values for clearing mappings on change (edit mode only)
  270. const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
  271. const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
  272. // Submission state for multi-printer
  273. const [isSubmitting, setIsSubmitting] = useState(false);
  274. const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
  275. const [filamentWarningItems, setFilamentWarningItems] = useState<FilamentWarningItem[] | null>(null);
  276. // Track which printers have had the "Expand custom mapping by default" setting applied
  277. // This ensures the setting only affects initial state, not preventing unchecking
  278. const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
  279. // Printer counts and effective printer for filament mapping
  280. const effectivePrinterCount = selectedPrinters.length;
  281. // For filament mapping, use first selected printer (mapping applies to all)
  282. const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
  283. // Queries
  284. const { data: settings } = useQuery({
  285. queryKey: ['settings'],
  286. queryFn: api.getSettings,
  287. });
  288. // Sync print option defaults from settings once available
  289. const printDefaultsApplied = useRef(false);
  290. useEffect(() => {
  291. if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return;
  292. printDefaultsApplied.current = true;
  293. setPrintOptions({
  294. bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
  295. flow_cali: settings.default_flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
  296. vibration_cali: settings.default_vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
  297. layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
  298. timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
  299. nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
  300. confirm_outcome: settings.default_confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
  301. preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
  302. preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
  303. });
  304. }, [settings, mode]);
  305. // Sync stagger defaults from settings once available
  306. const staggerDefaultsApplied = useRef(false);
  307. useEffect(() => {
  308. if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
  309. staggerDefaultsApplied.current = true;
  310. setScheduleOptions((prev) => ({
  311. ...prev,
  312. staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
  313. staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
  314. }));
  315. }, [settings, mode]);
  316. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  317. const defaultCostPerKg = settings?.default_filament_cost ?? 0;
  318. const billingEnabled = settings?.billing_enabled === true;
  319. const { data: printers, isLoading: loadingPrinters } = useQuery({
  320. queryKey: ['printers'],
  321. queryFn: api.getPrinters,
  322. });
  323. const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({
  324. queryKey: ['finance', 'cost-centers', 'mine'],
  325. queryFn: api.getMyCostCenters,
  326. enabled: !!user && billingEnabled,
  327. });
  328. const printableCostCenters = useMemo(
  329. () => (myCostCenters || []).filter((center: CostCenterSummary) => center.can_print && center.is_active),
  330. [myCostCenters],
  331. );
  332. const selectedCostCenter = useMemo(
  333. () => printableCostCenters.find((center) => center.id === selectedCostCenterId) ?? null,
  334. [printableCostCenters, selectedCostCenterId],
  335. );
  336. useEffect(() => {
  337. if (printableCostCenters.length === 0) return;
  338. if (selectedCostCenterId != null && printableCostCenters.some((center) => center.id === selectedCostCenterId)) {
  339. return;
  340. }
  341. const preferredPrivate = printableCostCenters.find((center) => center.is_private);
  342. setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
  343. }, [printableCostCenters, selectedCostCenterId]);
  344. // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
  345. // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
  346. // so internal and Spoolman modes both work uniformly, VT/external slots are
  347. // excluded, and negative grams are clamped — single source of truth between
  348. // the client-side preview and dispatch-time picks.
  349. const inventoryRemainQueries = useQueries({
  350. queries: selectedPrinters.map((printerId) => ({
  351. queryKey: ['printer-inventory-remain', printerId],
  352. queryFn: () => api.getInventoryRemain(printerId),
  353. staleTime: 30 * 1000,
  354. // Same key, same reason as FilamentMapping's copy — see the note there.
  355. // Concurrent mounts dedupe, so opening the dialog costs one fetch per
  356. // printer however many plate panels are on screen.
  357. refetchOnMount: 'always',
  358. enabled: selectedPrinters.length > 0,
  359. })),
  360. });
  361. const inventoryByTrayIdPerPrinter = useMemo(() => {
  362. const result = new Map<number, Map<number, number>>();
  363. selectedPrinters.forEach((printerId, idx) => {
  364. const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
  365. if (!data) return;
  366. const printerMap = new Map<number, number>();
  367. Object.entries(data).forEach(([key, grams]) => {
  368. const gtid = Number(key);
  369. if (!Number.isNaN(gtid)) printerMap.set(gtid, grams);
  370. });
  371. result.set(printerId, printerMap);
  372. });
  373. return result;
  374. }, [selectedPrinters, inventoryRemainQueries]);
  375. // Same endpoint, the other half of its payload: every inventory-bound slot on
  376. // the printer with the backend's material identity and extruder side. The
  377. // pre-flight filament check groups on these instead of resolving spools
  378. // itself, which is what makes it agree with the dispatcher and work in
  379. // Spoolman mode (where the modal has no assignment rows of its own).
  380. const slotMaterialsPerPrinter = useMemo(() => {
  381. const result = new Map<number, Map<number, SlotMaterial>>();
  382. selectedPrinters.forEach((printerId, idx) => {
  383. const slots = inventoryRemainQueries[idx]?.data?.slot_materials;
  384. if (!slots) return;
  385. const printerMap = new Map<number, SlotMaterial>();
  386. slots.forEach((slot) => printerMap.set(slot.global_tray_id, slot));
  387. result.set(printerId, printerMap);
  388. });
  389. return result;
  390. }, [selectedPrinters, inventoryRemainQueries]);
  391. // Fetch archive details to get sliced_for_model
  392. const { data: archiveDetails } = useQuery({
  393. queryKey: ['archive', archiveId],
  394. queryFn: () => api.getArchive(archiveId!),
  395. enabled: !!archiveId && !isLibraryFile,
  396. });
  397. // Fetch library file details to get sliced_for_model
  398. const { data: libraryFileDetails } = useQuery({
  399. queryKey: ['library-file', libraryFileId],
  400. queryFn: () => api.getLibraryFile(libraryFileId!),
  401. enabled: isLibraryFile && !!libraryFileId,
  402. });
  403. // Get sliced_for_model from archive or library file
  404. const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
  405. // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
  406. // mapping" virtual-printer setting) — undefined for library files or
  407. // archives that predate the feature / had it off at print time, and
  408. // deliberately undefined unless the selected printer is the one the mapping
  409. // was resolved against. See `resolveArchiveSlicerAmsMapping`.
  410. const archiveSlicerAmsMapping = useMemo(
  411. () =>
  412. isLibraryFile
  413. ? undefined
  414. : resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
  415. [isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
  416. );
  417. // Fetch plates for archives
  418. const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
  419. queryKey: ['archive-plates', archiveId],
  420. queryFn: () => api.getArchivePlates(archiveId!),
  421. enabled: !!archiveId && !isLibraryFile,
  422. retry: false,
  423. });
  424. // Fetch plates for library files
  425. const { data: libraryPlatesData } = useQuery({
  426. queryKey: ['library-file-plates', libraryFileId],
  427. queryFn: () => api.getLibraryFilePlates(libraryFileId!),
  428. enabled: isLibraryFile && !!libraryFileId,
  429. });
  430. // Combine plates data from either source
  431. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  432. // Fetch filament requirements for archives
  433. const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
  434. queryKey: ['archive-filaments', archiveId, selectedPlate],
  435. queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
  436. enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
  437. retry: false,
  438. });
  439. // Fetch filament requirements for library files (with plate support)
  440. const { data: libraryFilamentReqs } = useQuery({
  441. queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
  442. queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
  443. enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
  444. });
  445. // Track if archive data couldn't be loaded (archive deleted or file missing)
  446. const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
  447. // Combine filament requirements from either source
  448. const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
  449. // What the tray matching works from. An override chosen in model mode stays
  450. // in force when the job is moved to a specific printer (#3133), so that
  451. // printer's trays are matched against the requested filament rather than the
  452. // one the 3MF was sliced with. The override panel keeps the original list —
  453. // it shows "sliced brown, print Bone White".
  454. const mappingFilamentReqs = useMemo(
  455. () => withFilamentOverrides(effectiveFilamentReqs, filamentOverrides),
  456. [effectiveFilamentReqs, filamentOverrides],
  457. );
  458. // Fetch available filaments for model-based assignment (for filament override UI)
  459. const { data: availableFilaments } = useQuery({
  460. queryKey: ['available-filaments', targetModel, targetLocation],
  461. queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
  462. enabled: assignmentMode === 'model' && !!targetModel,
  463. });
  464. // A cross-model job (#671) has no single target model, so the query above is
  465. // disabled and the override UI would silently vanish — leaving less control
  466. // than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
  467. // and offer the union: the job can land on any of them, so anything loaded on
  468. // any of them is a legitimate choice. Picking one only some models have is
  469. // allowed and meaningful — it narrows which candidates can match.
  470. const candidateModels = useMemo(
  471. () => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
  472. [candidates],
  473. );
  474. const candidateFilamentQueries = useQueries({
  475. queries: isCrossModel
  476. ? candidateModels.map((model) => ({
  477. queryKey: ['available-filaments', model, targetLocation],
  478. queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
  479. }))
  480. : [],
  481. });
  482. const crossModelFilaments = useMemo(() => {
  483. const seen = new Set<string>();
  484. const merged: NonNullable<typeof availableFilaments> = [];
  485. for (const query of candidateFilamentQueries) {
  486. for (const filament of query.data ?? []) {
  487. // Same type+colour loaded on two models is one choice, not two.
  488. const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
  489. if (!seen.has(key)) {
  490. seen.add(key);
  491. merged.push(filament);
  492. }
  493. }
  494. }
  495. return merged;
  496. }, [candidateFilamentQueries]);
  497. const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
  498. // Only fetch printer status when single printer selected (for filament mapping)
  499. const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
  500. queryKey: ['printer-status', effectivePrinterId],
  501. queryFn: () => api.getPrinterStatus(effectivePrinterId!),
  502. enabled: !!effectivePrinterId,
  503. });
  504. // Single-printer flow: gate prefer_lowest on this printer's backup state.
  505. // Multi-printer flow gates per-printer inside the hook (different printers
  506. // may have different backup states), so we pass the raw setting down.
  507. const singlePrinterPreferLowest = effectivePreferLowest(
  508. settings?.prefer_lowest_filament,
  509. printerStatus?.ams_filament_backup,
  510. );
  511. const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
  512. if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
  513. if (selectedPrinters.length === 0) return false;
  514. try {
  515. const statuses = await Promise.all(
  516. selectedPrinters.map((printerId) =>
  517. queryClient.fetchQuery({
  518. queryKey: ['printer-status', printerId],
  519. queryFn: () => api.getPrinterStatus(printerId),
  520. staleTime: 0,
  521. }),
  522. ),
  523. );
  524. return statuses.some((status) => !isPrinterCurrentlyDispatchable(status));
  525. } catch {
  526. return true;
  527. }
  528. };
  529. // Get AMS mapping from hook (only when single printer selected)
  530. const { amsMapping } = useFilamentMapping(
  531. mappingFilamentReqs,
  532. printerStatus,
  533. manualMappings,
  534. singlePrinterPreferLowest,
  535. effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
  536. );
  537. // --- Per-plate filament mapping (multi-plate submissions) ---------------
  538. // Each plate prints its own subset of the file's slots and needs its own AMS
  539. // mapping. `effectiveFilamentReqs` above is keyed on `selectedPlate`, which is
  540. // null the moment two plates are picked, so it holds the union of every plate's
  541. // filaments — matching against that union lets two plates that share a colour
  542. // on different slots compete for the same tray, and sends the loser to a worse
  543. // tray or to none. So when several plates are selected we fetch each plate's
  544. // requirements and map them separately (#2551 follow-up).
  545. const selectedPlateIds = useMemo(() => [...selectedPlates].sort((a, b) => a - b), [selectedPlates]);
  546. const isMultiPlateSelection = selectedPlates.size > 1;
  547. const perPlateReqQueries = useQueries({
  548. queries: (isMultiPlateSelection ? selectedPlateIds : []).map((plateId) => ({
  549. queryKey: isLibraryFile
  550. ? ['library-file-filaments', libraryFileId, plateId]
  551. : ['archive-filaments', archiveId, plateId],
  552. queryFn: () =>
  553. isLibraryFile
  554. ? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId)
  555. : api.getArchiveFilamentRequirements(archiveId!, plateId),
  556. enabled: isLibraryFile ? !!libraryFileId : !!archiveId,
  557. // Same policy as the single-plate query above: these keys are shared, and a
  558. // retrying observer would leave the plate looking merely slow for seconds.
  559. retry: false,
  560. })),
  561. });
  562. // A plate that has not answered yet and a plate whose 3MF cannot be read look
  563. // identical from here — both are simply absent from `perPlateReqs`. Neither may
  564. // be treated as "this plate needs no filament": that would queue it with no
  565. // mapping and no force-colour overrides, and it would print in whatever happens
  566. // to be loaded. Both states gate submission instead (see `canSubmit`).
  567. // `isPending` is "no data yet", not "a request is in flight" — a background
  568. // refetch of a plate we already have must not disable the button under the user.
  569. const perPlateReqsPending = perPlateReqQueries.some((q) => q.isPending);
  570. const perPlateReqsFailed = perPlateReqQueries.some((q) => q.isError);
  571. const perPlateReqs = useMemo(() => {
  572. const byPlate = new Map<number, FilamentReqsData>();
  573. selectedPlateIds.forEach((plateId, i) => {
  574. const data = perPlateReqQueries[i]?.data;
  575. if (data) byPlate.set(plateId, data);
  576. });
  577. return byPlate;
  578. // Keyed on each query's last update stamp, not on the query objects (fresh every
  579. // render) and not on a spread of their data (a dep array whose *length* changes
  580. // with the plate count, which React treats as always-changed and warns about).
  581. // eslint-disable-next-line react-hooks/exhaustive-deps
  582. }, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]);
  583. // Per-plate twin of `mappingFilamentReqs`: slot ids are global to the file, so
  584. // one override applies to every plate that prints that slot (#3133).
  585. const mappingPerPlateReqs = useMemo(() => {
  586. const byPlate = new Map<number, FilamentReqsData>();
  587. for (const [plateId, reqs] of perPlateReqs) {
  588. byPlate.set(plateId, withFilamentOverrides(reqs, filamentOverrides) ?? reqs);
  589. }
  590. return byPlate;
  591. }, [perPlateReqs, filamentOverrides]);
  592. // Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2
  593. // are different prints and may want different trays.
  594. const [manualMappingsByPlate, setManualMappingsByPlate] = useState<Record<number, Record<number, number>>>({});
  595. // Rack position per filament group (#1784), and one set per plate for the
  596. // per-plate panels — each plate has its own groups.
  597. const [nozzleRackChoice, setNozzleRackChoice] = useState<Record<number, number>>(() => {
  598. // Re-opening an item shows the positions it was queued with, so editing
  599. // one filament does not silently drop the rest.
  600. if (mode === 'edit-queue-item' && queueItem?.nozzle_rack_choice) {
  601. const seeded: Record<number, number> = {};
  602. for (const [groupId, position] of Object.entries(queueItem.nozzle_rack_choice)) {
  603. const group = Number(groupId);
  604. if (Number.isInteger(group) && Number.isInteger(position)) seeded[group] = position;
  605. }
  606. return seeded;
  607. }
  608. return {};
  609. });
  610. const [nozzleRackChoiceByPlate, setNozzleRackChoiceByPlate] =
  611. useState<Record<number, Record<number, number>>>({});
  612. // Only ever computed for a single target printer: a tray id means nothing on a
  613. // different printer, so a fan-out across printers must not reuse these.
  614. const perPlateAmsMappings = useMemo(() => {
  615. const byPlate = new Map<number, number[] | undefined>();
  616. if (!isMultiPlateSelection || !effectivePrinterId || selectedPrinters.length !== 1) return byPlate;
  617. const loaded = buildLoadedFilaments(printerStatus);
  618. const ftsActive = printerStatus?.fila_switch?.installed === true;
  619. const inventoryByTrayId = inventoryByTrayIdPerPrinter.get(effectivePrinterId);
  620. for (const plateId of selectedPlateIds) {
  621. const reqs = mappingPerPlateReqs.get(plateId);
  622. if (!reqs) continue;
  623. const comparison = buildFilamentComparison(
  624. reqs,
  625. loaded,
  626. manualMappingsByPlate[plateId] ?? {},
  627. singlePrinterPreferLowest,
  628. inventoryByTrayId,
  629. ftsActive,
  630. );
  631. byPlate.set(plateId, buildAmsMapping(comparison));
  632. }
  633. return byPlate;
  634. }, [
  635. isMultiPlateSelection,
  636. effectivePrinterId,
  637. printerStatus,
  638. inventoryByTrayIdPerPrinter,
  639. selectedPlateIds,
  640. mappingPerPlateReqs,
  641. manualMappingsByPlate,
  642. singlePrinterPreferLowest,
  643. selectedPrinters.length,
  644. ]);
  645. // Multi-printer filament mapping (for per-printer configuration)
  646. const multiPrinterMapping = useMultiPrinterFilamentMapping(
  647. selectedPrinters,
  648. printers,
  649. mappingFilamentReqs,
  650. manualMappings,
  651. perPrinterConfigs,
  652. setPerPrinterConfigs,
  653. settings?.prefer_lowest_filament,
  654. inventoryByTrayIdPerPrinter,
  655. );
  656. // Auto-select first plate when plates load (single or multi-plate)
  657. useEffect(() => {
  658. if (platesData?.plates && platesData.plates.length >= 1 && selectedPlates.size === 0) {
  659. setSelectedPlates(new Set([platesData.plates[0].index]));
  660. }
  661. }, [platesData, selectedPlates.size]);
  662. // Cross-model: the candidate list owns plate choice, and `platesData` is the
  663. // primary file's. `selectedPlate` still keys the filament-requirements query,
  664. // so it has to follow that file's dropdown — otherwise the override panel
  665. // describes plate 1 while the job runs plate 3 (#3101). An untouched dropdown
  666. // renders its first plate, which is what the auto-select above already set.
  667. useEffect(() => {
  668. if (!isCrossModel || !libraryFileId) return;
  669. const chosen = candidatePlates[libraryFileId];
  670. if (chosen == null) return;
  671. setSelectedPlates((prev) => (prev.size === 1 && prev.has(chosen) ? prev : new Set([chosen])));
  672. }, [isCrossModel, libraryFileId, candidatePlates]);
  673. // Auto-select first printer when only one available
  674. useEffect(() => {
  675. // Skip auto-select for edit mode (already initialized from queueItem)
  676. if (mode === 'edit-queue-item') return;
  677. const activePrinters = printers?.filter(p => p.is_active) || [];
  678. if (activePrinters.length === 1 && selectedPrinters.length === 0) {
  679. setSelectedPrinters([activePrinters[0].id]);
  680. }
  681. }, [mode, printers, selectedPrinters.length]);
  682. // Clear manual mappings and per-printer configs when printer or plate changes.
  683. // The per-plate mappings go with them: a manual override holds a global tray id,
  684. // which names a different spool on a different printer.
  685. useEffect(() => {
  686. if (mode === 'edit-queue-item') {
  687. // For edit mode, clear mappings if printer selection or plate changed from initial
  688. const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
  689. if (printersChanged || selectedPlate !== initialPlateId) {
  690. setManualMappings({});
  691. setManualMappingsByPlate({});
  692. setPerPrinterConfigs({});
  693. setInitialExpandApplied(new Set());
  694. }
  695. } else {
  696. setManualMappings({});
  697. setManualMappingsByPlate({});
  698. setPerPrinterConfigs({});
  699. setInitialExpandApplied(new Set());
  700. }
  701. }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
  702. // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode).
  703. // `prevTargetModel` is the last model actually targeted, so it skips nulls:
  704. // "Any P2S" -> "Specific Printer" empties targetModel without naming another
  705. // model, and the override is the job's filament, not a tray on some printer —
  706. // it survives the switch and is matched against the chosen printer (#3133).
  707. // Going P2S -> (none) -> X1C still compares P2S with X1C and clears.
  708. const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
  709. const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
  710. useEffect(() => {
  711. const modelChanged = targetModel !== null && targetModel !== prevTargetModel;
  712. const plateChanged = selectedPlate !== prevPlateForOverrides;
  713. if (!modelChanged && !plateChanged) return;
  714. if (modelChanged) setPrevTargetModel(targetModel);
  715. if (plateChanged) setPrevPlateForOverrides(selectedPlate);
  716. // A first model after none is a choice, not a change: nothing was picked
  717. // against another model's filaments. That also covers the initial render in
  718. // edit mode, where the values are initialized from queueItem.
  719. if (modelChanged && !plateChanged && prevTargetModel === null) return;
  720. if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
  721. setFilamentOverrides({});
  722. setForceColorMatch({});
  723. }
  724. }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
  725. // The sliced-for metadata loads async. If the user switched to model mode
  726. // before it arrived, the target is still empty (we never silently default
  727. // to another model, #2578) — fill it with the sliced-for model once known,
  728. // provided an active printer of that model exists.
  729. useEffect(() => {
  730. if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
  731. if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
  732. setTargetModel(slicedForModel);
  733. }
  734. }, [assignmentMode, targetModel, slicedForModel, printers]);
  735. // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
  736. // Only applies once per printer on initial selection, not when user unchecks
  737. useEffect(() => {
  738. if (!settings?.per_printer_mapping_expanded) return;
  739. if (selectedPrinters.length <= 1) return;
  740. // Only auto-configure printers that:
  741. // 1. Haven't had initial expand applied yet
  742. // 2. Have their status loaded (so auto-configure will actually work)
  743. const printersReadyForExpand = selectedPrinters.filter(printerId => {
  744. if (initialExpandApplied.has(printerId)) return false;
  745. // Check if this printer has status loaded
  746. const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);
  747. return result && result.status && !result.isLoading;
  748. });
  749. if (printersReadyForExpand.length > 0) {
  750. // Mark these printers as having been initially expanded
  751. setInitialExpandApplied(prev => {
  752. const next = new Set(prev);
  753. printersReadyForExpand.forEach(id => next.add(id));
  754. return next;
  755. });
  756. // Auto-configure printers
  757. printersReadyForExpand.forEach(printerId => {
  758. multiPrinterMapping.autoConfigurePrinter(printerId);
  759. });
  760. }
  761. }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);
  762. // Close on Escape key
  763. useEffect(() => {
  764. const handleKeyDown = (e: KeyboardEvent) => {
  765. if (e.key === 'Escape' && !isSubmitting) onClose();
  766. };
  767. window.addEventListener('keydown', handleKeyDown);
  768. return () => window.removeEventListener('keydown', handleKeyDown);
  769. }, [onClose, isSubmitting]);
  770. const isMultiPlate = platesData?.is_multi_plate ?? false;
  771. const plates = platesData?.plates ?? [];
  772. const filamentWarningMessage = useMemo(() => {
  773. if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
  774. const lines = filamentWarningItems.map((item) =>
  775. // Under AMS Filament Backup the shortfall is against the pooled spools,
  776. // not the one slot — quoting that slot's remaining next to a pooled
  777. // requirement reads as a contradiction ("needs 1441g, remaining 1000g"
  778. // while a second full spool sits next to it).
  779. item.pooled
  780. ? t('printModal.insufficientFilamentLinePooled', {
  781. printer: item.printerName,
  782. slot: item.slotLabel,
  783. required: Math.round(item.requiredGrams),
  784. remaining: Math.round(item.remainingGrams),
  785. })
  786. : t('printModal.insufficientFilamentLine', {
  787. printer: item.printerName,
  788. slot: item.slotLabel,
  789. required: Math.round(item.requiredGrams),
  790. remaining: Math.round(item.remainingGrams),
  791. })
  792. );
  793. return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
  794. }, [filamentWarningItems, t]);
  795. // Add to queue mutation (single printer)
  796. const addToQueueMutation = useMutation({
  797. mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
  798. });
  799. // Update queue item mutation
  800. const updateQueueMutation = useMutation({
  801. mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
  802. onSuccess: () => {
  803. queryClient.invalidateQueries({ queryKey: ['queue'] });
  804. showToast('Queue item updated');
  805. onSuccess?.();
  806. onClose();
  807. },
  808. onError: (error: Error) => {
  809. showToast(error.message || 'Failed to update queue item', 'error');
  810. },
  811. });
  812. // Get mapping for a specific printer (per-printer override or default).
  813. // A multi-plate submission maps each plate on its own — `amsMapping` and the
  814. // per-printer mappings are both derived from the union of every selected
  815. // plate's filaments, which is not this plate's print (#2551 follow-up).
  816. // Without a per-plate mapping we send none at all and let the scheduler
  817. // compute one at dispatch, which it already does per plate; a union mapping
  818. // would be used verbatim and could feed a slot from the wrong tray.
  819. const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => {
  820. if (isMultiPlateSelection) {
  821. // Fanning several plates across several printers would be a mapping per
  822. // plate *per printer*; those items go out without one and the scheduler
  823. // maps each plate against the printer it actually picks.
  824. if (plateId === null || selectedPrinters.length !== 1) return undefined;
  825. return perPlateAmsMappings.get(plateId);
  826. }
  827. // For multi-printer selection, check if this printer has an override
  828. if (selectedPrinters.length > 1) {
  829. const printerConfig = perPrinterConfigs[printerId];
  830. if (printerConfig && !printerConfig.useDefault) {
  831. return multiPrinterMapping.getFinalMapping(printerId);
  832. }
  833. }
  834. return amsMapping;
  835. };
  836. const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
  837. e?.preventDefault();
  838. if (billingEnabled && selectedCostCenter == null) {
  839. showToast(t('printModal.noPrintableCostCenters'), 'error');
  840. return;
  841. }
  842. if (
  843. !options?.skipFilamentCheck &&
  844. !settings?.disable_filament_warnings &&
  845. !isEditing &&
  846. assignmentMode === 'printer'
  847. ) {
  848. const warningItems: FilamentWarningItem[] = [];
  849. // The spool check follows what is actually dispatched: one job per selected
  850. // plate, each with the mapping that plate's queue item carries. Two plates
  851. // can also draw on the same spool, so the demand is summed per tray before
  852. // it is weighed against what is left on it — 60 g left does not cover two
  853. // plates of 40 g, even though it covers either one of them (#2551).
  854. const plateJobs = isMultiPlateSelection
  855. ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
  856. : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
  857. if (plateJobs.some((job) => job.reqs.length > 0) && slotMaterialsPerPrinter.size > 0) {
  858. for (const printerId of selectedPrinters) {
  859. const printerStatusForWarning = selectedPrinters.length > 1
  860. ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
  861. : printerStatus;
  862. const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
  863. const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
  864. // Slots the backend could price. A slot missing here is one with no
  865. // inventory binding, or one whose Spoolman spool it could not read —
  866. // both mean "nothing to weigh", never "empty".
  867. const slotMaterials = slotMaterialsPerPrinter.get(printerId);
  868. const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
  869. if (!slotMaterials || slotMaterials.size === 0) continue;
  870. const gramsByTray = new Map<number, number>();
  871. for (const job of plateJobs) {
  872. // No mapping means the scheduler picks the trays at dispatch, against
  873. // an AMS state we cannot see from here — nothing to weigh.
  874. const printerMapping = getMappingForPrinter(printerId, job.plateId);
  875. if (!printerMapping) continue;
  876. job.reqs.forEach((req) => {
  877. if (!req.slot_id || req.slot_id <= 0) return;
  878. const globalTrayId = printerMapping[req.slot_id - 1];
  879. if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
  880. gramsByTray.set(globalTrayId, (gramsByTray.get(globalTrayId) ?? 0) + req.used_grams);
  881. });
  882. }
  883. // With AMS Filament Backup ON the firmware switches to any other slot
  884. // holding the same material, so the print is only short when the whole
  885. // pool is (#1762). The dispatcher has accounted for this since #1762 —
  886. // this check did not, and blocked prints the dispatcher would have run.
  887. // Dual-extruder printers pool per side: the firmware cannot cross
  888. // nozzles even with the backup bit set, which is why `extruder` is part
  889. // of the key the backend hands us.
  890. const backupOn = printerStatusForWarning?.ams_filament_backup === true;
  891. const poolKey = (slot: SlotMaterial) => `${slot.material_key}#${slot.extruder}`;
  892. const pooledGrams = new Map<string, number>();
  893. const pooledSlotCount = new Map<string, number>();
  894. const pooledRequired = new Map<string, number>();
  895. if (backupOn) {
  896. slotMaterials.forEach((slot) => {
  897. const key = poolKey(slot);
  898. pooledGrams.set(key, (pooledGrams.get(key) ?? 0) + slot.remaining_g);
  899. pooledSlotCount.set(key, (pooledSlotCount.get(key) ?? 0) + 1);
  900. });
  901. for (const [globalTrayId, requiredGrams] of gramsByTray) {
  902. const slot = slotMaterials.get(globalTrayId);
  903. if (!slot) continue;
  904. const key = poolKey(slot);
  905. pooledRequired.set(key, (pooledRequired.get(key) ?? 0) + requiredGrams);
  906. }
  907. }
  908. for (const [globalTrayId, requiredGrams] of gramsByTray) {
  909. const slot = slotMaterials.get(globalTrayId);
  910. if (!slot) continue;
  911. const slotLabel = slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`;
  912. if (backupOn) {
  913. const key = poolKey(slot);
  914. const available = pooledGrams.get(key) ?? 0;
  915. const needed = pooledRequired.get(key) ?? 0;
  916. if (available >= needed) continue;
  917. warningItems.push({
  918. printerName,
  919. slotLabel,
  920. requiredGrams: needed,
  921. remainingGrams: available,
  922. // A pool of one is just the slot itself — same numbers, so use
  923. // the plain wording rather than talk about spools that aren't there.
  924. pooled: (pooledSlotCount.get(key) ?? 1) > 1,
  925. });
  926. continue;
  927. }
  928. if (slot.remaining_g >= requiredGrams) continue;
  929. warningItems.push({
  930. printerName,
  931. slotLabel,
  932. requiredGrams,
  933. remainingGrams: slot.remaining_g,
  934. });
  935. }
  936. }
  937. }
  938. if (warningItems.length > 0) {
  939. setFilamentWarningItems(warningItems);
  940. return;
  941. }
  942. }
  943. // Validate printer/model selection
  944. if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
  945. showToast('Please select at least one printer', 'error');
  946. return;
  947. }
  948. // A cross-model job has no single target model — each candidate carries its
  949. // own, and the backend gates each of them separately. Both checks below are
  950. // about the one-model case only.
  951. if (!isCrossModel && assignmentMode === 'model' && !targetModel) {
  952. showToast('Please select a target printer model', 'error');
  953. return;
  954. }
  955. // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
  956. // gets inline feedback instead of a failed request.
  957. if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
  958. showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
  959. return;
  960. }
  961. setIsSubmitting(true);
  962. // Calculate total API calls: plates × printers (or 1 for model-based)
  963. const platesToQueue = selectedPlates.size > 1
  964. ? plates.filter(p => selectedPlates.has(p.index))
  965. : [null];
  966. const totalCount = assignmentMode === 'model'
  967. ? platesToQueue.length
  968. : selectedPrinters.length * platesToQueue.length;
  969. setSubmitProgress({ current: 0, total: totalCount });
  970. const results: { success: number; failed: number; errors: string[] } = {
  971. success: 0,
  972. failed: 0,
  973. errors: [],
  974. };
  975. // Convert filament overrides from Record to array format for API.
  976. // Include all slots that either have a user override or have force_color_match enabled
  977. // (which is the default for model-based assignment).
  978. // The dialog keeps an override as type + colour only, so an entry that comes
  979. // back unchanged gets the variant id the item already carried re-attached. A
  980. // virtual printer writes force-colour entries with the 3MF's tray_info_idx to
  981. // tell Basic, Matte and Silk PLA apart (#2650); saving the item — which a
  982. // specific-printer edit now does for the overrides too (#3133) — must not
  983. // quietly drop that pin. A changed entry is a swap and has no idx to keep.
  984. const storedOverrideBySlot = new Map(
  985. (mode === 'edit-queue-item' ? queueItem?.filament_overrides ?? [] : []).map((o) => [o.slot_id, o]),
  986. );
  987. const storedVariantFor = (slotId: number, type: string, color: string) => {
  988. const stored = storedOverrideBySlot.get(slotId);
  989. return stored?.tray_info_idx && isSameFilament(stored, { type, color }) ? { tray_info_idx: stored.tray_info_idx } : {};
  990. };
  991. const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
  992. const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; tray_info_idx?: string; force_color_match: boolean }> = [];
  993. // Process all slots from filament requirements (to capture force_color_match defaults)
  994. if (reqs?.filaments) {
  995. for (const req of reqs.filaments) {
  996. const userOverride = filamentOverrides[req.slot_id];
  997. const isForceColor = forceColorMatch[req.slot_id] ?? false;
  998. const effectiveType = userOverride?.type ?? req.type;
  999. const effectiveColor = userOverride?.color ?? req.color;
  1000. // Include slot if user changed the filament OR force_color_match is enabled
  1001. if (userOverride || isForceColor) {
  1002. 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 });
  1003. }
  1004. }
  1005. } else {
  1006. // Fallback: no filament requirements data — only include explicit user overrides
  1007. for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
  1008. const id = parseInt(slotId, 10);
  1009. const isForceColor = forceColorMatch[id] ?? false;
  1010. entries.push({ slot_id: id, type, color, color_name: getColorName(color), ...storedVariantFor(id, type, color), force_color_match: isForceColor });
  1011. }
  1012. }
  1013. return entries.length > 0 ? entries : undefined;
  1014. };
  1015. const filamentOverridesArray = buildFilamentOverridesArray(effectiveFilamentReqs);
  1016. // A plate only carries the slots it prints (#2552). Slot ids are global to the
  1017. // file, so an override on slot 3 means the same filament in every plate that
  1018. // uses slot 3 — the per-plate list is a subset of the shared state, not a
  1019. // rewrite of it. No fallback to the whole-file list: it holds slots this plate
  1020. // never prints, and submission is gated on every selected plate having answered,
  1021. // so a plate is never missing here.
  1022. const overridesForPlate = (plateId: number | null) =>
  1023. isMultiPlateSelection && plateId !== null
  1024. ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
  1025. : filamentOverridesArray;
  1026. // A specific-printer job carries only the slots the user actually changed
  1027. // (#3133): the tray mapping was matched against them, and they are what the
  1028. // scheduler needs if it has to recompute that mapping at dispatch. The
  1029. // force-colour flags on their own stay behind — printer mode never sent
  1030. // them, and changing that is not what this is for.
  1031. const printerOverridesForPlate = (plateId: number | null) => {
  1032. const entries = overridesForPlate(plateId)?.filter((o) => filamentOverrides[o.slot_id]);
  1033. return entries && entries.length > 0 ? entries : undefined;
  1034. };
  1035. // Cross-model alternatives (#671): ONE item carrying a candidate per file,
  1036. // in the order the user arranged. This returns before the plate/printer
  1037. // fan-out below because it deliberately fans out to nothing — the whole
  1038. // point is that exactly one of these candidates ever runs.
  1039. //
  1040. // Filament overrides are shared rather than per-candidate, matching how
  1041. // single-model assignment already behaves: the printer is unknown at queue
  1042. // time, so what is expressed here is "this job needs PETG", which is true of
  1043. // every slice of the same job. The AMS mapping is likewise absent — the
  1044. // scheduler computes it against the printer it actually picks.
  1045. if (isCrossModel) {
  1046. try {
  1047. await api.addToQueue({
  1048. variants: candidates.map((c) => ({
  1049. library_file_id: c.id,
  1050. plate_id: candidatePlates[c.id] ?? null,
  1051. filament_overrides: filamentOverridesArray,
  1052. })),
  1053. target_location: targetLocation,
  1054. require_previous_success: scheduleOptions.requirePreviousSuccess,
  1055. auto_off_after: scheduleOptions.autoOffAfter,
  1056. gcode_injection: scheduleOptions.gcodeInjection,
  1057. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  1058. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  1059. ? new Date(scheduleOptions.scheduledTime).toISOString()
  1060. : undefined,
  1061. quantity,
  1062. ...printOptions,
  1063. project_id: projectId ?? undefined,
  1064. });
  1065. showToast(t('printModal.variants.queued', { count: candidates.length }), 'success');
  1066. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1067. onSuccess?.();
  1068. onClose();
  1069. } catch (error) {
  1070. showToast(error instanceof Error ? error.message : String(error), 'error');
  1071. } finally {
  1072. setIsSubmitting(false);
  1073. }
  1074. return;
  1075. }
  1076. // Batch order (#342): a create submission that produces more than one run
  1077. // from one source is pre-created as a batch carrying per-plate targets,
  1078. // and its id is passed to each subsequent addToQueue call. The targets are
  1079. // what make the order able to say a failed run is still owed — without
  1080. // them the batch only knows what it happened to queue. Only for
  1081. // single-target submissions; multi-printer fan-out keeps the old per-item
  1082. // shape, where "how many" is answered by the printer count.
  1083. const plateTargets = platesToQueue.map((plate, index) => {
  1084. const plateIndex = plate ? plate.index : selectedPlate;
  1085. return {
  1086. plate_id: plateIndex,
  1087. plate_name: plate ? (plate.name || null) : null,
  1088. quantity_target: quantityForPlate(plateIndex),
  1089. sort_order: index,
  1090. };
  1091. });
  1092. const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0);
  1093. const shouldAutoBatch =
  1094. mode === 'create'
  1095. && (platesToQueue.length > 1 || totalRuns > 1)
  1096. && (assignmentMode === 'model' || selectedPrinters.length === 1);
  1097. let autoBatchId: number | null = null;
  1098. if (shouldAutoBatch) {
  1099. try {
  1100. const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
  1101. const batchName = platesToQueue.length > 1
  1102. ? `${baseName || 'Batch'} · ${platesToQueue.length} plates`
  1103. : `${baseName || 'Batch'} ×${totalRuns}`;
  1104. const batch = await api.createBatch({
  1105. name: batchName,
  1106. archive_id: isLibraryFile ? undefined : archiveId,
  1107. library_file_id: isLibraryFile ? libraryFileId : undefined,
  1108. plates: plateTargets,
  1109. });
  1110. autoBatchId = batch.id;
  1111. } catch {
  1112. // Non-fatal: fall back to ungrouped items so the queue still works.
  1113. // The server still creates a plain batch when quantity > 1, so the
  1114. // queue grouping survives even when the order layer doesn't.
  1115. autoBatchId = null;
  1116. }
  1117. }
  1118. // ASAP items go to the top of the queue in the order this submit creates
  1119. // them. One counter for the whole submit: positions are a single sequence
  1120. // across every printer and model (#3200), so a per-printer counter would put
  1121. // each printer's first item at position 1 and shuffle them.
  1122. let asapInserted = 0;
  1123. const applyAsapInsertion = (queueData: PrintQueueItemCreate, itemCount = 1) => {
  1124. if (scheduleOptions.scheduleType !== 'asap') return;
  1125. queueData.insert_at_top = true;
  1126. queueData.insert_position = asapInserted + 1;
  1127. asapInserted += itemCount;
  1128. };
  1129. // Common queue data for create and edit modes
  1130. // One panel per plate when several are selected, one shared panel
  1131. // otherwise -- the same split the AMS mappings use above.
  1132. const rackChoiceForPlate = (plateId: number | null): Record<number, number> | undefined => {
  1133. const choice = plateId != null && isMultiPlateSelection
  1134. ? nozzleRackChoiceByPlate[plateId]
  1135. : nozzleRackChoice;
  1136. return choice && Object.keys(choice).length > 0 ? choice : undefined;
  1137. };
  1138. const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
  1139. const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
  1140. const plateEstimatedCost =
  1141. plateId != null && isMultiPlateSelection ? estimatedCostsByPlate[plateId] ?? null : estimatedCost;
  1142. return {
  1143. printer_id: assignmentMode === 'printer' ? printerId : null,
  1144. target_model: assignmentMode === 'model' ? targetModel : null,
  1145. target_location: assignmentMode === 'model' ? targetLocation : null,
  1146. filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : printerOverridesForPlate(plateId),
  1147. // Use library_file_id for library files, archive_id for archives
  1148. archive_id: isLibraryFile ? undefined : archiveId,
  1149. library_file_id: isLibraryFile ? libraryFileId : undefined,
  1150. require_previous_success: scheduleOptions.requirePreviousSuccess,
  1151. auto_off_after: scheduleOptions.autoOffAfter,
  1152. gcode_injection: scheduleOptions.gcodeInjection,
  1153. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  1154. // When the user clicks "Print Anyway" on the frontend deficit warning,
  1155. // persist that acknowledgement so the scheduler doesn't immediately
  1156. // re-flag the item on its first dispatch tick (#1698-followup).
  1157. skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
  1158. ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
  1159. // Rack positions per filament group (#1784). Only sent in printer mode:
  1160. // in model mode the target printer is not known yet, and the rack it
  1161. // will be dispatched to cannot be validated against here. The dispatcher
  1162. // assigns them itself in that case.
  1163. nozzle_rack_choice: printerId ? rackChoiceForPlate(plateId) : undefined,
  1164. plate_id: plateId,
  1165. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  1166. ? new Date(scheduleOptions.scheduledTime).toISOString()
  1167. : undefined,
  1168. ...printOptions,
  1169. project_id: projectId ?? undefined,
  1170. cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
  1171. estimated_cost: billingEnabled && selectedCostCenterId != null ? plateEstimatedCost : undefined,
  1172. batch_id: autoBatchId ?? undefined,
  1173. cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
  1174. };
  1175. };
  1176. // Model-based assignment
  1177. if (assignmentMode === 'model') {
  1178. let progressCounter = 0;
  1179. for (const plate of platesToQueue) {
  1180. progressCounter++;
  1181. setSubmitProgress({ current: progressCounter, total: totalCount });
  1182. const plateId = plate ? plate.index : selectedPlate;
  1183. try {
  1184. if (mode === 'edit-queue-item' && !plate) {
  1185. // Edit mode - update with target_model (only for single plate)
  1186. const updateData: PrintQueueItemUpdate = {
  1187. printer_id: null,
  1188. target_model: targetModel,
  1189. target_location: targetLocation,
  1190. filament_overrides: filamentOverridesArray || null,
  1191. require_previous_success: scheduleOptions.requirePreviousSuccess,
  1192. auto_off_after: scheduleOptions.autoOffAfter,
  1193. gcode_injection: scheduleOptions.gcodeInjection,
  1194. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  1195. ams_mapping: undefined,
  1196. plate_id: plateId,
  1197. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  1198. ? new Date(scheduleOptions.scheduledTime).toISOString()
  1199. : null,
  1200. ...printOptions,
  1201. cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
  1202. estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
  1203. };
  1204. await updateQueueMutation.mutateAsync(updateData);
  1205. } else {
  1206. // Add-to-queue mode with model-based assignment
  1207. const queueData = getQueueData(null, plateId);
  1208. const plateQuantity = quantityForPlate(plateId);
  1209. if (plateQuantity > 1) queueData.quantity = plateQuantity;
  1210. applyAsapInsertion(queueData, plateQuantity);
  1211. await addToQueueMutation.mutateAsync(queueData);
  1212. }
  1213. results.success++;
  1214. } catch (error) {
  1215. results.failed++;
  1216. const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
  1217. results.errors.push(plateName ? `${plateName}: ${(error as Error).message}` : (error as Error).message);
  1218. }
  1219. }
  1220. } else {
  1221. // Printer-based assignment: loop through plates × printers
  1222. // Compute stagger base time once before the loop
  1223. const useStagger = scheduleOptions.staggerEnabled
  1224. && !isEditing
  1225. && selectedPrinters.length > 1;
  1226. const staggerBaseTime = useStagger
  1227. ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  1228. ? new Date(scheduleOptions.scheduledTime).getTime()
  1229. : Date.now())
  1230. : 0;
  1231. let progressCounter = 0;
  1232. for (const plate of platesToQueue) {
  1233. const plateId = plate ? plate.index : selectedPlate;
  1234. for (let i = 0; i < selectedPrinters.length; i++) {
  1235. const printerId = selectedPrinters[i];
  1236. progressCounter++;
  1237. setSubmitProgress({ current: progressCounter, total: totalCount });
  1238. try {
  1239. if (isEditing && progressCounter === 1) {
  1240. // Edit mode - update the original queue item for the first entry
  1241. const printerMapping = getMappingForPrinter(printerId, plateId);
  1242. const updateData: PrintQueueItemUpdate = {
  1243. printer_id: printerId,
  1244. target_model: null,
  1245. target_location: null,
  1246. // null, not undefined: omitting the field left a model job's
  1247. // overrides on the row after it moved to a printer, whatever the
  1248. // user did with them here (#3133).
  1249. filament_overrides: printerOverridesForPlate(plateId) ?? null,
  1250. require_previous_success: scheduleOptions.requirePreviousSuccess,
  1251. auto_off_after: scheduleOptions.autoOffAfter,
  1252. gcode_injection: scheduleOptions.gcodeInjection,
  1253. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  1254. ams_mapping: printerMapping,
  1255. // null, not undefined: an operator who cleared their picks
  1256. // means "assign these again", and undefined would leave the
  1257. // stale ones on the row (#1784).
  1258. nozzle_rack_choice: rackChoiceForPlate(plateId) ?? null,
  1259. plate_id: plateId,
  1260. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  1261. ? new Date(scheduleOptions.scheduledTime).toISOString()
  1262. : null,
  1263. ...printOptions,
  1264. cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
  1265. estimated_cost: billingEnabled && selectedCostCenterId != null
  1266. ? (plateId != null && isMultiPlateSelection
  1267. ? estimatedCostsByPlate[plateId] ?? null
  1268. : estimatedCost)
  1269. : undefined,
  1270. };
  1271. await updateQueueMutation.mutateAsync(updateData);
  1272. } else {
  1273. // New print mode, staggered print, or edit mode with additional entries
  1274. const queueData = getQueueData(printerId, plateId);
  1275. const plateQuantity = quantityForPlate(plateId);
  1276. if (plateQuantity > 1) queueData.quantity = plateQuantity;
  1277. applyAsapInsertion(queueData, plateQuantity);
  1278. // Apply stagger offset for groups after the first
  1279. if (useStagger) {
  1280. const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
  1281. if (groupIndex > 0) {
  1282. const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
  1283. queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
  1284. }
  1285. // Group 0 with ASAP: no scheduled_time (start immediately)
  1286. // Group 0 with scheduled: keeps the scheduled_time from getQueueData
  1287. }
  1288. await addToQueueMutation.mutateAsync(queueData);
  1289. }
  1290. results.success++;
  1291. } catch (error) {
  1292. results.failed++;
  1293. const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
  1294. const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
  1295. const label = plateName ? `${printerName} (${plateName})` : printerName;
  1296. results.errors.push(`${label}: ${(error as Error).message}`);
  1297. }
  1298. }
  1299. }
  1300. }
  1301. setIsSubmitting(false);
  1302. // Show result toast
  1303. if (results.failed === 0) {
  1304. if (isEditing) {
  1305. if (mode === 'edit-queue-item') {
  1306. showToast('Queue item updated');
  1307. }
  1308. } else if (results.success === 1) {
  1309. const waitForIdleToast = await asapToastShouldPromiseLaterStart();
  1310. showToast(
  1311. waitForIdleToast
  1312. ? t('queue.printQueuedWillStartWhenIdle')
  1313. : assignmentMode === 'model'
  1314. ? `Queued for any ${targetModel}`
  1315. : t('queue.printQueued'),
  1316. );
  1317. } else {
  1318. const waitForIdleToast = await asapToastShouldPromiseLaterStart();
  1319. showToast(
  1320. waitForIdleToast
  1321. ? t('queue.printQueuedWillStartWhenIdle')
  1322. : t('queue.itemsQueued', { count: results.success }),
  1323. );
  1324. }
  1325. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1326. onSuccess?.();
  1327. onClose();
  1328. } else if (results.success === 0) {
  1329. showToast(`Failed: ${results.errors[0]}`, 'error');
  1330. } else {
  1331. showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
  1332. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1333. }
  1334. };
  1335. const isPending = isSubmitting || updateQueueMutation.isPending;
  1336. const canSubmit = useMemo(() => {
  1337. if (isPending) return false;
  1338. // Billing requires a server-authorized cost center. Wait for the query and
  1339. // keep submission disabled when the user has no printable center, rather
  1340. // than letting the API fail with an unexplained 400.
  1341. if (billingEnabled && (loadingCostCenters || selectedCostCenter == null)) return false;
  1342. // Need valid printer/model selection
  1343. if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
  1344. // Both are about the single-model case. A cross-model job has no one target
  1345. // model, and each candidate is gated against its own by the backend (#671).
  1346. if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
  1347. // Cross-model mismatch cannot be queued (#2578)
  1348. if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
  1349. // For multi-plate files, need at least one plate selected
  1350. if (isMultiPlate && selectedPlates.size === 0) return false;
  1351. // Every selected plate has to have answered before we can queue it: a plate
  1352. // still in flight would be sent with no mapping and no overrides, and one that
  1353. // failed to load cannot be mapped at all. Deselect the failing plate to queue
  1354. // the rest — the banner above says which state we are in.
  1355. if (perPlateReqsPending || perPlateReqsFailed) return false;
  1356. // A single-printer AMS job must wait for the printer's live status before it
  1357. // can resolve the filament mapping. Submitting mid-load matched against zero
  1358. // known trays and serialized an all-[-1] mapping, which dispatched the print
  1359. // to the empty external feed (#2589).
  1360. if (assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading) return false;
  1361. return true;
  1362. }, [
  1363. selectedPrinters.length,
  1364. assignmentMode,
  1365. targetModel,
  1366. slicedForModel,
  1367. isMultiPlate,
  1368. selectedPlates.size,
  1369. isPending,
  1370. perPlateReqsPending,
  1371. perPlateReqsFailed,
  1372. printerStatusLoading,
  1373. isCrossModel,
  1374. billingEnabled,
  1375. loadingCostCenters,
  1376. selectedCostCenter,
  1377. ]);
  1378. // Quantity only applies for single-printer or model-based assignment (not multi-printer)
  1379. const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
  1380. // On a multi-plate file the per-plate steppers own the quantity and the
  1381. // global field is hidden (#342) — the reporter's case is "plate 1 once,
  1382. // plate 2 twice", which one shared number cannot express. Single-plate
  1383. // files, and edit mode, keep the single field exactly as before.
  1384. // Cross-model is excluded: its plate choice is per candidate and lives in
  1385. // VariantCandidates, so there are no per-plate steppers to own the number
  1386. // and the global Quantity field below is the only one there is (#3101).
  1387. const usePerPlateQuantities =
  1388. mode === 'create' && !isCrossModel && isMultiPlate && plates.length > 1;
  1389. /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
  1390. const quantityForPlate = (plateIndex: number | null): number => {
  1391. if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity;
  1392. // Multi-printer fan-out already means one copy per printer; multiplying by
  1393. // a per-plate count on top would silently produce plates × printers × n.
  1394. if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1;
  1395. return Math.max(1, plateQuantities[plateIndex] ?? 1);
  1396. };
  1397. // Clear gcode_injection if the admin removes all snippets while the modal
  1398. // is open — the checkbox itself hides via hasGcodeSnippets in
  1399. // ScheduleOptions, but the boolean would otherwise stay true and ship to
  1400. // the API. The previous gate also reset the flag whenever effectiveQuantity
  1401. // dropped to <= 1, which silently un-ticked the checkbox on every single-
  1402. // print create flow (#1852). The scheduler reads item.gcode_injection per
  1403. // queue item regardless of batch size, so there's no underlying reason for
  1404. // the quantity-1 case to be blocked.
  1405. useEffect(() => {
  1406. if (mode === 'create' && scheduleOptions.gcodeInjection && !settings?.gcode_snippets) {
  1407. setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false }));
  1408. }
  1409. }, [mode, settings?.gcode_snippets, scheduleOptions.gcodeInjection]);
  1410. // Modal title and action button text based on mode
  1411. const getModalConfig = () => {
  1412. if (!isEditing) {
  1413. return {
  1414. title: t('common.print'),
  1415. icon: Printer,
  1416. submitText: t('common.print'),
  1417. submitIcon: Printer,
  1418. loadingText: submitProgress.total > 1
  1419. ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
  1420. : t('queue.adding'),
  1421. };
  1422. }
  1423. // edit-queue-item mode
  1424. return {
  1425. title: t('queue.editQueueItem'),
  1426. icon: Pencil,
  1427. submitText: t('common.save'),
  1428. submitIcon: Pencil,
  1429. loadingText: submitProgress.total > 1
  1430. ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
  1431. : t('common.saving'),
  1432. };
  1433. };
  1434. const modalConfig = getModalConfig();
  1435. const TitleIcon = modalConfig.icon;
  1436. const SubmitIcon = modalConfig.submitIcon;
  1437. // Show filament mapping when:
  1438. // - Single printer selected
  1439. // - For archives: plate is selected (for multi-plate) or not required (single-plate)
  1440. // - For library files: always show (no plate selection)
  1441. const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (
  1442. isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
  1443. );
  1444. useEffect(() => {
  1445. if (!showFilamentMapping || archiveDataMissing || selectedPrinters.length !== 1) {
  1446. setEstimatedCost(null);
  1447. }
  1448. }, [archiveDataMissing, selectedPrinters.length, showFilamentMapping]);
  1449. // Several plates on one printer: one mapping panel per plate, each mapping only
  1450. // the slots its own plate prints. Multi-printer fan-out would be a panel per
  1451. // plate *per printer*, so those items ship without a mapping and the scheduler
  1452. // computes one per plate when it picks the printer.
  1453. const showPerPlateFilamentMapping =
  1454. !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
  1455. // Model mode has no printer and so no trays to map onto; what it offers instead
  1456. // is the filament each slot must be printed in, which the scheduler matches
  1457. // against whatever printer of the model it picks. Needs the model's loaded
  1458. // filaments to offer as alternatives.
  1459. // Cross-model items have no targetModel by design — their candidates each
  1460. // carry their own — so gate on having somewhere to source choices from.
  1461. const showFilamentOverride =
  1462. assignmentMode === 'model'
  1463. && (isCrossModel || !!targetModel)
  1464. && !!effectiveAvailableFilaments
  1465. && effectiveAvailableFilaments.length > 0;
  1466. // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
  1467. // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
  1468. // the toggle without a specific printer selected. For printer-mode we rely
  1469. // on the canonical `nozzle_count` field auto-detected from MQTT.
  1470. const DUAL_NOZZLE_MODELS = useMemo(
  1471. () => new Set(['H2D', 'H2DPRO', 'H2C', 'X2D']),
  1472. [],
  1473. );
  1474. const showDualNozzleOptions = useMemo(() => {
  1475. if (assignmentMode === 'model') {
  1476. if (!targetModel) return false;
  1477. return DUAL_NOZZLE_MODELS.has(targetModel.toUpperCase().replace(/[\s-]/g, ''));
  1478. }
  1479. if (!printers || selectedPrinters.length === 0) return false;
  1480. return selectedPrinters.some(id => printers.find(p => p.id === id)?.nozzle_count === 2);
  1481. }, [assignmentMode, targetModel, printers, selectedPrinters, DUAL_NOZZLE_MODELS]);
  1482. return (
  1483. <div
  1484. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  1485. onClick={isSubmitting ? undefined : onClose}
  1486. >
  1487. {/* 4xl rather than the 2xl this was: the filament rows carry the most
  1488. horizontal content in the dialog — a required name, a nozzle picker on
  1489. rack machines, and an AMS slot dropdown naming type, colour and
  1490. remaining weight — and anything narrower truncated the name to
  1491. "Bamb..." (#1784). 4xl is 896px, so it still fits a 1024-wide laptop
  1492. with the surrounding padding, and `w-full` keeps it fluid below that. */}
  1493. <Card
  1494. className="w-full max-w-4xl max-h-[90vh] overflow-y-auto"
  1495. onClick={(e) => e.stopPropagation()}
  1496. >
  1497. <CardContent className="p-0">
  1498. {/* Header */}
  1499. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  1500. <div className="flex items-center gap-2">
  1501. <TitleIcon className="w-5 h-5 text-bambu-green" />
  1502. <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
  1503. </div>
  1504. <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
  1505. <X className="w-5 h-5" />
  1506. </Button>
  1507. </div>
  1508. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  1509. {/* Archive name */}
  1510. <p className="text-sm text-bambu-gray">
  1511. <span className="block text-bambu-gray mb-1">Print Job</span>
  1512. <span className="text-white font-medium truncate block">{archiveName}</span>
  1513. </p>
  1514. {/* Build-plate badge for the selected (or sole) plate — surfaced
  1515. early so the user knows which plate to mount before scheduling
  1516. (#1281). PlateSelector renders its own per-plate badges for
  1517. multi-plate files; this badge covers the single-plate case and
  1518. the multi-plate case where exactly one plate is selected. */}
  1519. {(() => {
  1520. if (!plates.length) return null;
  1521. const target = selectedPlate != null
  1522. ? plates.find(p => p.index === selectedPlate)
  1523. : plates[0];
  1524. const bed = getBedTypeInfo(target?.bed_type);
  1525. if (!bed) return null;
  1526. return (
  1527. <p className="flex items-center gap-1.5 text-xs text-bambu-gray -mt-2" title={bed.label}>
  1528. <img src={bed.icon} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
  1529. <span className="truncate">{bed.label}</span>
  1530. </p>
  1531. );
  1532. })()}
  1533. {/* Plate selection - first so users know filament requirements before
  1534. selecting printers. Cross-model has no use for it: the plate is
  1535. chosen per candidate in the list below, and this selector's own
  1536. choice never reached the request — it only decided which plate
  1537. the filament panel described (#3101). */}
  1538. {!isCrossModel && (
  1539. <PlateSelector
  1540. plates={plates}
  1541. isMultiPlate={isMultiPlate}
  1542. selectedPlates={selectedPlates}
  1543. onToggle={(plateIndex) => {
  1544. setSelectedPlates(prev => {
  1545. const next = new Set(prev);
  1546. if (!isEditing) {
  1547. // Multi-select: toggle the plate
  1548. if (next.has(plateIndex)) {
  1549. next.delete(plateIndex);
  1550. } else {
  1551. next.add(plateIndex);
  1552. }
  1553. } else {
  1554. // Single-select: replace selection
  1555. next.clear();
  1556. next.add(plateIndex);
  1557. }
  1558. return next;
  1559. });
  1560. }}
  1561. onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
  1562. onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
  1563. multiSelect={!isEditing}
  1564. quantities={usePerPlateQuantities ? plateQuantities : undefined}
  1565. onQuantityChange={usePerPlateQuantities
  1566. ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value }))
  1567. : undefined}
  1568. />
  1569. )}
  1570. {/* Cross-model alternatives (#671) replace the printer picker entirely:
  1571. the user already answered "which printer" by choosing these files,
  1572. and the remaining question is only which they'd rather have. */}
  1573. {isCrossModel && (
  1574. <VariantCandidates
  1575. candidates={candidates}
  1576. onReorder={setCandidates}
  1577. plateByFile={candidatePlates}
  1578. onPlateChange={(fileId, plateId) =>
  1579. setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
  1580. }
  1581. />
  1582. )}
  1583. {hasEditingVariants && (
  1584. <VariantCandidates
  1585. candidates={editingVariants}
  1586. readOnly
  1587. readOnlyNote={t('printModal.variants.editNote')}
  1588. onReorder={() => {}}
  1589. plateByFile={{}}
  1590. onPlateChange={() => {}}
  1591. />
  1592. )}
  1593. {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
  1594. {!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
  1595. <PrinterSelector
  1596. printers={printers || []}
  1597. selectedPrinterIds={selectedPrinters}
  1598. onMultiSelect={setSelectedPrinters}
  1599. isLoading={loadingPrinters}
  1600. allowMultiple={true}
  1601. showInactive={mode === 'edit-queue-item'}
  1602. disableBusy={false}
  1603. printerMappingResults={multiPrinterMapping.printerResults}
  1604. // The per-printer tray editor inside the selector maps one filament
  1605. // list onto each printer. Several plates have several lists, and a
  1606. // fan-out across printers ships no mapping at all (the scheduler maps
  1607. // each plate against the printer it picks), so the editor would be
  1608. // collecting tray choices it then throws away. Withhold its input.
  1609. filamentReqs={isMultiPlateSelection ? undefined : mappingFilamentReqs}
  1610. onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
  1611. onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
  1612. assignmentMode={assignmentMode}
  1613. onAssignmentModeChange={setAssignmentMode}
  1614. targetModel={targetModel}
  1615. onTargetModelChange={setTargetModel}
  1616. targetLocation={targetLocation}
  1617. onTargetLocationChange={setTargetLocation}
  1618. slicedForModel={slicedForModel}
  1619. />
  1620. )}
  1621. {/* Filament override - shown in model mode when filament requirements are available */}
  1622. {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
  1623. <FilamentOverride
  1624. filamentReqs={effectiveFilamentReqs}
  1625. availableFilaments={effectiveAvailableFilaments!}
  1626. overrides={filamentOverrides}
  1627. onChange={setFilamentOverrides}
  1628. forceColorMatch={forceColorMatch}
  1629. onForceColorMatchChange={(slotId, value) =>
  1630. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1631. }
  1632. />
  1633. )}
  1634. {/* Filament override, one panel per selected plate. `effectiveFilamentReqs`
  1635. is keyed on `selectedPlate`, which is null as soon as two plates are
  1636. picked, so a multi-plate selection used to render this panel from
  1637. whatever the whole-file query had left in the cache — the union of every
  1638. plate's filaments, or nothing at all once the plates query was warm and
  1639. the whole-file query therefore never ran, which is why the section
  1640. vanished on the second open of the dialog (#2552). */}
  1641. {showFilamentOverride && isMultiPlateSelection && selectedPlateIds.map((plateId, idx) => {
  1642. const plate = plates.find((p) => p.index === plateId);
  1643. const plateReqs = perPlateReqs.get(plateId);
  1644. if (!plateReqs) return null;
  1645. return (
  1646. <FilamentOverride
  1647. key={plateId}
  1648. plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
  1649. showHint={idx === 0}
  1650. filamentReqs={plateReqs}
  1651. availableFilaments={availableFilaments!}
  1652. overrides={filamentOverrides}
  1653. onChange={setFilamentOverrides}
  1654. forceColorMatch={forceColorMatch}
  1655. onForceColorMatchChange={(slotId, value) =>
  1656. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1657. }
  1658. />
  1659. );
  1660. })}
  1661. {/* Compatibility warning when sliced model doesn't match selected printer */}
  1662. {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
  1663. const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
  1664. if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
  1665. return (
  1666. <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">
  1667. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
  1668. <span className="text-sm text-yellow-700 dark:text-yellow-400">
  1669. File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
  1670. </span>
  1671. </div>
  1672. );
  1673. }
  1674. return null;
  1675. })()}
  1676. {/* Warning when archive data couldn't be loaded */}
  1677. {archiveDataMissing && (
  1678. <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">
  1679. <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  1680. <p className="text-orange-700 dark:text-orange-400">
  1681. Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
  1682. </p>
  1683. </div>
  1684. )}
  1685. {/* A selected plate whose filaments could not be read cannot be mapped and
  1686. cannot carry its forced colours, so it is not queued silently — say so
  1687. and hold the button until the plate is deselected. */}
  1688. {perPlateReqsFailed && (
  1689. <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">
  1690. <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  1691. <p className="text-orange-700 dark:text-orange-400">
  1692. {t(
  1693. 'printModal.plateFilamentsUnreadable',
  1694. "The filaments of a selected plate could not be read, so it can't be mapped. Deselect it to queue the others.",
  1695. )}
  1696. </p>
  1697. </div>
  1698. )}
  1699. {/* Filament mapping - only show when single printer selected */}
  1700. {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
  1701. <FilamentMapping
  1702. printerId={effectivePrinterId!}
  1703. filamentReqs={mappingFilamentReqs}
  1704. manualMappings={manualMappings}
  1705. onManualMappingChange={setManualMappings}
  1706. onEstimatedCostChange={setEstimatedCost}
  1707. budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
  1708. quantity={effectiveQuantity}
  1709. defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
  1710. currencySymbol={currencySymbol}
  1711. defaultCostPerKg={defaultCostPerKg}
  1712. forceColorMatch={forceColorMatch}
  1713. onForceColorMatchChange={(slotId, value) =>
  1714. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1715. }
  1716. archiveAmsMapping={archiveSlicerAmsMapping}
  1717. nozzleRackChoice={nozzleRackChoice}
  1718. onNozzleRackChoiceChange={setNozzleRackChoice}
  1719. />
  1720. )}
  1721. {/* Filament mapping, one panel per selected plate — each plate is its
  1722. own print with its own slots, so it gets its own AMS mapping. */}
  1723. {showPerPlateFilamentMapping && !archiveDataMissing && selectedPlateIds.map((plateId) => {
  1724. const plate = plates.find((p) => p.index === plateId);
  1725. const plateReqs = mappingPerPlateReqs.get(plateId);
  1726. if (!plateReqs) return null;
  1727. return (
  1728. <FilamentMapping
  1729. key={plateId}
  1730. printerId={effectivePrinterId!}
  1731. plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
  1732. filamentReqs={plateReqs}
  1733. manualMappings={manualMappingsByPlate[plateId] ?? {}}
  1734. onManualMappingChange={(mappings) =>
  1735. setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: mappings }))
  1736. }
  1737. onEstimatedCostChange={(cost) =>
  1738. setEstimatedCostsByPlate((prev) => ({ ...prev, [plateId]: cost }))
  1739. }
  1740. budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
  1741. quantity={quantityForPlate(plateId)}
  1742. defaultExpanded={false}
  1743. currencySymbol={currencySymbol}
  1744. defaultCostPerKg={defaultCostPerKg}
  1745. forceColorMatch={forceColorMatch}
  1746. onForceColorMatchChange={(slotId, value) =>
  1747. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1748. }
  1749. archiveAmsMapping={archiveSlicerAmsMapping}
  1750. nozzleRackChoice={nozzleRackChoiceByPlate[plateId] ?? {}}
  1751. onNozzleRackChoiceChange={(choice) =>
  1752. setNozzleRackChoiceByPlate((prev) => ({ ...prev, [plateId]: choice }))
  1753. }
  1754. />
  1755. );
  1756. })}
  1757. {/* Print options */}
  1758. {(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
  1759. <PrintOptionsPanel
  1760. options={printOptions}
  1761. onChange={setPrintOptions}
  1762. defaultExpanded={!!initialSelectedPrinterIds?.length}
  1763. showDualNozzleOptions={showDualNozzleOptions}
  1764. />
  1765. )}
  1766. {billingEnabled && printableCostCenters.length > 0 && (
  1767. <CostCenterSelect
  1768. costCenters={printableCostCenters}
  1769. selectedCostCenterId={selectedCostCenterId}
  1770. onChange={setSelectedCostCenterId}
  1771. />
  1772. )}
  1773. {billingEnabled && !loadingCostCenters && printableCostCenters.length === 0 && (
  1774. <div
  1775. role="alert"
  1776. 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"
  1777. >
  1778. <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
  1779. {t('printModal.noPrintableCostCenters')}
  1780. </div>
  1781. )}
  1782. {/* Quantity — create multiple copies (batch). Hidden for multi-printer
  1783. selection, and for multi-plate files where the per-plate steppers
  1784. in PlateSelector own the number instead (#342). */}
  1785. {mode !== 'edit-queue-item' && !usePerPlateQuantities
  1786. && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
  1787. <div className="flex items-center gap-3">
  1788. <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
  1789. {t('queue.quantity', 'Quantity')}
  1790. </label>
  1791. <NumberInput
  1792. id="printQuantity"
  1793. min={1}
  1794. max={999}
  1795. value={quantity}
  1796. onChange={setQuantity}
  1797. fallback={1}
  1798. 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"
  1799. />
  1800. {quantity > 1 && (
  1801. <span className="text-xs text-bambu-gray">
  1802. {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
  1803. </span>
  1804. )}
  1805. </div>
  1806. )}
  1807. {/* Schedule options */}
  1808. <ScheduleOptionsPanel
  1809. options={scheduleOptions}
  1810. onChange={setScheduleOptions}
  1811. dateFormat={settings?.date_format || 'system'}
  1812. timeFormat={settings?.time_format || 'system'}
  1813. canControlPrinter={hasPermission('printers:control')}
  1814. showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
  1815. printerCount={selectedPrinters.length}
  1816. hasGcodeSnippets={!!settings?.gcode_snippets}
  1817. />
  1818. {/* Outcome prompt (#1898) sits outside the collapsed Print Options
  1819. panel so it is discoverable; it edits the same printOptions
  1820. field as the row inside the panel. */}
  1821. <button
  1822. type="button"
  1823. aria-pressed={printOptions.confirm_outcome}
  1824. title={t('printModal.askForOutcomeTitle')}
  1825. onClick={() => setPrintOptions((prev) => ({ ...prev, confirm_outcome: !prev.confirm_outcome }))}
  1826. className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors ${
  1827. printOptions.confirm_outcome
  1828. ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
  1829. : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1830. }`}
  1831. >
  1832. <ThumbsUp className="w-4 h-4" />
  1833. {t('printModal.askForOutcome')}
  1834. </button>
  1835. {/* Error message */}
  1836. {updateQueueMutation.isError && (
  1837. <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">
  1838. {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
  1839. </div>
  1840. )}
  1841. {/* Waiting for the printer's AMS status: submitting now would map
  1842. against zero known trays and dispatch to the empty external feed (#2589). */}
  1843. {assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading && (
  1844. <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">
  1845. <Loader2 className="w-4 h-4 animate-spin" />
  1846. {t('printModal.waitingForAmsStatus', {
  1847. printer: printers?.find((p) => p.id === effectivePrinterId)?.name ?? '',
  1848. })}
  1849. </div>
  1850. )}
  1851. {/* Actions */}
  1852. <div className="flex gap-3 pt-2">
  1853. <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
  1854. Cancel
  1855. </Button>
  1856. <Button
  1857. type="submit"
  1858. disabled={!canSubmit}
  1859. className="flex-1"
  1860. >
  1861. {isPending ? (
  1862. <>
  1863. <Loader2 className="w-4 h-4 animate-spin" />
  1864. {modalConfig.loadingText}
  1865. </>
  1866. ) : (
  1867. <>
  1868. <SubmitIcon className="w-4 h-4" />
  1869. {modalConfig.submitText}
  1870. </>
  1871. )}
  1872. </Button>
  1873. </div>
  1874. </form>
  1875. </CardContent>
  1876. </Card>
  1877. {filamentWarningItems && filamentWarningItems.length > 0 && (
  1878. <ConfirmModal
  1879. title={t('printModal.insufficientFilamentTitle')}
  1880. message={filamentWarningMessage}
  1881. confirmText={t('printModal.printAnyway')}
  1882. cancelText={t('common.cancel')}
  1883. variant="warning"
  1884. onConfirm={() => {
  1885. setFilamentWarningItems(null);
  1886. void handleSubmit(undefined, { skipFilamentCheck: true });
  1887. }}
  1888. onCancel={() => setFilamentWarningItems(null)}
  1889. />
  1890. )}
  1891. </div>
  1892. );
  1893. }
  1894. // Re-export types for convenience
  1895. export type { PrintModalMode, PrintModalProps } from './types';