index.tsx 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510
  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 { PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } 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 { getCurrencySymbol } from '../../utils/currency';
  21. import { getBedTypeInfo } from '../../utils/bedType';
  22. import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
  23. import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
  24. import { FilamentMapping } from './FilamentMapping';
  25. import { FilamentOverride } from './FilamentOverride';
  26. import { PlateSelector } from './PlateSelector';
  27. import { PrinterSelector } from './PrinterSelector';
  28. import { PrintOptionsPanel } from './PrintOptions';
  29. import { ScheduleOptionsPanel } from './ScheduleOptions';
  30. import type {
  31. AssignmentMode,
  32. FilamentReqsData,
  33. PrintModalProps,
  34. PrintOptions,
  35. ScheduleOptions,
  36. ScheduleType,
  37. } from './types';
  38. import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
  39. /**
  40. * Unified PrintModal component that handles queue item creation and editing.
  41. * - 'create': Create a print queue item from an archive or library file
  42. * - 'edit-queue-item': Edit existing queue item
  43. *
  44. * Both archiveId and libraryFileId are supported. Library files are archived at
  45. * print start time by the scheduler, not when queued.
  46. */
  47. export function PrintModal({
  48. mode,
  49. archiveId,
  50. libraryFileId,
  51. archiveName,
  52. queueItem,
  53. initialSelectedPrinterIds,
  54. onClose,
  55. onSuccess,
  56. projectId,
  57. cleanupLibraryAfterDispatch,
  58. }: PrintModalProps) {
  59. const { t } = useTranslation();
  60. const queryClient = useQueryClient();
  61. const { showToast } = useToast();
  62. const { hasPermission } = useAuth();
  63. // Determine if we're printing a library file
  64. const isLibraryFile = !!libraryFileId && !archiveId;
  65. const isEditing = mode === 'edit-queue-item';
  66. type FilamentWarningItem = {
  67. printerName: string;
  68. slotLabel: string;
  69. requiredGrams: number;
  70. remainingGrams: number;
  71. };
  72. // Multiple printer selection (used for all modes now)
  73. const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
  74. // Initialize with the queue item's printer if editing
  75. if (mode === 'edit-queue-item' && queueItem?.printer_id) {
  76. return [queueItem.printer_id];
  77. }
  78. if (initialSelectedPrinterIds?.length) {
  79. return initialSelectedPrinterIds;
  80. }
  81. return [];
  82. });
  83. // Multi-select plates: create mode users can pick a subset of plates
  84. const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {
  85. if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
  86. return new Set([queueItem.plate_id]);
  87. }
  88. return new Set();
  89. });
  90. // Derived single-plate value for filament queries and single-select contexts
  91. const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
  92. // Quantity — number of copies (creates a batch if > 1)
  93. const [quantity, setQuantity] = useState(1);
  94. const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
  95. if (mode === 'edit-queue-item' && queueItem) {
  96. return {
  97. bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
  98. flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
  99. vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
  100. layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
  101. timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
  102. nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
  103. preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
  104. preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
  105. };
  106. }
  107. return DEFAULT_PRINT_OPTIONS;
  108. });
  109. const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
  110. if (mode === 'edit-queue-item' && queueItem) {
  111. let scheduleType: ScheduleType = 'queue';
  112. if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  113. scheduleType = 'scheduled';
  114. }
  115. let scheduledTime = '';
  116. if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  117. const date = parseUTCDate(queueItem.scheduled_time) ?? new Date();
  118. // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
  119. scheduledTime = toDateTimeLocalValue(date);
  120. }
  121. return {
  122. scheduleType,
  123. scheduledTime,
  124. requireManualStart: queueItem.manual_start,
  125. requirePreviousSuccess: queueItem.require_previous_success,
  126. autoOffAfter: queueItem.auto_off_after,
  127. gcodeInjection: queueItem.gcode_injection ?? false,
  128. staggerEnabled: false,
  129. staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,
  130. staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
  131. };
  132. }
  133. return DEFAULT_SCHEDULE_OPTIONS;
  134. });
  135. // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
  136. const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
  137. if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
  138. const mappings: Record<number, number> = {};
  139. queueItem.ams_mapping.forEach((globalTrayId, idx) => {
  140. if (globalTrayId !== -1) {
  141. mappings[idx + 1] = globalTrayId;
  142. }
  143. });
  144. return mappings;
  145. }
  146. return {};
  147. });
  148. // Per-printer override configs (for multi-printer selection)
  149. const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
  150. // Assignment mode: 'printer' (specific) or 'model' (any of model)
  151. const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
  152. // Initialize from queue item if editing with target_model
  153. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  154. return 'model';
  155. }
  156. return 'printer';
  157. });
  158. // Target model for model-based assignment
  159. const [targetModel, setTargetModel] = useState<string | null>(() => {
  160. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  161. return queueItem.target_model;
  162. }
  163. return null;
  164. });
  165. // Target location for model-based assignment (optional filter)
  166. const [targetLocation, setTargetLocation] = useState<string | null>(() => {
  167. if (mode === 'edit-queue-item' && queueItem?.target_location) {
  168. return queueItem.target_location;
  169. }
  170. return null;
  171. });
  172. // Filament overrides for model-based assignment: slot_id -> {type, color}
  173. const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
  174. if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
  175. const overrides: Record<number, { type: string; color: string }> = {};
  176. for (const o of queueItem.filament_overrides) {
  177. overrides[o.slot_id] = { type: o.type, color: o.color };
  178. }
  179. return overrides;
  180. }
  181. return {};
  182. });
  183. // Per-slot force color match flags. Default is false (opt-in).
  184. const [forceColorMatch, setForceColorMatch] = useState<Record<number, boolean>>(() => {
  185. if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
  186. const flags: Record<number, boolean> = {};
  187. for (const o of queueItem.filament_overrides) {
  188. flags[o.slot_id] = o.force_color_match === true;
  189. }
  190. return flags;
  191. }
  192. return {};
  193. });
  194. // Track initial values for clearing mappings on change (edit mode only)
  195. const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
  196. const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
  197. // Submission state for multi-printer
  198. const [isSubmitting, setIsSubmitting] = useState(false);
  199. const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
  200. const [filamentWarningItems, setFilamentWarningItems] = useState<FilamentWarningItem[] | null>(null);
  201. // Track which printers have had the "Expand custom mapping by default" setting applied
  202. // This ensures the setting only affects initial state, not preventing unchecking
  203. const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
  204. // Printer counts and effective printer for filament mapping
  205. const effectivePrinterCount = selectedPrinters.length;
  206. // For filament mapping, use first selected printer (mapping applies to all)
  207. const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
  208. // Queries
  209. const { data: settings } = useQuery({
  210. queryKey: ['settings'],
  211. queryFn: api.getSettings,
  212. });
  213. // Sync print option defaults from settings once available
  214. const printDefaultsApplied = useRef(false);
  215. useEffect(() => {
  216. if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return;
  217. printDefaultsApplied.current = true;
  218. setPrintOptions({
  219. bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
  220. flow_cali: settings.default_flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
  221. vibration_cali: settings.default_vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
  222. layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
  223. timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
  224. nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
  225. preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
  226. preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
  227. });
  228. }, [settings, mode]);
  229. // Sync stagger defaults from settings once available
  230. const staggerDefaultsApplied = useRef(false);
  231. useEffect(() => {
  232. if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
  233. staggerDefaultsApplied.current = true;
  234. setScheduleOptions((prev) => ({
  235. ...prev,
  236. staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
  237. staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
  238. }));
  239. }, [settings, mode]);
  240. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  241. const defaultCostPerKg = settings?.default_filament_cost ?? 0;
  242. const { data: printers, isLoading: loadingPrinters } = useQuery({
  243. queryKey: ['printers'],
  244. queryFn: api.getPrinters,
  245. });
  246. const { data: spoolAssignments } = useQuery({
  247. queryKey: ['spool-assignments'],
  248. queryFn: () => api.getAssignments(),
  249. staleTime: 30 * 1000,
  250. enabled: !isEditing && assignmentMode === 'printer',
  251. });
  252. // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
  253. // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
  254. // so internal and Spoolman modes both work uniformly, VT/external slots are
  255. // excluded, and negative grams are clamped — single source of truth between
  256. // the client-side preview and dispatch-time picks.
  257. const inventoryRemainQueries = useQueries({
  258. queries: selectedPrinters.map((printerId) => ({
  259. queryKey: ['printer-inventory-remain', printerId],
  260. queryFn: () => api.getInventoryRemain(printerId),
  261. staleTime: 30 * 1000,
  262. enabled: selectedPrinters.length > 0,
  263. })),
  264. });
  265. const inventoryByTrayIdPerPrinter = useMemo(() => {
  266. const result = new Map<number, Map<number, number>>();
  267. selectedPrinters.forEach((printerId, idx) => {
  268. const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
  269. if (!data) return;
  270. const printerMap = new Map<number, number>();
  271. Object.entries(data).forEach(([key, grams]) => {
  272. const gtid = Number(key);
  273. if (!Number.isNaN(gtid)) printerMap.set(gtid, grams);
  274. });
  275. result.set(printerId, printerMap);
  276. });
  277. return result;
  278. }, [selectedPrinters, inventoryRemainQueries]);
  279. // Fetch archive details to get sliced_for_model
  280. const { data: archiveDetails } = useQuery({
  281. queryKey: ['archive', archiveId],
  282. queryFn: () => api.getArchive(archiveId!),
  283. enabled: !!archiveId && !isLibraryFile,
  284. });
  285. // Fetch library file details to get sliced_for_model
  286. const { data: libraryFileDetails } = useQuery({
  287. queryKey: ['library-file', libraryFileId],
  288. queryFn: () => api.getLibraryFile(libraryFileId!),
  289. enabled: isLibraryFile && !!libraryFileId,
  290. });
  291. // Get sliced_for_model from archive or library file
  292. const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
  293. // Fetch plates for archives
  294. const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
  295. queryKey: ['archive-plates', archiveId],
  296. queryFn: () => api.getArchivePlates(archiveId!),
  297. enabled: !!archiveId && !isLibraryFile,
  298. retry: false,
  299. });
  300. // Fetch plates for library files
  301. const { data: libraryPlatesData } = useQuery({
  302. queryKey: ['library-file-plates', libraryFileId],
  303. queryFn: () => api.getLibraryFilePlates(libraryFileId!),
  304. enabled: isLibraryFile && !!libraryFileId,
  305. });
  306. // Combine plates data from either source
  307. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  308. // Fetch filament requirements for archives
  309. const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
  310. queryKey: ['archive-filaments', archiveId, selectedPlate],
  311. queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
  312. enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
  313. retry: false,
  314. });
  315. // Fetch filament requirements for library files (with plate support)
  316. const { data: libraryFilamentReqs } = useQuery({
  317. queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
  318. queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
  319. enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
  320. });
  321. // Track if archive data couldn't be loaded (archive deleted or file missing)
  322. const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
  323. // Combine filament requirements from either source
  324. const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
  325. // Fetch available filaments for model-based assignment (for filament override UI)
  326. const { data: availableFilaments } = useQuery({
  327. queryKey: ['available-filaments', targetModel, targetLocation],
  328. queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
  329. enabled: assignmentMode === 'model' && !!targetModel,
  330. });
  331. // Only fetch printer status when single printer selected (for filament mapping)
  332. const { data: printerStatus } = useQuery({
  333. queryKey: ['printer-status', effectivePrinterId],
  334. queryFn: () => api.getPrinterStatus(effectivePrinterId!),
  335. enabled: !!effectivePrinterId,
  336. });
  337. // Single-printer flow: gate prefer_lowest on this printer's backup state.
  338. // Multi-printer flow gates per-printer inside the hook (different printers
  339. // may have different backup states), so we pass the raw setting down.
  340. const singlePrinterPreferLowest = effectivePreferLowest(
  341. settings?.prefer_lowest_filament,
  342. printerStatus?.ams_filament_backup,
  343. );
  344. const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => {
  345. if (!status?.connected) return false;
  346. if (status.awaiting_plate_clear) return false;
  347. if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
  348. return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
  349. };
  350. const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
  351. if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
  352. if (selectedPrinters.length === 0) return false;
  353. try {
  354. const statuses = await Promise.all(
  355. selectedPrinters.map((printerId) =>
  356. queryClient.fetchQuery({
  357. queryKey: ['printer-status', printerId],
  358. queryFn: () => api.getPrinterStatus(printerId),
  359. staleTime: 0,
  360. }),
  361. ),
  362. );
  363. return statuses.some((status) => !isPrinterCurrentlyDispatchable(status));
  364. } catch {
  365. return true;
  366. }
  367. };
  368. // Get AMS mapping from hook (only when single printer selected)
  369. const { amsMapping } = useFilamentMapping(
  370. effectiveFilamentReqs,
  371. printerStatus,
  372. manualMappings,
  373. singlePrinterPreferLowest,
  374. effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
  375. );
  376. // --- Per-plate filament mapping (multi-plate submissions) ---------------
  377. // Each plate prints its own subset of the file's slots and needs its own AMS
  378. // mapping. `effectiveFilamentReqs` above is keyed on `selectedPlate`, which is
  379. // null the moment two plates are picked, so it holds the union of every plate's
  380. // filaments — matching against that union lets two plates that share a colour
  381. // on different slots compete for the same tray, and sends the loser to a worse
  382. // tray or to none. So when several plates are selected we fetch each plate's
  383. // requirements and map them separately (#2551 follow-up).
  384. const selectedPlateIds = useMemo(() => [...selectedPlates].sort((a, b) => a - b), [selectedPlates]);
  385. const isMultiPlateSelection = selectedPlates.size > 1;
  386. const perPlateReqQueries = useQueries({
  387. queries: (isMultiPlateSelection ? selectedPlateIds : []).map((plateId) => ({
  388. queryKey: isLibraryFile
  389. ? ['library-file-filaments', libraryFileId, plateId]
  390. : ['archive-filaments', archiveId, plateId],
  391. queryFn: () =>
  392. isLibraryFile
  393. ? api.getLibraryFileFilamentRequirements(libraryFileId!, plateId)
  394. : api.getArchiveFilamentRequirements(archiveId!, plateId),
  395. enabled: isLibraryFile ? !!libraryFileId : !!archiveId,
  396. // Same policy as the single-plate query above: these keys are shared, and a
  397. // retrying observer would leave the plate looking merely slow for seconds.
  398. retry: false,
  399. })),
  400. });
  401. // A plate that has not answered yet and a plate whose 3MF cannot be read look
  402. // identical from here — both are simply absent from `perPlateReqs`. Neither may
  403. // be treated as "this plate needs no filament": that would queue it with no
  404. // mapping and no force-colour overrides, and it would print in whatever happens
  405. // to be loaded. Both states gate submission instead (see `canSubmit`).
  406. // `isPending` is "no data yet", not "a request is in flight" — a background
  407. // refetch of a plate we already have must not disable the button under the user.
  408. const perPlateReqsPending = perPlateReqQueries.some((q) => q.isPending);
  409. const perPlateReqsFailed = perPlateReqQueries.some((q) => q.isError);
  410. const perPlateReqs = useMemo(() => {
  411. const byPlate = new Map<number, FilamentReqsData>();
  412. selectedPlateIds.forEach((plateId, i) => {
  413. const data = perPlateReqQueries[i]?.data;
  414. if (data) byPlate.set(plateId, data);
  415. });
  416. return byPlate;
  417. // Keyed on each query's last update stamp, not on the query objects (fresh every
  418. // render) and not on a spread of their data (a dep array whose *length* changes
  419. // with the plate count, which React treats as always-changed and warns about).
  420. // eslint-disable-next-line react-hooks/exhaustive-deps
  421. }, [selectedPlateIds, perPlateReqQueries.map((q) => q.dataUpdatedAt).join('|')]);
  422. // Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2
  423. // are different prints and may want different trays.
  424. const [manualMappingsByPlate, setManualMappingsByPlate] = useState<Record<number, Record<number, number>>>({});
  425. // Only ever computed for a single target printer: a tray id means nothing on a
  426. // different printer, so a fan-out across printers must not reuse these.
  427. const perPlateAmsMappings = useMemo(() => {
  428. const byPlate = new Map<number, number[] | undefined>();
  429. if (!isMultiPlateSelection || !effectivePrinterId || selectedPrinters.length !== 1) return byPlate;
  430. const loaded = buildLoadedFilaments(printerStatus);
  431. const ftsActive = printerStatus?.fila_switch?.installed === true;
  432. const inventoryByTrayId = inventoryByTrayIdPerPrinter.get(effectivePrinterId);
  433. for (const plateId of selectedPlateIds) {
  434. const reqs = perPlateReqs.get(plateId);
  435. if (!reqs) continue;
  436. const comparison = buildFilamentComparison(
  437. reqs,
  438. loaded,
  439. manualMappingsByPlate[plateId] ?? {},
  440. singlePrinterPreferLowest,
  441. inventoryByTrayId,
  442. ftsActive,
  443. );
  444. byPlate.set(plateId, buildAmsMapping(comparison));
  445. }
  446. return byPlate;
  447. }, [
  448. isMultiPlateSelection,
  449. effectivePrinterId,
  450. printerStatus,
  451. inventoryByTrayIdPerPrinter,
  452. selectedPlateIds,
  453. perPlateReqs,
  454. manualMappingsByPlate,
  455. singlePrinterPreferLowest,
  456. selectedPrinters.length,
  457. ]);
  458. // Multi-printer filament mapping (for per-printer configuration)
  459. const multiPrinterMapping = useMultiPrinterFilamentMapping(
  460. selectedPrinters,
  461. printers,
  462. effectiveFilamentReqs,
  463. manualMappings,
  464. perPrinterConfigs,
  465. setPerPrinterConfigs,
  466. settings?.prefer_lowest_filament,
  467. inventoryByTrayIdPerPrinter,
  468. );
  469. // Auto-select first plate when plates load (single or multi-plate)
  470. useEffect(() => {
  471. if (platesData?.plates && platesData.plates.length >= 1 && selectedPlates.size === 0) {
  472. setSelectedPlates(new Set([platesData.plates[0].index]));
  473. }
  474. }, [platesData, selectedPlates.size]);
  475. // Auto-select first printer when only one available
  476. useEffect(() => {
  477. // Skip auto-select for edit mode (already initialized from queueItem)
  478. if (mode === 'edit-queue-item') return;
  479. const activePrinters = printers?.filter(p => p.is_active) || [];
  480. if (activePrinters.length === 1 && selectedPrinters.length === 0) {
  481. setSelectedPrinters([activePrinters[0].id]);
  482. }
  483. }, [mode, printers, selectedPrinters.length]);
  484. // Clear manual mappings and per-printer configs when printer or plate changes.
  485. // The per-plate mappings go with them: a manual override holds a global tray id,
  486. // which names a different spool on a different printer.
  487. useEffect(() => {
  488. if (mode === 'edit-queue-item') {
  489. // For edit mode, clear mappings if printer selection or plate changed from initial
  490. const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
  491. if (printersChanged || selectedPlate !== initialPlateId) {
  492. setManualMappings({});
  493. setManualMappingsByPlate({});
  494. setPerPrinterConfigs({});
  495. setInitialExpandApplied(new Set());
  496. }
  497. } else {
  498. setManualMappings({});
  499. setManualMappingsByPlate({});
  500. setPerPrinterConfigs({});
  501. setInitialExpandApplied(new Set());
  502. }
  503. }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
  504. // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)
  505. const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
  506. const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
  507. useEffect(() => {
  508. if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {
  509. setPrevTargetModel(targetModel);
  510. setPrevPlateForOverrides(selectedPlate);
  511. // Don't clear on initial render in edit mode (values are initialized from queueItem)
  512. if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
  513. setFilamentOverrides({});
  514. setForceColorMatch({});
  515. }
  516. }
  517. }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
  518. // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
  519. // Only applies once per printer on initial selection, not when user unchecks
  520. useEffect(() => {
  521. if (!settings?.per_printer_mapping_expanded) return;
  522. if (selectedPrinters.length <= 1) return;
  523. // Only auto-configure printers that:
  524. // 1. Haven't had initial expand applied yet
  525. // 2. Have their status loaded (so auto-configure will actually work)
  526. const printersReadyForExpand = selectedPrinters.filter(printerId => {
  527. if (initialExpandApplied.has(printerId)) return false;
  528. // Check if this printer has status loaded
  529. const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);
  530. return result && result.status && !result.isLoading;
  531. });
  532. if (printersReadyForExpand.length > 0) {
  533. // Mark these printers as having been initially expanded
  534. setInitialExpandApplied(prev => {
  535. const next = new Set(prev);
  536. printersReadyForExpand.forEach(id => next.add(id));
  537. return next;
  538. });
  539. // Auto-configure printers
  540. printersReadyForExpand.forEach(printerId => {
  541. multiPrinterMapping.autoConfigurePrinter(printerId);
  542. });
  543. }
  544. }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);
  545. // Close on Escape key
  546. useEffect(() => {
  547. const handleKeyDown = (e: KeyboardEvent) => {
  548. if (e.key === 'Escape' && !isSubmitting) onClose();
  549. };
  550. window.addEventListener('keydown', handleKeyDown);
  551. return () => window.removeEventListener('keydown', handleKeyDown);
  552. }, [onClose, isSubmitting]);
  553. const isMultiPlate = platesData?.is_multi_plate ?? false;
  554. const plates = platesData?.plates ?? [];
  555. const spoolAssignmentsByPrinter = useMemo(() => {
  556. const map = new Map<number, Map<number, SpoolAssignment>>();
  557. if (!spoolAssignments) return map;
  558. spoolAssignments.forEach((assignment) => {
  559. const isExternal = assignment.ams_id === 255;
  560. const globalTrayId = getGlobalTrayId(
  561. assignment.ams_id,
  562. assignment.tray_id,
  563. isExternal
  564. );
  565. const printerMap = map.get(assignment.printer_id) ?? new Map();
  566. printerMap.set(globalTrayId, assignment);
  567. map.set(assignment.printer_id, printerMap);
  568. });
  569. return map;
  570. }, [spoolAssignments]);
  571. const filamentWarningMessage = useMemo(() => {
  572. if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
  573. const lines = filamentWarningItems.map((item) =>
  574. t('printModal.insufficientFilamentLine', {
  575. printer: item.printerName,
  576. slot: item.slotLabel,
  577. required: Math.round(item.requiredGrams),
  578. remaining: Math.round(item.remainingGrams),
  579. })
  580. );
  581. return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
  582. }, [filamentWarningItems, t]);
  583. // Add to queue mutation (single printer)
  584. const addToQueueMutation = useMutation({
  585. mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
  586. });
  587. // Update queue item mutation
  588. const updateQueueMutation = useMutation({
  589. mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
  590. onSuccess: () => {
  591. queryClient.invalidateQueries({ queryKey: ['queue'] });
  592. showToast('Queue item updated');
  593. onSuccess?.();
  594. onClose();
  595. },
  596. onError: (error: Error) => {
  597. showToast(error.message || 'Failed to update queue item', 'error');
  598. },
  599. });
  600. // Get mapping for a specific printer (per-printer override or default).
  601. // A multi-plate submission maps each plate on its own — `amsMapping` and the
  602. // per-printer mappings are both derived from the union of every selected
  603. // plate's filaments, which is not this plate's print (#2551 follow-up).
  604. // Without a per-plate mapping we send none at all and let the scheduler
  605. // compute one at dispatch, which it already does per plate; a union mapping
  606. // would be used verbatim and could feed a slot from the wrong tray.
  607. const getMappingForPrinter = (printerId: number, plateId: number | null): number[] | undefined => {
  608. if (isMultiPlateSelection) {
  609. // Fanning several plates across several printers would be a mapping per
  610. // plate *per printer*; those items go out without one and the scheduler
  611. // maps each plate against the printer it actually picks.
  612. if (plateId === null || selectedPrinters.length !== 1) return undefined;
  613. return perPlateAmsMappings.get(plateId);
  614. }
  615. // For multi-printer selection, check if this printer has an override
  616. if (selectedPrinters.length > 1) {
  617. const printerConfig = perPrinterConfigs[printerId];
  618. if (printerConfig && !printerConfig.useDefault) {
  619. return multiPrinterMapping.getFinalMapping(printerId);
  620. }
  621. }
  622. return amsMapping;
  623. };
  624. const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
  625. e?.preventDefault();
  626. if (
  627. !options?.skipFilamentCheck &&
  628. !settings?.disable_filament_warnings &&
  629. !isEditing &&
  630. assignmentMode === 'printer'
  631. ) {
  632. const warningItems: FilamentWarningItem[] = [];
  633. // The spool check follows what is actually dispatched: one job per selected
  634. // plate, each with the mapping that plate's queue item carries. Two plates
  635. // can also draw on the same spool, so the demand is summed per tray before
  636. // it is weighed against what is left on it — 60 g left does not cover two
  637. // plates of 40 g, even though it covers either one of them (#2551).
  638. const plateJobs = isMultiPlateSelection
  639. ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
  640. : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
  641. if (plateJobs.some((job) => job.reqs.length > 0) && spoolAssignmentsByPrinter.size > 0) {
  642. const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
  643. if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
  644. if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
  645. return Math.max(0, labelWeight - weightUsed);
  646. };
  647. for (const printerId of selectedPrinters) {
  648. const printerStatusForWarning = selectedPrinters.length > 1
  649. ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
  650. : printerStatus;
  651. const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
  652. const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
  653. const assignments = spoolAssignmentsByPrinter.get(printerId);
  654. const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
  655. if (!assignments) continue;
  656. const gramsByTray = new Map<number, number>();
  657. for (const job of plateJobs) {
  658. // No mapping means the scheduler picks the trays at dispatch, against
  659. // an AMS state we cannot see from here — nothing to weigh.
  660. const printerMapping = getMappingForPrinter(printerId, job.plateId);
  661. if (!printerMapping) continue;
  662. job.reqs.forEach((req) => {
  663. if (!req.slot_id || req.slot_id <= 0) return;
  664. const globalTrayId = printerMapping[req.slot_id - 1];
  665. if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
  666. gramsByTray.set(globalTrayId, (gramsByTray.get(globalTrayId) ?? 0) + req.used_grams);
  667. });
  668. }
  669. for (const [globalTrayId, requiredGrams] of gramsByTray) {
  670. const spool = assignments.get(globalTrayId)?.spool;
  671. if (!spool) continue;
  672. const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
  673. if (remainingGrams === null) continue;
  674. if (remainingGrams >= requiredGrams) continue;
  675. warningItems.push({
  676. printerName,
  677. slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
  678. requiredGrams,
  679. remainingGrams,
  680. });
  681. }
  682. }
  683. }
  684. if (warningItems.length > 0) {
  685. setFilamentWarningItems(warningItems);
  686. return;
  687. }
  688. }
  689. // Validate printer/model selection
  690. if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
  691. showToast('Please select at least one printer', 'error');
  692. return;
  693. }
  694. if (assignmentMode === 'model' && !targetModel) {
  695. showToast('Please select a target printer model', 'error');
  696. return;
  697. }
  698. setIsSubmitting(true);
  699. // Calculate total API calls: plates × printers (or 1 for model-based)
  700. const platesToQueue = selectedPlates.size > 1
  701. ? plates.filter(p => selectedPlates.has(p.index))
  702. : [null];
  703. const totalCount = assignmentMode === 'model'
  704. ? platesToQueue.length
  705. : selectedPrinters.length * platesToQueue.length;
  706. setSubmitProgress({ current: 0, total: totalCount });
  707. const results: { success: number; failed: number; errors: string[] } = {
  708. success: 0,
  709. failed: 0,
  710. errors: [],
  711. };
  712. // Convert filament overrides from Record to array format for API.
  713. // Include all slots that either have a user override or have force_color_match enabled
  714. // (which is the default for model-based assignment).
  715. const buildFilamentOverridesArray = (reqs: FilamentReqsData | undefined) => {
  716. const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
  717. // Process all slots from filament requirements (to capture force_color_match defaults)
  718. if (reqs?.filaments) {
  719. for (const req of reqs.filaments) {
  720. const userOverride = filamentOverrides[req.slot_id];
  721. const isForceColor = forceColorMatch[req.slot_id] ?? false;
  722. const effectiveType = userOverride?.type ?? req.type;
  723. const effectiveColor = userOverride?.color ?? req.color;
  724. // Include slot if user changed the filament OR force_color_match is enabled
  725. if (userOverride || isForceColor) {
  726. entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor });
  727. }
  728. }
  729. } else {
  730. // Fallback: no filament requirements data — only include explicit user overrides
  731. for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
  732. const id = parseInt(slotId, 10);
  733. const isForceColor = forceColorMatch[id] ?? false;
  734. entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor });
  735. }
  736. }
  737. return entries.length > 0 ? entries : undefined;
  738. };
  739. const filamentOverridesArray = buildFilamentOverridesArray(effectiveFilamentReqs);
  740. // A plate only carries the slots it prints (#2552). Slot ids are global to the
  741. // file, so an override on slot 3 means the same filament in every plate that
  742. // uses slot 3 — the per-plate list is a subset of the shared state, not a
  743. // rewrite of it. No fallback to the whole-file list: it holds slots this plate
  744. // never prints, and submission is gated on every selected plate having answered,
  745. // so a plate is never missing here.
  746. const overridesForPlate = (plateId: number | null) =>
  747. isMultiPlateSelection && plateId !== null
  748. ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
  749. : filamentOverridesArray;
  750. // Multi-plate auto-batch: when the user adds 2+ plates from one source in
  751. // a single create submission, pre-create a PrintBatch and pass its
  752. // id to each subsequent addToQueue call so the queue UI groups them as a
  753. // collapsible batch. Only triggered for single-target submissions —
  754. // multi-printer fan-out keeps the old per-item shape.
  755. const shouldAutoBatch =
  756. mode === 'create'
  757. && platesToQueue.length > 1
  758. && (assignmentMode === 'model' || selectedPrinters.length === 1);
  759. let autoBatchId: number | null = null;
  760. if (shouldAutoBatch) {
  761. try {
  762. const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
  763. const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
  764. const batch = await api.createBatch({
  765. name: batchName,
  766. archive_id: isLibraryFile ? undefined : archiveId,
  767. library_file_id: isLibraryFile ? libraryFileId : undefined,
  768. });
  769. autoBatchId = batch.id;
  770. } catch {
  771. // Non-fatal: fall back to ungrouped items so the queue still works.
  772. autoBatchId = null;
  773. }
  774. }
  775. const asapInsertionCounts = new Map<string, number>();
  776. const applyAsapInsertion = (
  777. queueData: PrintQueueItemCreate,
  778. printerId: number | null,
  779. itemCount = 1,
  780. ) => {
  781. if (scheduleOptions.scheduleType !== 'asap') return;
  782. const scopeKey = printerId !== null ? `printer:${printerId}` : 'unassigned';
  783. const insertPosition = (asapInsertionCounts.get(scopeKey) ?? 0) + 1;
  784. queueData.insert_at_top = true;
  785. queueData.insert_position = insertPosition;
  786. asapInsertionCounts.set(scopeKey, insertPosition + itemCount - 1);
  787. };
  788. // Common queue data for create and edit modes
  789. const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
  790. const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
  791. return {
  792. printer_id: assignmentMode === 'printer' ? printerId : null,
  793. target_model: assignmentMode === 'model' ? targetModel : null,
  794. target_location: assignmentMode === 'model' ? targetLocation : null,
  795. filament_overrides: assignmentMode === 'model' ? overridesForPlate(plateId) : undefined,
  796. // Use library_file_id for library files, archive_id for archives
  797. archive_id: isLibraryFile ? undefined : archiveId,
  798. library_file_id: isLibraryFile ? libraryFileId : undefined,
  799. require_previous_success: scheduleOptions.requirePreviousSuccess,
  800. auto_off_after: scheduleOptions.autoOffAfter,
  801. gcode_injection: scheduleOptions.gcodeInjection,
  802. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  803. // When the user clicks "Print Anyway" on the frontend deficit warning,
  804. // persist that acknowledgement so the scheduler doesn't immediately
  805. // re-flag the item on its first dispatch tick (#1698-followup).
  806. skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
  807. ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
  808. plate_id: plateId,
  809. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  810. ? new Date(scheduleOptions.scheduledTime).toISOString()
  811. : undefined,
  812. ...printOptions,
  813. project_id: projectId ?? undefined,
  814. batch_id: autoBatchId ?? undefined,
  815. cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
  816. };
  817. };
  818. // Model-based assignment
  819. if (assignmentMode === 'model') {
  820. let progressCounter = 0;
  821. for (const plate of platesToQueue) {
  822. progressCounter++;
  823. setSubmitProgress({ current: progressCounter, total: totalCount });
  824. const plateId = plate ? plate.index : selectedPlate;
  825. try {
  826. if (mode === 'edit-queue-item' && !plate) {
  827. // Edit mode - update with target_model (only for single plate)
  828. const updateData: PrintQueueItemUpdate = {
  829. printer_id: null,
  830. target_model: targetModel,
  831. target_location: targetLocation,
  832. filament_overrides: filamentOverridesArray || null,
  833. require_previous_success: scheduleOptions.requirePreviousSuccess,
  834. auto_off_after: scheduleOptions.autoOffAfter,
  835. gcode_injection: scheduleOptions.gcodeInjection,
  836. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  837. ams_mapping: undefined,
  838. plate_id: plateId,
  839. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  840. ? new Date(scheduleOptions.scheduledTime).toISOString()
  841. : null,
  842. ...printOptions,
  843. };
  844. await updateQueueMutation.mutateAsync(updateData);
  845. } else {
  846. // Add-to-queue mode with model-based assignment
  847. const queueData = getQueueData(null, plateId);
  848. if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
  849. applyAsapInsertion(queueData, null, effectiveQuantity);
  850. await addToQueueMutation.mutateAsync(queueData);
  851. }
  852. results.success++;
  853. } catch (error) {
  854. results.failed++;
  855. const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
  856. results.errors.push(plateName ? `${plateName}: ${(error as Error).message}` : (error as Error).message);
  857. }
  858. }
  859. } else {
  860. // Printer-based assignment: loop through plates × printers
  861. // Compute stagger base time once before the loop
  862. const useStagger = scheduleOptions.staggerEnabled
  863. && !isEditing
  864. && selectedPrinters.length > 1;
  865. const staggerBaseTime = useStagger
  866. ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  867. ? new Date(scheduleOptions.scheduledTime).getTime()
  868. : Date.now())
  869. : 0;
  870. let progressCounter = 0;
  871. for (const plate of platesToQueue) {
  872. const plateId = plate ? plate.index : selectedPlate;
  873. for (let i = 0; i < selectedPrinters.length; i++) {
  874. const printerId = selectedPrinters[i];
  875. progressCounter++;
  876. setSubmitProgress({ current: progressCounter, total: totalCount });
  877. try {
  878. if (isEditing && progressCounter === 1) {
  879. // Edit mode - update the original queue item for the first entry
  880. const printerMapping = getMappingForPrinter(printerId, plateId);
  881. const updateData: PrintQueueItemUpdate = {
  882. printer_id: printerId,
  883. target_model: null,
  884. target_location: null,
  885. require_previous_success: scheduleOptions.requirePreviousSuccess,
  886. auto_off_after: scheduleOptions.autoOffAfter,
  887. gcode_injection: scheduleOptions.gcodeInjection,
  888. manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
  889. ams_mapping: printerMapping,
  890. plate_id: plateId,
  891. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  892. ? new Date(scheduleOptions.scheduledTime).toISOString()
  893. : null,
  894. ...printOptions,
  895. };
  896. await updateQueueMutation.mutateAsync(updateData);
  897. } else {
  898. // New print mode, staggered print, or edit mode with additional entries
  899. const queueData = getQueueData(printerId, plateId);
  900. if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
  901. applyAsapInsertion(queueData, printerId, effectiveQuantity);
  902. // Apply stagger offset for groups after the first
  903. if (useStagger) {
  904. const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
  905. if (groupIndex > 0) {
  906. const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
  907. queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
  908. }
  909. // Group 0 with ASAP: no scheduled_time (start immediately)
  910. // Group 0 with scheduled: keeps the scheduled_time from getQueueData
  911. }
  912. await addToQueueMutation.mutateAsync(queueData);
  913. }
  914. results.success++;
  915. } catch (error) {
  916. results.failed++;
  917. const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
  918. const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
  919. const label = plateName ? `${printerName} (${plateName})` : printerName;
  920. results.errors.push(`${label}: ${(error as Error).message}`);
  921. }
  922. }
  923. }
  924. }
  925. setIsSubmitting(false);
  926. // Show result toast
  927. if (results.failed === 0) {
  928. if (isEditing) {
  929. if (mode === 'edit-queue-item') {
  930. showToast('Queue item updated');
  931. }
  932. } else if (results.success === 1) {
  933. const waitForIdleToast = await asapToastShouldPromiseLaterStart();
  934. showToast(
  935. waitForIdleToast
  936. ? t('queue.printQueuedWillStartWhenIdle')
  937. : assignmentMode === 'model'
  938. ? `Queued for any ${targetModel}`
  939. : t('queue.printQueued'),
  940. );
  941. } else {
  942. const waitForIdleToast = await asapToastShouldPromiseLaterStart();
  943. showToast(
  944. waitForIdleToast
  945. ? t('queue.printQueuedWillStartWhenIdle')
  946. : t('queue.itemsQueued', { count: results.success }),
  947. );
  948. }
  949. queryClient.invalidateQueries({ queryKey: ['queue'] });
  950. onSuccess?.();
  951. onClose();
  952. } else if (results.success === 0) {
  953. showToast(`Failed: ${results.errors[0]}`, 'error');
  954. } else {
  955. showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
  956. queryClient.invalidateQueries({ queryKey: ['queue'] });
  957. }
  958. };
  959. const isPending = isSubmitting || updateQueueMutation.isPending;
  960. const canSubmit = useMemo(() => {
  961. if (isPending) return false;
  962. // Need valid printer/model selection
  963. if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
  964. if (assignmentMode === 'model' && !targetModel) return false;
  965. // For multi-plate files, need at least one plate selected
  966. if (isMultiPlate && selectedPlates.size === 0) return false;
  967. // Every selected plate has to have answered before we can queue it: a plate
  968. // still in flight would be sent with no mapping and no overrides, and one that
  969. // failed to load cannot be mapped at all. Deselect the failing plate to queue
  970. // the rest — the banner above says which state we are in.
  971. if (perPlateReqsPending || perPlateReqsFailed) return false;
  972. return true;
  973. }, [
  974. selectedPrinters.length,
  975. assignmentMode,
  976. targetModel,
  977. isMultiPlate,
  978. selectedPlates.size,
  979. isPending,
  980. perPlateReqsPending,
  981. perPlateReqsFailed,
  982. ]);
  983. // Quantity only applies for single-printer or model-based assignment (not multi-printer)
  984. const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
  985. // Clear gcode_injection if the admin removes all snippets while the modal
  986. // is open — the checkbox itself hides via hasGcodeSnippets in
  987. // ScheduleOptions, but the boolean would otherwise stay true and ship to
  988. // the API. The previous gate also reset the flag whenever effectiveQuantity
  989. // dropped to <= 1, which silently un-ticked the checkbox on every single-
  990. // print create flow (#1852). The scheduler reads item.gcode_injection per
  991. // queue item regardless of batch size, so there's no underlying reason for
  992. // the quantity-1 case to be blocked.
  993. useEffect(() => {
  994. if (mode === 'create' && scheduleOptions.gcodeInjection && !settings?.gcode_snippets) {
  995. setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false }));
  996. }
  997. }, [mode, settings?.gcode_snippets, scheduleOptions.gcodeInjection]);
  998. // Modal title and action button text based on mode
  999. const getModalConfig = () => {
  1000. if (!isEditing) {
  1001. return {
  1002. title: t('common.print'),
  1003. icon: Printer,
  1004. submitText: t('common.print'),
  1005. submitIcon: Printer,
  1006. loadingText: submitProgress.total > 1
  1007. ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
  1008. : t('queue.adding'),
  1009. };
  1010. }
  1011. // edit-queue-item mode
  1012. return {
  1013. title: t('queue.editQueueItem'),
  1014. icon: Pencil,
  1015. submitText: t('common.save'),
  1016. submitIcon: Pencil,
  1017. loadingText: submitProgress.total > 1
  1018. ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
  1019. : t('common.saving'),
  1020. };
  1021. };
  1022. const modalConfig = getModalConfig();
  1023. const TitleIcon = modalConfig.icon;
  1024. const SubmitIcon = modalConfig.submitIcon;
  1025. // Show filament mapping when:
  1026. // - Single printer selected
  1027. // - For archives: plate is selected (for multi-plate) or not required (single-plate)
  1028. // - For library files: always show (no plate selection)
  1029. const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (
  1030. isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
  1031. );
  1032. // Several plates on one printer: one mapping panel per plate, each mapping only
  1033. // the slots its own plate prints. Multi-printer fan-out would be a panel per
  1034. // plate *per printer*, so those items ship without a mapping and the scheduler
  1035. // computes one per plate when it picks the printer.
  1036. const showPerPlateFilamentMapping =
  1037. !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
  1038. // Model mode has no printer and so no trays to map onto; what it offers instead
  1039. // is the filament each slot must be printed in, which the scheduler matches
  1040. // against whatever printer of the model it picks. Needs the model's loaded
  1041. // filaments to offer as alternatives.
  1042. const showFilamentOverride =
  1043. assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
  1044. // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
  1045. // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
  1046. // the toggle without a specific printer selected. For printer-mode we rely
  1047. // on the canonical `nozzle_count` field auto-detected from MQTT.
  1048. const DUAL_NOZZLE_MODELS = useMemo(
  1049. () => new Set(['H2D', 'H2DPRO', 'H2C', 'X2D']),
  1050. [],
  1051. );
  1052. const showDualNozzleOptions = useMemo(() => {
  1053. if (assignmentMode === 'model') {
  1054. if (!targetModel) return false;
  1055. return DUAL_NOZZLE_MODELS.has(targetModel.toUpperCase().replace(/[\s-]/g, ''));
  1056. }
  1057. if (!printers || selectedPrinters.length === 0) return false;
  1058. return selectedPrinters.some(id => printers.find(p => p.id === id)?.nozzle_count === 2);
  1059. }, [assignmentMode, targetModel, printers, selectedPrinters, DUAL_NOZZLE_MODELS]);
  1060. return (
  1061. <div
  1062. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  1063. onClick={isSubmitting ? undefined : onClose}
  1064. >
  1065. <Card
  1066. className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
  1067. onClick={(e) => e.stopPropagation()}
  1068. >
  1069. <CardContent className="p-0">
  1070. {/* Header */}
  1071. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  1072. <div className="flex items-center gap-2">
  1073. <TitleIcon className="w-5 h-5 text-bambu-green" />
  1074. <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
  1075. </div>
  1076. <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
  1077. <X className="w-5 h-5" />
  1078. </Button>
  1079. </div>
  1080. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  1081. {/* Archive name */}
  1082. <p className="text-sm text-bambu-gray">
  1083. <span className="block text-bambu-gray mb-1">Print Job</span>
  1084. <span className="text-white font-medium truncate block">{archiveName}</span>
  1085. </p>
  1086. {/* Build-plate badge for the selected (or sole) plate — surfaced
  1087. early so the user knows which plate to mount before scheduling
  1088. (#1281). PlateSelector renders its own per-plate badges for
  1089. multi-plate files; this badge covers the single-plate case and
  1090. the multi-plate case where exactly one plate is selected. */}
  1091. {(() => {
  1092. if (!plates.length) return null;
  1093. const target = selectedPlate != null
  1094. ? plates.find(p => p.index === selectedPlate)
  1095. : plates[0];
  1096. const bed = getBedTypeInfo(target?.bed_type);
  1097. if (!bed) return null;
  1098. return (
  1099. <p className="flex items-center gap-1.5 text-xs text-bambu-gray -mt-2" title={bed.label}>
  1100. <img src={bed.icon} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
  1101. <span className="truncate">{bed.label}</span>
  1102. </p>
  1103. );
  1104. })()}
  1105. {/* Plate selection - first so users know filament requirements before selecting printers */}
  1106. <PlateSelector
  1107. plates={plates}
  1108. isMultiPlate={isMultiPlate}
  1109. selectedPlates={selectedPlates}
  1110. onToggle={(plateIndex) => {
  1111. setSelectedPlates(prev => {
  1112. const next = new Set(prev);
  1113. if (!isEditing) {
  1114. // Multi-select: toggle the plate
  1115. if (next.has(plateIndex)) {
  1116. next.delete(plateIndex);
  1117. } else {
  1118. next.add(plateIndex);
  1119. }
  1120. } else {
  1121. // Single-select: replace selection
  1122. next.clear();
  1123. next.add(plateIndex);
  1124. }
  1125. return next;
  1126. });
  1127. }}
  1128. onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
  1129. onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
  1130. multiSelect={!isEditing}
  1131. />
  1132. {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
  1133. {!initialSelectedPrinterIds?.length && (
  1134. <PrinterSelector
  1135. printers={printers || []}
  1136. selectedPrinterIds={selectedPrinters}
  1137. onMultiSelect={setSelectedPrinters}
  1138. isLoading={loadingPrinters}
  1139. allowMultiple={true}
  1140. showInactive={mode === 'edit-queue-item'}
  1141. disableBusy={false}
  1142. printerMappingResults={multiPrinterMapping.printerResults}
  1143. // The per-printer tray editor inside the selector maps one filament
  1144. // list onto each printer. Several plates have several lists, and a
  1145. // fan-out across printers ships no mapping at all (the scheduler maps
  1146. // each plate against the printer it picks), so the editor would be
  1147. // collecting tray choices it then throws away. Withhold its input.
  1148. filamentReqs={isMultiPlateSelection ? undefined : effectiveFilamentReqs}
  1149. onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
  1150. onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
  1151. assignmentMode={assignmentMode}
  1152. onAssignmentModeChange={setAssignmentMode}
  1153. targetModel={targetModel}
  1154. onTargetModelChange={setTargetModel}
  1155. targetLocation={targetLocation}
  1156. onTargetLocationChange={setTargetLocation}
  1157. slicedForModel={slicedForModel}
  1158. />
  1159. )}
  1160. {/* Filament override - shown in model mode when filament requirements are available */}
  1161. {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
  1162. <FilamentOverride
  1163. filamentReqs={effectiveFilamentReqs}
  1164. availableFilaments={availableFilaments!}
  1165. overrides={filamentOverrides}
  1166. onChange={setFilamentOverrides}
  1167. forceColorMatch={forceColorMatch}
  1168. onForceColorMatchChange={(slotId, value) =>
  1169. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1170. }
  1171. />
  1172. )}
  1173. {/* Filament override, one panel per selected plate. `effectiveFilamentReqs`
  1174. is keyed on `selectedPlate`, which is null as soon as two plates are
  1175. picked, so a multi-plate selection used to render this panel from
  1176. whatever the whole-file query had left in the cache — the union of every
  1177. plate's filaments, or nothing at all once the plates query was warm and
  1178. the whole-file query therefore never ran, which is why the section
  1179. vanished on the second open of the dialog (#2552). */}
  1180. {showFilamentOverride && isMultiPlateSelection && selectedPlateIds.map((plateId, idx) => {
  1181. const plate = plates.find((p) => p.index === plateId);
  1182. const plateReqs = perPlateReqs.get(plateId);
  1183. if (!plateReqs) return null;
  1184. return (
  1185. <FilamentOverride
  1186. key={plateId}
  1187. plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
  1188. showHint={idx === 0}
  1189. filamentReqs={plateReqs}
  1190. availableFilaments={availableFilaments!}
  1191. overrides={filamentOverrides}
  1192. onChange={setFilamentOverrides}
  1193. forceColorMatch={forceColorMatch}
  1194. onForceColorMatchChange={(slotId, value) =>
  1195. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1196. }
  1197. />
  1198. );
  1199. })}
  1200. {/* Compatibility warning when sliced model doesn't match selected printer */}
  1201. {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
  1202. const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
  1203. if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
  1204. return (
  1205. <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">
  1206. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
  1207. <span className="text-sm text-yellow-700 dark:text-yellow-400">
  1208. File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
  1209. </span>
  1210. </div>
  1211. );
  1212. }
  1213. return null;
  1214. })()}
  1215. {/* Warning when archive data couldn't be loaded */}
  1216. {archiveDataMissing && (
  1217. <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">
  1218. <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  1219. <p className="text-orange-700 dark:text-orange-400">
  1220. Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
  1221. </p>
  1222. </div>
  1223. )}
  1224. {/* A selected plate whose filaments could not be read cannot be mapped and
  1225. cannot carry its forced colours, so it is not queued silently — say so
  1226. and hold the button until the plate is deselected. */}
  1227. {perPlateReqsFailed && (
  1228. <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">
  1229. <AlertCircle className="w-4 h-4 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  1230. <p className="text-orange-700 dark:text-orange-400">
  1231. {t(
  1232. 'printModal.plateFilamentsUnreadable',
  1233. "The filaments of a selected plate could not be read, so it can't be mapped. Deselect it to queue the others.",
  1234. )}
  1235. </p>
  1236. </div>
  1237. )}
  1238. {/* Filament mapping - only show when single printer selected */}
  1239. {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
  1240. <FilamentMapping
  1241. printerId={effectivePrinterId!}
  1242. filamentReqs={effectiveFilamentReqs}
  1243. manualMappings={manualMappings}
  1244. onManualMappingChange={setManualMappings}
  1245. defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
  1246. currencySymbol={currencySymbol}
  1247. defaultCostPerKg={defaultCostPerKg}
  1248. forceColorMatch={forceColorMatch}
  1249. onForceColorMatchChange={(slotId, value) =>
  1250. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1251. }
  1252. />
  1253. )}
  1254. {/* Filament mapping, one panel per selected plate — each plate is its
  1255. own print with its own slots, so it gets its own AMS mapping. */}
  1256. {showPerPlateFilamentMapping && !archiveDataMissing && selectedPlateIds.map((plateId) => {
  1257. const plate = plates.find((p) => p.index === plateId);
  1258. const plateReqs = perPlateReqs.get(plateId);
  1259. if (!plateReqs) return null;
  1260. return (
  1261. <FilamentMapping
  1262. key={plateId}
  1263. printerId={effectivePrinterId!}
  1264. plateLabel={plate?.name || t('printModal.plateN', 'Plate {{n}}', { n: plateId })}
  1265. filamentReqs={plateReqs}
  1266. manualMappings={manualMappingsByPlate[plateId] ?? {}}
  1267. onManualMappingChange={(mappings) =>
  1268. setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: mappings }))
  1269. }
  1270. defaultExpanded={false}
  1271. currencySymbol={currencySymbol}
  1272. defaultCostPerKg={defaultCostPerKg}
  1273. forceColorMatch={forceColorMatch}
  1274. onForceColorMatchChange={(slotId, value) =>
  1275. setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
  1276. }
  1277. />
  1278. );
  1279. })}
  1280. {/* Print options */}
  1281. {(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
  1282. <PrintOptionsPanel
  1283. options={printOptions}
  1284. onChange={setPrintOptions}
  1285. defaultExpanded={!!initialSelectedPrinterIds?.length}
  1286. showDualNozzleOptions={showDualNozzleOptions}
  1287. />
  1288. )}
  1289. {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
  1290. {mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
  1291. <div className="flex items-center gap-3">
  1292. <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
  1293. {t('queue.quantity', 'Quantity')}
  1294. </label>
  1295. <input
  1296. id="printQuantity"
  1297. type="number"
  1298. min={1}
  1299. max={999}
  1300. value={quantity}
  1301. onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}
  1302. 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"
  1303. />
  1304. {quantity > 1 && (
  1305. <span className="text-xs text-bambu-gray">
  1306. {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
  1307. </span>
  1308. )}
  1309. </div>
  1310. )}
  1311. {/* Schedule options */}
  1312. <ScheduleOptionsPanel
  1313. options={scheduleOptions}
  1314. onChange={setScheduleOptions}
  1315. dateFormat={settings?.date_format || 'system'}
  1316. timeFormat={settings?.time_format || 'system'}
  1317. canControlPrinter={hasPermission('printers:control')}
  1318. showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
  1319. printerCount={selectedPrinters.length}
  1320. hasGcodeSnippets={!!settings?.gcode_snippets}
  1321. />
  1322. {/* Error message */}
  1323. {updateQueueMutation.isError && (
  1324. <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">
  1325. {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
  1326. </div>
  1327. )}
  1328. {/* Actions */}
  1329. <div className="flex gap-3 pt-2">
  1330. <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
  1331. Cancel
  1332. </Button>
  1333. <Button
  1334. type="submit"
  1335. disabled={!canSubmit}
  1336. className="flex-1"
  1337. >
  1338. {isPending ? (
  1339. <>
  1340. <Loader2 className="w-4 h-4 animate-spin" />
  1341. {modalConfig.loadingText}
  1342. </>
  1343. ) : (
  1344. <>
  1345. <SubmitIcon className="w-4 h-4" />
  1346. {modalConfig.submitText}
  1347. </>
  1348. )}
  1349. </Button>
  1350. </div>
  1351. </form>
  1352. </CardContent>
  1353. </Card>
  1354. {filamentWarningItems && filamentWarningItems.length > 0 && (
  1355. <ConfirmModal
  1356. title={t('printModal.insufficientFilamentTitle')}
  1357. message={filamentWarningMessage}
  1358. confirmText={t('printModal.printAnyway')}
  1359. cancelText={t('common.cancel')}
  1360. variant="warning"
  1361. onConfirm={() => {
  1362. setFilamentWarningItems(null);
  1363. void handleSubmit(undefined, { skipFilamentCheck: true });
  1364. }}
  1365. onCancel={() => setFilamentWarningItems(null)}
  1366. />
  1367. )}
  1368. </div>
  1369. );
  1370. }
  1371. // Re-export types for convenience
  1372. export type { PrintModalMode, PrintModalProps } from './types';