index.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  1. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  2. import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
  3. import { useEffect, useMemo, useState } from 'react';
  4. import { useTranslation } from 'react-i18next';
  5. import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
  6. import { api } from '../../api/client';
  7. import { useToast } from '../../contexts/ToastContext';
  8. import { useFilamentMapping } from '../../hooks/useFilamentMapping';
  9. import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
  10. import { isPlaceholderDate } from '../../utils/amsHelpers';
  11. import { getCurrencySymbol } from '../../utils/currency';
  12. import { toDateTimeLocalValue } from '../../utils/date';
  13. import { Button } from '../Button';
  14. import { Card, CardContent } from '../Card';
  15. import { FilamentMapping } from './FilamentMapping';
  16. import { FilamentOverride } from './FilamentOverride';
  17. import { PlateSelector } from './PlateSelector';
  18. import { PrinterSelector } from './PrinterSelector';
  19. import { PrintOptionsPanel } from './PrintOptions';
  20. import { ScheduleOptionsPanel } from './ScheduleOptions';
  21. import type {
  22. AssignmentMode,
  23. PrintModalProps,
  24. PrintOptions,
  25. ScheduleOptions,
  26. ScheduleType,
  27. } from './types';
  28. import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
  29. /**
  30. * Unified PrintModal component that handles three modes:
  31. * - 'reprint': Immediate print from archive or library file (supports multi-printer)
  32. * - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
  33. * - 'edit-queue-item': Edit existing queue item (supports multi-printer)
  34. *
  35. * Both archiveId and libraryFileId are supported. Library files can be printed immediately
  36. * or added to queue (archive is created at print start time, not when queued).
  37. */
  38. export function PrintModal({
  39. mode,
  40. archiveId,
  41. libraryFileId,
  42. archiveName,
  43. queueItem,
  44. onClose,
  45. onSuccess,
  46. }: PrintModalProps) {
  47. const { t } = useTranslation();
  48. const queryClient = useQueryClient();
  49. const { showToast } = useToast();
  50. // Determine if we're printing a library file
  51. const isLibraryFile = !!libraryFileId && !archiveId;
  52. // Multiple printer selection (used for all modes now)
  53. const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
  54. // Initialize with the queue item's printer if editing
  55. if (mode === 'edit-queue-item' && queueItem?.printer_id) {
  56. return [queueItem.printer_id];
  57. }
  58. return [];
  59. });
  60. const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
  61. if (mode === 'edit-queue-item' && queueItem) {
  62. return queueItem.plate_id;
  63. }
  64. return null;
  65. });
  66. const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
  67. if (mode === 'edit-queue-item' && queueItem) {
  68. return {
  69. bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
  70. flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
  71. vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
  72. layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
  73. timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
  74. };
  75. }
  76. return DEFAULT_PRINT_OPTIONS;
  77. });
  78. const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
  79. if (mode === 'edit-queue-item' && queueItem) {
  80. let scheduleType: ScheduleType = 'asap';
  81. if (queueItem.manual_start) {
  82. scheduleType = 'manual';
  83. } else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  84. scheduleType = 'scheduled';
  85. }
  86. let scheduledTime = '';
  87. if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
  88. const date = new Date(queueItem.scheduled_time);
  89. // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
  90. scheduledTime = toDateTimeLocalValue(date);
  91. }
  92. return {
  93. scheduleType,
  94. scheduledTime,
  95. requirePreviousSuccess: queueItem.require_previous_success,
  96. autoOffAfter: queueItem.auto_off_after,
  97. };
  98. }
  99. return DEFAULT_SCHEDULE_OPTIONS;
  100. });
  101. // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
  102. const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
  103. if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
  104. const mappings: Record<number, number> = {};
  105. queueItem.ams_mapping.forEach((globalTrayId, idx) => {
  106. if (globalTrayId !== -1) {
  107. mappings[idx + 1] = globalTrayId;
  108. }
  109. });
  110. return mappings;
  111. }
  112. return {};
  113. });
  114. // Per-printer override configs (for multi-printer selection)
  115. const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
  116. // Assignment mode: 'printer' (specific) or 'model' (any of model)
  117. const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
  118. // Initialize from queue item if editing with target_model
  119. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  120. return 'model';
  121. }
  122. return 'printer';
  123. });
  124. // Target model for model-based assignment
  125. const [targetModel, setTargetModel] = useState<string | null>(() => {
  126. if (mode === 'edit-queue-item' && queueItem?.target_model) {
  127. return queueItem.target_model;
  128. }
  129. return null;
  130. });
  131. // Target location for model-based assignment (optional filter)
  132. const [targetLocation, setTargetLocation] = useState<string | null>(() => {
  133. if (mode === 'edit-queue-item' && queueItem?.target_location) {
  134. return queueItem.target_location;
  135. }
  136. return null;
  137. });
  138. // Filament overrides for model-based assignment: slot_id -> {type, color}
  139. const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
  140. if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
  141. const overrides: Record<number, { type: string; color: string }> = {};
  142. for (const o of queueItem.filament_overrides) {
  143. overrides[o.slot_id] = { type: o.type, color: o.color };
  144. }
  145. return overrides;
  146. }
  147. return {};
  148. });
  149. // Track initial values for clearing mappings on change (edit mode only)
  150. const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
  151. const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
  152. // Submission state for multi-printer
  153. const [isSubmitting, setIsSubmitting] = useState(false);
  154. const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
  155. // Track which printers have had the "Expand custom mapping by default" setting applied
  156. // This ensures the setting only affects initial state, not preventing unchecking
  157. const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
  158. // Printer counts and effective printer for filament mapping
  159. const effectivePrinterCount = selectedPrinters.length;
  160. // For filament mapping, use first selected printer (mapping applies to all)
  161. const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
  162. // Queries
  163. const { data: settings } = useQuery({
  164. queryKey: ['settings'],
  165. queryFn: api.getSettings,
  166. });
  167. const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
  168. const defaultCostPerKg = settings?.default_filament_cost ?? 0;
  169. const { data: printers, isLoading: loadingPrinters } = useQuery({
  170. queryKey: ['printers'],
  171. queryFn: api.getPrinters,
  172. });
  173. // Fetch archive details to get sliced_for_model
  174. const { data: archiveDetails } = useQuery({
  175. queryKey: ['archive', archiveId],
  176. queryFn: () => api.getArchive(archiveId!),
  177. enabled: !!archiveId && !isLibraryFile,
  178. });
  179. // Fetch library file details to get sliced_for_model
  180. const { data: libraryFileDetails } = useQuery({
  181. queryKey: ['library-file', libraryFileId],
  182. queryFn: () => api.getLibraryFile(libraryFileId!),
  183. enabled: isLibraryFile && !!libraryFileId,
  184. });
  185. // Get sliced_for_model from archive or library file
  186. const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
  187. // Fetch plates for archives
  188. const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
  189. queryKey: ['archive-plates', archiveId],
  190. queryFn: () => api.getArchivePlates(archiveId!),
  191. enabled: !!archiveId && !isLibraryFile,
  192. retry: false,
  193. });
  194. // Fetch plates for library files
  195. const { data: libraryPlatesData } = useQuery({
  196. queryKey: ['library-file-plates', libraryFileId],
  197. queryFn: () => api.getLibraryFilePlates(libraryFileId!),
  198. enabled: isLibraryFile && !!libraryFileId,
  199. });
  200. // Combine plates data from either source
  201. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  202. // Fetch filament requirements for archives
  203. const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
  204. queryKey: ['archive-filaments', archiveId, selectedPlate],
  205. queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
  206. enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
  207. retry: false,
  208. });
  209. // Fetch filament requirements for library files (with plate support)
  210. const { data: libraryFilamentReqs } = useQuery({
  211. queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
  212. queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
  213. enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
  214. });
  215. // Track if archive data couldn't be loaded (archive deleted or file missing)
  216. const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
  217. // Combine filament requirements from either source
  218. const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
  219. const selectedPlateName = useMemo(() => {
  220. if (selectedPlate === null || !platesData?.plates?.length) {
  221. return undefined;
  222. }
  223. return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
  224. }, [platesData, selectedPlate]);
  225. // Fetch available filaments for model-based assignment (for filament override UI)
  226. const { data: availableFilaments } = useQuery({
  227. queryKey: ['available-filaments', targetModel, targetLocation],
  228. queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
  229. enabled: assignmentMode === 'model' && !!targetModel,
  230. });
  231. // Only fetch printer status when single printer selected (for filament mapping)
  232. const { data: printerStatus } = useQuery({
  233. queryKey: ['printer-status', effectivePrinterId],
  234. queryFn: () => api.getPrinterStatus(effectivePrinterId!),
  235. enabled: !!effectivePrinterId,
  236. });
  237. // Get AMS mapping from hook (only when single printer selected)
  238. const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
  239. // Multi-printer filament mapping (for per-printer configuration)
  240. const multiPrinterMapping = useMultiPrinterFilamentMapping(
  241. selectedPrinters,
  242. printers,
  243. effectiveFilamentReqs,
  244. manualMappings,
  245. perPrinterConfigs,
  246. setPerPrinterConfigs
  247. );
  248. // Auto-select first plate when plates load (single or multi-plate)
  249. useEffect(() => {
  250. if (platesData?.plates && platesData.plates.length >= 1 && !selectedPlate) {
  251. setSelectedPlate(platesData.plates[0].index);
  252. }
  253. }, [platesData, selectedPlate]);
  254. // Auto-select first printer when only one available
  255. useEffect(() => {
  256. // Skip auto-select for edit mode (already initialized from queueItem)
  257. if (mode === 'edit-queue-item') return;
  258. const activePrinters = printers?.filter(p => p.is_active) || [];
  259. if (activePrinters.length === 1 && selectedPrinters.length === 0) {
  260. setSelectedPrinters([activePrinters[0].id]);
  261. }
  262. }, [mode, printers, selectedPrinters.length]);
  263. // Clear manual mappings and per-printer configs when printer or plate changes
  264. useEffect(() => {
  265. if (mode === 'edit-queue-item') {
  266. // For edit mode, clear mappings if printer selection or plate changed from initial
  267. const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
  268. if (printersChanged || selectedPlate !== initialPlateId) {
  269. setManualMappings({});
  270. setPerPrinterConfigs({});
  271. setInitialExpandApplied(new Set());
  272. }
  273. } else {
  274. setManualMappings({});
  275. setPerPrinterConfigs({});
  276. setInitialExpandApplied(new Set());
  277. }
  278. }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
  279. // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)
  280. const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
  281. const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
  282. useEffect(() => {
  283. if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {
  284. setPrevTargetModel(targetModel);
  285. setPrevPlateForOverrides(selectedPlate);
  286. // Don't clear on initial render in edit mode (values are initialized from queueItem)
  287. if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
  288. setFilamentOverrides({});
  289. }
  290. }
  291. }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
  292. // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
  293. // Only applies once per printer on initial selection, not when user unchecks
  294. useEffect(() => {
  295. if (!settings?.per_printer_mapping_expanded) return;
  296. if (selectedPrinters.length <= 1) return;
  297. // Only auto-configure printers that:
  298. // 1. Haven't had initial expand applied yet
  299. // 2. Have their status loaded (so auto-configure will actually work)
  300. const printersReadyForExpand = selectedPrinters.filter(printerId => {
  301. if (initialExpandApplied.has(printerId)) return false;
  302. // Check if this printer has status loaded
  303. const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);
  304. return result && result.status && !result.isLoading;
  305. });
  306. if (printersReadyForExpand.length > 0) {
  307. // Mark these printers as having been initially expanded
  308. setInitialExpandApplied(prev => {
  309. const next = new Set(prev);
  310. printersReadyForExpand.forEach(id => next.add(id));
  311. return next;
  312. });
  313. // Auto-configure printers
  314. printersReadyForExpand.forEach(printerId => {
  315. multiPrinterMapping.autoConfigurePrinter(printerId);
  316. });
  317. }
  318. }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);
  319. // Close on Escape key
  320. useEffect(() => {
  321. const handleKeyDown = (e: KeyboardEvent) => {
  322. if (e.key === 'Escape' && !isSubmitting) onClose();
  323. };
  324. window.addEventListener('keydown', handleKeyDown);
  325. return () => window.removeEventListener('keydown', handleKeyDown);
  326. }, [onClose, isSubmitting]);
  327. const isMultiPlate = platesData?.is_multi_plate ?? false;
  328. const plates = platesData?.plates ?? [];
  329. // Add to queue mutation (single printer)
  330. const addToQueueMutation = useMutation({
  331. mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
  332. });
  333. // Update queue item mutation
  334. const updateQueueMutation = useMutation({
  335. mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
  336. onSuccess: () => {
  337. queryClient.invalidateQueries({ queryKey: ['queue'] });
  338. showToast('Queue item updated');
  339. onSuccess?.();
  340. onClose();
  341. },
  342. onError: (error: Error) => {
  343. showToast(error.message || 'Failed to update queue item', 'error');
  344. },
  345. });
  346. const handleSubmit = async (e?: React.FormEvent) => {
  347. e?.preventDefault();
  348. // Validate printer/model selection
  349. if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
  350. showToast('Please select at least one printer', 'error');
  351. return;
  352. }
  353. if (assignmentMode === 'model' && !targetModel) {
  354. showToast('Please select a target printer model', 'error');
  355. return;
  356. }
  357. setIsSubmitting(true);
  358. // For model-based assignment, we just make one API call
  359. const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
  360. setSubmitProgress({ current: 0, total: totalCount });
  361. const results: { success: number; failed: number; errors: string[] } = {
  362. success: 0,
  363. failed: 0,
  364. errors: [],
  365. };
  366. // Get mapping for a specific printer (per-printer override or default)
  367. const getMappingForPrinter = (printerId: number): number[] | undefined => {
  368. // For multi-printer selection, check if this printer has an override
  369. if (selectedPrinters.length > 1) {
  370. const printerConfig = perPrinterConfigs[printerId];
  371. if (printerConfig && !printerConfig.useDefault) {
  372. return multiPrinterMapping.getFinalMapping(printerId);
  373. }
  374. }
  375. return amsMapping;
  376. };
  377. // Convert filament overrides from Record to array format for API
  378. const filamentOverridesArray = Object.keys(filamentOverrides).length > 0
  379. ? Object.entries(filamentOverrides).map(([slotId, { type, color }]) => ({
  380. slot_id: parseInt(slotId, 10),
  381. type,
  382. color,
  383. }))
  384. : undefined;
  385. // Common queue data for add-to-queue and edit modes
  386. const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
  387. printer_id: assignmentMode === 'printer' ? printerId : null,
  388. target_model: assignmentMode === 'model' ? targetModel : null,
  389. target_location: assignmentMode === 'model' ? targetLocation : null,
  390. filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,
  391. // Use library_file_id for library files, archive_id for archives
  392. archive_id: isLibraryFile ? undefined : archiveId,
  393. library_file_id: isLibraryFile ? libraryFileId : undefined,
  394. require_previous_success: scheduleOptions.requirePreviousSuccess,
  395. auto_off_after: scheduleOptions.autoOffAfter,
  396. manual_start: scheduleOptions.scheduleType === 'manual',
  397. ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
  398. plate_id: selectedPlate,
  399. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  400. ? new Date(scheduleOptions.scheduledTime).toISOString()
  401. : undefined,
  402. ...printOptions,
  403. });
  404. // Model-based assignment: single API call
  405. if (assignmentMode === 'model') {
  406. setSubmitProgress({ current: 1, total: 1 });
  407. try {
  408. if (mode === 'reprint') {
  409. // Model-based reprint not supported (need specific printer for immediate print)
  410. showToast('Model-based assignment only works with queue mode', 'error');
  411. setIsSubmitting(false);
  412. return;
  413. } else if (mode === 'edit-queue-item') {
  414. // Edit mode - update with target_model
  415. const updateData: PrintQueueItemUpdate = {
  416. printer_id: null,
  417. target_model: targetModel,
  418. target_location: targetLocation,
  419. filament_overrides: filamentOverridesArray || null,
  420. require_previous_success: scheduleOptions.requirePreviousSuccess,
  421. auto_off_after: scheduleOptions.autoOffAfter,
  422. manual_start: scheduleOptions.scheduleType === 'manual',
  423. ams_mapping: undefined,
  424. plate_id: selectedPlate,
  425. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  426. ? new Date(scheduleOptions.scheduledTime).toISOString()
  427. : null,
  428. ...printOptions,
  429. };
  430. await updateQueueMutation.mutateAsync(updateData);
  431. } else {
  432. // Add-to-queue mode with model-based assignment
  433. await addToQueueMutation.mutateAsync(getQueueData(null));
  434. }
  435. results.success++;
  436. } catch (error) {
  437. results.failed++;
  438. results.errors.push((error as Error).message);
  439. }
  440. } else {
  441. // Printer-based assignment: loop through selected printers
  442. for (let i = 0; i < selectedPrinters.length; i++) {
  443. const printerId = selectedPrinters[i];
  444. setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
  445. try {
  446. if (mode === 'reprint') {
  447. // Reprint mode - start print immediately
  448. const printerMapping = getMappingForPrinter(printerId);
  449. if (isLibraryFile) {
  450. await api.printLibraryFile(libraryFileId!, printerId, {
  451. plate_id: selectedPlate ?? undefined,
  452. plate_name: selectedPlateName,
  453. ams_mapping: printerMapping,
  454. ...printOptions,
  455. });
  456. } else {
  457. await api.reprintArchive(archiveId!, printerId, {
  458. plate_id: selectedPlate ?? undefined,
  459. plate_name: selectedPlateName,
  460. ams_mapping: printerMapping,
  461. ...printOptions,
  462. });
  463. }
  464. } else if (mode === 'edit-queue-item' && i === 0) {
  465. // Edit mode - update the original queue item for the first printer
  466. const printerMapping = getMappingForPrinter(printerId);
  467. const updateData: PrintQueueItemUpdate = {
  468. printer_id: printerId,
  469. target_model: null,
  470. target_location: null,
  471. require_previous_success: scheduleOptions.requirePreviousSuccess,
  472. auto_off_after: scheduleOptions.autoOffAfter,
  473. manual_start: scheduleOptions.scheduleType === 'manual',
  474. ams_mapping: printerMapping,
  475. plate_id: selectedPlate,
  476. scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
  477. ? new Date(scheduleOptions.scheduledTime).toISOString()
  478. : null,
  479. ...printOptions,
  480. };
  481. await updateQueueMutation.mutateAsync(updateData);
  482. } else {
  483. // Add-to-queue mode OR edit mode with additional printers
  484. await addToQueueMutation.mutateAsync(getQueueData(printerId));
  485. }
  486. results.success++;
  487. } catch (error) {
  488. results.failed++;
  489. const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
  490. results.errors.push(`${printerName}: ${(error as Error).message}`);
  491. }
  492. }
  493. }
  494. setIsSubmitting(false);
  495. // Show result toast (skip for reprint mode — the dispatch toast handles it)
  496. if (results.failed === 0) {
  497. if (mode !== 'reprint') {
  498. if (assignmentMode === 'model') {
  499. showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
  500. } else {
  501. if (mode === 'edit-queue-item') {
  502. showToast('Queue item updated');
  503. } else if (results.success === 1) {
  504. showToast('Print queued for printer');
  505. } else {
  506. showToast(`Print queued for ${results.success} printers`);
  507. }
  508. }
  509. }
  510. queryClient.invalidateQueries({ queryKey: ['queue'] });
  511. onSuccess?.();
  512. onClose();
  513. } else if (results.success === 0) {
  514. showToast(`Failed: ${results.errors[0]}`, 'error');
  515. } else {
  516. showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
  517. queryClient.invalidateQueries({ queryKey: ['queue'] });
  518. }
  519. };
  520. const isPending = isSubmitting || updateQueueMutation.isPending;
  521. const canSubmit = useMemo(() => {
  522. if (isPending) return false;
  523. // Need valid printer/model selection
  524. if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
  525. if (assignmentMode === 'model' && !targetModel) return false;
  526. // Model-based assignment only works in queue modes (not immediate reprint)
  527. if (assignmentMode === 'model' && mode === 'reprint') return false;
  528. // For multi-plate files, need a selected plate
  529. if (isMultiPlate && !selectedPlate) return false;
  530. return true;
  531. }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending]);
  532. // Modal title and action button text based on mode
  533. const getModalConfig = () => {
  534. const printerCount = selectedPrinters.length;
  535. if (mode === 'reprint') {
  536. return {
  537. title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
  538. icon: Printer,
  539. submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
  540. submitIcon: Printer,
  541. loadingText: submitProgress.total > 1
  542. ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
  543. : t('queue.sending'),
  544. };
  545. }
  546. if (mode === 'add-to-queue') {
  547. return {
  548. title: t('queue.schedulePrint'),
  549. icon: Calendar,
  550. submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
  551. submitIcon: Calendar,
  552. loadingText: submitProgress.total > 1
  553. ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
  554. : t('queue.adding'),
  555. };
  556. }
  557. // edit-queue-item mode
  558. return {
  559. title: t('queue.editQueueItem'),
  560. icon: Pencil,
  561. submitText: t('common.save'),
  562. submitIcon: Pencil,
  563. loadingText: submitProgress.total > 1
  564. ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
  565. : t('common.saving'),
  566. };
  567. };
  568. const modalConfig = getModalConfig();
  569. const TitleIcon = modalConfig.icon;
  570. const SubmitIcon = modalConfig.submitIcon;
  571. // Show filament mapping when:
  572. // - Single printer selected
  573. // - For archives: plate is selected (for multi-plate) or not required (single-plate)
  574. // - For library files: always show (no plate selection)
  575. const showFilamentMapping = effectivePrinterId && (
  576. isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
  577. );
  578. return (
  579. <div
  580. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  581. onClick={isSubmitting ? undefined : onClose}
  582. >
  583. <Card
  584. className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
  585. onClick={(e) => e.stopPropagation()}
  586. >
  587. <CardContent className={mode === 'reprint' ? '' : 'p-0'}>
  588. {/* Header */}
  589. <div
  590. className={`flex items-center justify-between ${
  591. mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
  592. }`}
  593. >
  594. <div className="flex items-center gap-2">
  595. <TitleIcon className="w-5 h-5 text-bambu-green" />
  596. <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
  597. </div>
  598. <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
  599. <X className="w-5 h-5" />
  600. </Button>
  601. </div>
  602. <form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
  603. {/* Archive name */}
  604. <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
  605. {mode === 'reprint' ? (
  606. <>
  607. Send <span className="text-white">{archiveName}</span> to printer(s)
  608. </>
  609. ) : (
  610. <>
  611. <span className="block text-bambu-gray mb-1">Print Job</span>
  612. <span className="text-white font-medium truncate block">{archiveName}</span>
  613. </>
  614. )}
  615. </p>
  616. {/* Plate selection - first so users know filament requirements before selecting printers */}
  617. <PlateSelector
  618. plates={plates}
  619. isMultiPlate={isMultiPlate}
  620. selectedPlate={selectedPlate}
  621. onSelect={setSelectedPlate}
  622. />
  623. {/* Printer selection with per-printer mapping */}
  624. <PrinterSelector
  625. printers={printers || []}
  626. selectedPrinterIds={selectedPrinters}
  627. onMultiSelect={setSelectedPrinters}
  628. isLoading={loadingPrinters}
  629. allowMultiple={true}
  630. showInactive={mode === 'edit-queue-item'}
  631. printerMappingResults={multiPrinterMapping.printerResults}
  632. filamentReqs={effectiveFilamentReqs}
  633. onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
  634. onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
  635. assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
  636. onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
  637. targetModel={targetModel}
  638. onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
  639. targetLocation={targetLocation}
  640. onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
  641. slicedForModel={slicedForModel}
  642. />
  643. {/* Filament override - shown in model mode when filament requirements are available */}
  644. {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
  645. <FilamentOverride
  646. filamentReqs={effectiveFilamentReqs}
  647. availableFilaments={availableFilaments}
  648. overrides={filamentOverrides}
  649. onChange={setFilamentOverrides}
  650. />
  651. )}
  652. {/* Compatibility warning when sliced model doesn't match selected printer */}
  653. {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
  654. const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
  655. if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
  656. return (
  657. <div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
  658. <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
  659. <span className="text-sm text-yellow-400">
  660. File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
  661. </span>
  662. </div>
  663. );
  664. }
  665. return null;
  666. })()}
  667. {/* Warning when archive data couldn't be loaded */}
  668. {archiveDataMissing && (
  669. <div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
  670. <AlertCircle className="w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0" />
  671. <p className="text-orange-400">
  672. Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
  673. </p>
  674. </div>
  675. )}
  676. {/* Filament mapping - only show when single printer selected */}
  677. {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
  678. <FilamentMapping
  679. printerId={effectivePrinterId!}
  680. filamentReqs={effectiveFilamentReqs}
  681. manualMappings={manualMappings}
  682. onManualMappingChange={setManualMappings}
  683. defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
  684. currencySymbol={currencySymbol}
  685. defaultCostPerKg={defaultCostPerKg}
  686. />
  687. )}
  688. {/* Print options */}
  689. {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
  690. <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
  691. )}
  692. {/* Schedule options - only for queue modes */}
  693. {mode !== 'reprint' && (
  694. <ScheduleOptionsPanel
  695. options={scheduleOptions}
  696. onChange={setScheduleOptions}
  697. dateFormat={settings?.date_format || 'system'}
  698. timeFormat={settings?.time_format || 'system'}
  699. />
  700. )}
  701. {/* Error message */}
  702. {updateQueueMutation.isError && (
  703. <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  704. {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
  705. </div>
  706. )}
  707. {/* Actions */}
  708. <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
  709. <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
  710. Cancel
  711. </Button>
  712. <Button
  713. type="submit"
  714. disabled={!canSubmit}
  715. className="flex-1"
  716. >
  717. {isPending ? (
  718. <>
  719. <Loader2 className="w-4 h-4 animate-spin" />
  720. {modalConfig.loadingText}
  721. </>
  722. ) : (
  723. <>
  724. <SubmitIcon className="w-4 h-4" />
  725. {modalConfig.submitText}
  726. </>
  727. )}
  728. </Button>
  729. </div>
  730. </form>
  731. </CardContent>
  732. </Card>
  733. </div>
  734. );
  735. }
  736. // Re-export types for convenience
  737. export type { PrintModalMode, PrintModalProps } from './types';