index.tsx 69 KB

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