index.tsx 30 KB

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