index.tsx 104 KB

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