index.tsx 89 KB

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