index.tsx 60 KB

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