QueuePage.tsx 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600
  1. import { useState, useMemo, useEffect, useCallback } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { Link } from 'react-router-dom';
  5. import {
  6. DndContext,
  7. closestCenter,
  8. KeyboardSensor,
  9. PointerSensor,
  10. useSensor,
  11. useSensors,
  12. } from '@dnd-kit/core';
  13. import type { DragEndEvent } from '@dnd-kit/core';
  14. import {
  15. arrayMove,
  16. SortableContext,
  17. sortableKeyboardCoordinates,
  18. useSortable,
  19. verticalListSortingStrategy,
  20. } from '@dnd-kit/sortable';
  21. import { CSS } from '@dnd-kit/utilities';
  22. import {
  23. Clock,
  24. Trash2,
  25. Play,
  26. X,
  27. CheckCircle,
  28. XCircle,
  29. AlertCircle,
  30. Calendar,
  31. Printer,
  32. GripVertical,
  33. SkipForward,
  34. ExternalLink,
  35. Power,
  36. StopCircle,
  37. Pencil,
  38. RefreshCw,
  39. Timer,
  40. ListOrdered,
  41. Layers,
  42. ArrowUp,
  43. ArrowDown,
  44. Hand,
  45. Check,
  46. CheckSquare,
  47. Square,
  48. User,
  49. Pause,
  50. Weight,
  51. ChevronDown,
  52. ChevronRight,
  53. List,
  54. GanttChart,
  55. Code,
  56. Snail,
  57. } from 'lucide-react';
  58. import { api, ApiError } from '../api/client';
  59. import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
  60. import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
  61. import { Card } from '../components/Card';
  62. import { Button } from '../components/Button';
  63. import { ConfirmModal } from '../components/ConfirmModal';
  64. import { PrintModal } from '../components/PrintModal';
  65. import { useToast } from '../contexts/ToastContext';
  66. import { useAuth } from '../contexts/AuthContext';
  67. import { QueueStatsBar } from '../components/QueueStatsBar';
  68. import { CompactHistoryRow } from '../components/CompactHistoryRow';
  69. import { QueueTimelineView } from '../components/QueueTimelineView';
  70. function formatWeight(g: number, useKg = false): string {
  71. if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
  72. return `${Math.round(g)}g`;
  73. }
  74. function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
  75. // Special case: pending with waiting_reason shows as "Waiting"
  76. if (status === 'pending' && waitingReason) {
  77. return (
  78. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
  79. <Clock className="w-3.5 h-3.5" />
  80. {t('queue.status.waiting')}
  81. </span>
  82. );
  83. }
  84. // Special case: printing but printer is paused
  85. if (status === 'printing' && printerState === 'PAUSE') {
  86. return (
  87. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
  88. <Pause className="w-3.5 h-3.5" />
  89. {t('queue.status.paused')}
  90. </span>
  91. );
  92. }
  93. const config = {
  94. pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
  95. printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
  96. completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
  97. failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
  98. skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
  99. cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
  100. };
  101. const { icon: Icon, color, label } = config[status];
  102. return (
  103. <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
  104. <Icon className="w-3.5 h-3.5" />
  105. {label}
  106. </span>
  107. );
  108. }
  109. // Bulk edit modal for multiple queue items
  110. function BulkEditModal({
  111. selectedCount,
  112. printers,
  113. onSave,
  114. onClose,
  115. isSaving,
  116. canControlPrinter,
  117. t,
  118. }: {
  119. selectedCount: number;
  120. printers: { id: number; name: string }[];
  121. onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
  122. onClose: () => void;
  123. isSaving: boolean;
  124. canControlPrinter: boolean;
  125. t: (key: string, options?: Record<string, unknown>) => string;
  126. }) {
  127. const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
  128. const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
  129. const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
  130. const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
  131. const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');
  132. const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');
  133. const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
  134. const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
  135. const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
  136. const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
  137. const handleSave = () => {
  138. const data: Partial<PrintQueueBulkUpdate> = {};
  139. if (printerId !== 'unchanged') data.printer_id = printerId;
  140. if (manualStart !== 'unchanged') data.manual_start = manualStart;
  141. if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
  142. if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
  143. if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
  144. if (flowCali !== 'unchanged') data.flow_cali = flowCali;
  145. if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
  146. if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
  147. if (timelapse !== 'unchanged') data.timelapse = timelapse;
  148. if (useAms !== 'unchanged') data.use_ams = useAms;
  149. onSave(data);
  150. };
  151. const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
  152. requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
  153. vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged';
  154. return (
  155. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
  156. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
  157. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  158. <h2 className="text-lg font-semibold text-white">
  159. {t('queue.bulkEdit.title', { count: selectedCount })}
  160. </h2>
  161. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  162. <X className="w-5 h-5 text-bambu-gray" />
  163. </button>
  164. </div>
  165. <div className="p-4 space-y-4">
  166. <p className="text-sm text-bambu-gray">
  167. {t('queue.bulkEdit.description')}
  168. </p>
  169. {/* Printer Assignment */}
  170. <div>
  171. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
  172. <select
  173. value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
  174. onChange={(e) => {
  175. const val = e.target.value;
  176. if (val === 'unchanged') setPrinterId('unchanged');
  177. else if (val === 'null') setPrinterId(null);
  178. else setPrinterId(Number(val));
  179. }}
  180. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  181. >
  182. <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
  183. <option value="null">{t('queue.filter.unassigned')}</option>
  184. {printers.map(p => (
  185. <option key={p.id} value={p.id}>{p.name}</option>
  186. ))}
  187. </select>
  188. </div>
  189. {/* Queue Options */}
  190. <div>
  191. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
  192. <div className="space-y-2">
  193. <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
  194. <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />
  195. <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
  196. </div>
  197. </div>
  198. {/* Print Options */}
  199. <div>
  200. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
  201. <div className="space-y-2">
  202. <TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
  203. <TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
  204. <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
  205. <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
  206. <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
  207. <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
  208. </div>
  209. </div>
  210. </div>
  211. <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  212. <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
  213. <Button
  214. onClick={handleSave}
  215. disabled={!hasChanges || isSaving}
  216. >
  217. {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
  218. </Button>
  219. </div>
  220. </div>
  221. </div>
  222. );
  223. }
  224. // Tri-state toggle for bulk edit (unchanged / on / off)
  225. function TriStateToggle({
  226. label,
  227. value,
  228. onChange,
  229. disabled,
  230. t,
  231. }: {
  232. label: string;
  233. value: boolean | 'unchanged';
  234. onChange: (val: boolean | 'unchanged') => void;
  235. disabled?: boolean;
  236. t: (key: string) => string;
  237. }) {
  238. return (
  239. <div className={`flex items-center justify-between py-1 ${disabled ? 'opacity-50' : ''}`}>
  240. <span className="text-sm text-bambu-gray">{label}</span>
  241. <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
  242. <button
  243. onClick={() => onChange('unchanged')}
  244. disabled={disabled}
  245. className={`px-2 py-1 text-xs rounded ${value === 'unchanged' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  246. >
  247. —
  248. </button>
  249. <button
  250. onClick={() => onChange(false)}
  251. disabled={disabled}
  252. className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  253. >
  254. {t('common.off')}
  255. </button>
  256. <button
  257. onClick={() => onChange(true)}
  258. disabled={disabled}
  259. className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  260. >
  261. {t('common.on')}
  262. </button>
  263. </div>
  264. </div>
  265. );
  266. }
  267. // Sortable queue item for drag and drop
  268. function SortableQueueItem({
  269. item,
  270. position,
  271. onEdit,
  272. onCancel,
  273. onRemove,
  274. onStop,
  275. onRequeue,
  276. onStart,
  277. timeFormat = 'system',
  278. isSelected = false,
  279. onToggleSelect,
  280. hasPermission,
  281. canModify,
  282. printerState,
  283. t,
  284. }: {
  285. item: PrintQueueItem;
  286. position?: number;
  287. onEdit: () => void;
  288. onCancel: () => void;
  289. onRemove: () => void;
  290. onStop: () => void;
  291. onRequeue: () => void;
  292. onStart: () => void;
  293. timeFormat?: TimeFormat;
  294. isSelected?: boolean;
  295. onToggleSelect?: () => void;
  296. hasPermission: (permission: Permission) => boolean;
  297. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  298. printerState?: string | null;
  299. t: (key: string, options?: Record<string, unknown>) => string;
  300. }) {
  301. // Fetch printer status every 30 seconds while printing to monitor progress
  302. const { data: status } = useQuery({
  303. queryKey: ['printerStatus', item.printer_id],
  304. queryFn: () => api.getPrinterStatus(item.printer_id!),
  305. refetchInterval: 30000,
  306. enabled: item.printer_id != null && printerState === 'printing',
  307. });
  308. // Determine if we're printing a library file
  309. const isLibraryFile = !!item.library_file_id && !item.archive_id;
  310. // Fetch archive plate details. Skip when the linked archive has been
  311. // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
  312. // /plates endpoint just 404-storms the queue page.
  313. const { data: archivePlatesData } = useQuery({
  314. queryKey: ['archive-plates', item.archive_id],
  315. queryFn: () => api.getArchivePlates(item.archive_id!),
  316. enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
  317. });
  318. // Fetch library file plate details
  319. const { data: libraryPlatesData } = useQuery({
  320. queryKey: ['library-file-plates', item.library_file_id],
  321. queryFn: () => api.getLibraryFilePlates(item.library_file_id!),
  322. enabled: isLibraryFile && !!item.library_file_id,
  323. });
  324. // Combine plates data from either source
  325. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  326. const plates = platesData?.plates ?? [];
  327. const canReorder = hasPermission('queue:reorder');
  328. const {
  329. attributes,
  330. listeners,
  331. setNodeRef,
  332. transform,
  333. transition,
  334. isDragging,
  335. } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
  336. const style = {
  337. transform: CSS.Transform.toString(transform),
  338. transition,
  339. };
  340. const isPrinting = item.status === 'printing';
  341. const isPending = item.status === 'pending';
  342. const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
  343. const isMobileSelectable = isPending && onToggleSelect;
  344. return (
  345. <div
  346. ref={setNodeRef}
  347. style={style}
  348. className={`
  349. group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
  350. border-l-[3px] ${
  351. isPrinting ? 'border-l-blue-500' :
  352. isPending ? 'border-l-yellow-500' :
  353. item.status === 'completed' ? 'border-l-emerald-500' :
  354. item.status === 'failed' ? 'border-l-red-500' :
  355. 'border-l-gray-500'
  356. }
  357. ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
  358. ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
  359. ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
  360. ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
  361. ${isMobileSelectable ? 'sm:cursor-default' : ''}
  362. `}
  363. onClick={isMobileSelectable ? () => {
  364. if (window.innerWidth < 640) onToggleSelect();
  365. } : undefined}
  366. >
  367. {/* Mobile selected left accent bar */}
  368. {isMobileSelectable && isSelected && (
  369. <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
  370. )}
  371. <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
  372. {/* Mobile selection indicator — left accent bar only, no tick */}
  373. {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
  374. {isPending && onToggleSelect && (
  375. <button
  376. onClick={(e) => {
  377. e.stopPropagation();
  378. onToggleSelect();
  379. }}
  380. className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
  381. isSelected
  382. ? 'bg-bambu-green border-bambu-green text-white'
  383. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  384. }`}
  385. >
  386. {isSelected && <Check className="w-4 h-4" />}
  387. </button>
  388. )}
  389. {/* Drag handle or position number - hidden on mobile */}
  390. {isPending ? (
  391. <div
  392. {...attributes}
  393. {...listeners}
  394. className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
  395. >
  396. <GripVertical className="w-4 h-4 text-bambu-gray" />
  397. </div>
  398. ) : position !== undefined ? (
  399. <div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0">
  400. #{position}
  401. </div>
  402. ) : (
  403. <div className="hidden sm:block w-8 shrink-0" />
  404. )}
  405. {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
  406. <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  407. {item.archive_thumbnail ? (
  408. <img
  409. src={
  410. item.plate_id != null
  411. ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
  412. : api.getArchiveThumbnail(item.archive_id!)
  413. }
  414. alt=""
  415. className="w-full h-full object-cover"
  416. />
  417. ) : item.library_file_thumbnail ? (
  418. <img
  419. src={
  420. item.plate_id != null
  421. ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
  422. : api.getLibraryFileThumbnailUrl(item.library_file_id!)
  423. }
  424. alt=""
  425. className="w-full h-full object-cover"
  426. />
  427. ) : (
  428. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  429. <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
  430. </div>
  431. )}
  432. </div>
  433. {/* Info */}
  434. <div className="flex-1 min-w-0">
  435. <div className="flex items-center gap-2 mb-1">
  436. <p className="text-sm sm:text-base text-white font-medium truncate">
  437. {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
  438. {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
  439. </p>
  440. {item.archive_id ? (
  441. <Link
  442. to={`/archives?highlight=${item.archive_id}`}
  443. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  444. title={t('queue.viewArchive')}
  445. >
  446. <ExternalLink className="w-3.5 h-3.5" />
  447. </Link>
  448. ) : item.library_file_id ? (
  449. <Link
  450. to={`/library?highlight=${item.library_file_id}`}
  451. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  452. title={t('queue.viewInFileManager')}
  453. >
  454. <ExternalLink className="w-3.5 h-3.5" />
  455. </Link>
  456. ) : null}
  457. {item.batch_name && (
  458. <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30">
  459. {item.batch_name}
  460. </span>
  461. )}
  462. </div>
  463. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
  464. <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-400' : ''}`}>
  465. <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  466. <span className="truncate max-w-[120px] sm:max-w-none">
  467. {item.target_model && !item.printer_id
  468. ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
  469. : item.printer_id === null
  470. ? t('queue.filter.unassigned')
  471. : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
  472. </span>
  473. </span>
  474. {item.print_time_seconds && (
  475. <span className="flex items-center gap-1 sm:gap-1.5">
  476. <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  477. {formatDuration(item.print_time_seconds)}
  478. </span>
  479. )}
  480. {item.filament_used_grams && (
  481. <span className="flex items-center gap-1 sm:gap-1.5">
  482. <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  483. {formatWeight(item.filament_used_grams)}
  484. </span>
  485. )}
  486. {item.created_by_username && (
  487. <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
  488. <User className="w-3.5 h-3.5" />
  489. {item.created_by_username}
  490. </span>
  491. )}
  492. {isPending && !item.manual_start && (
  493. <span className="flex items-center gap-1.5">
  494. <Clock className="w-3.5 h-3.5" />
  495. {item.scheduled_time
  496. ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
  497. ? t?.('queue.time.overdue') ?? 'Overdue'
  498. : formatRelativeTime(item.scheduled_time, timeFormat, t))
  499. : t?.('queue.time.asap') ?? 'ASAP'}
  500. </span>
  501. )}
  502. </div>
  503. {/* Options badges */}
  504. <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
  505. {item.manual_start && (
  506. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
  507. <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  508. {t('queue.badges.staged')}
  509. </span>
  510. )}
  511. {item.require_previous_success && (
  512. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
  513. {t('queue.badges.requiresPrevious')}
  514. </span>
  515. )}
  516. {item.auto_off_after && (
  517. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
  518. <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  519. {t('queue.badges.autoPowerOff')}
  520. </span>
  521. )}
  522. {item.gcode_injection && (
  523. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20 flex items-center gap-1">
  524. <Code className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  525. {t('queue.badges.gcodeInjection')}
  526. </span>
  527. )}
  528. </div>
  529. {/* Progress bar for printing items - TODO: integrate with WebSocket */}
  530. {isPrinting && status && (() => {
  531. // Gate progress/remaining/layer on printer actually running this print.
  532. // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress
  533. // is stale from the previous print — showing 100% then snapping back to 0%
  534. // once the new print starts. Only trust these fields when state is active.
  535. const isActive = status.state === 'RUNNING' || status.state === 'PAUSE';
  536. const progress = isActive ? (status.progress || 0) : 0;
  537. const remaining = isActive ? status.remaining_time : null;
  538. const layerNum = isActive ? status.layer_num : null;
  539. const totalLayers = isActive ? status.total_layers : null;
  540. return (
  541. <div className="mt-2 sm:mt-3">
  542. <div className="flex items-center justify-between text-xs sm:text-sm">
  543. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
  544. <div
  545. className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
  546. style={{ width: `${progress}%` }}
  547. />
  548. </div>
  549. <span className="text-white">{Math.round(progress)}%</span>
  550. </div>
  551. <div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray">
  552. {remaining != null && remaining > 0 && (
  553. <>
  554. <span className="flex items-center gap-1">
  555. <Clock className="w-3 h-3" />
  556. {formatDuration(remaining * 60)}
  557. </span>
  558. <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
  559. ETA {formatETA(remaining, timeFormat, t)}
  560. </span>
  561. </>
  562. )}
  563. {layerNum != null && totalLayers != null && totalLayers > 0 && (
  564. <span className="flex items-center gap-1">
  565. <Layers className="w-3 h-3" />
  566. {layerNum}/{totalLayers}
  567. </span>
  568. )}
  569. </div>
  570. </div>
  571. );
  572. })()}
  573. {/* Waiting reason for model-based assignments */}
  574. {item.waiting_reason && item.status === 'pending' && (
  575. <p className="text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
  576. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  577. <span>{item.waiting_reason}</span>
  578. </p>
  579. )}
  580. {/* Filament-short flag from the dispatch pre-flight (#1496). */}
  581. {item.filament_short && item.status === 'pending' && (
  582. <p
  583. className="text-[10px] sm:text-xs text-yellow-400 mt-1.5 sm:mt-2 flex items-start gap-1"
  584. title={t('queue.filamentShort.rowTooltip')}
  585. >
  586. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  587. <span>{t('queue.filamentShort.rowBadge')}</span>
  588. </p>
  589. )}
  590. {/* Error message */}
  591. {item.error_message && (
  592. <p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
  593. <AlertCircle className="w-3 h-3" />
  594. {item.error_message}
  595. </p>
  596. )}
  597. </div>
  598. {/* Status badge + Actions */}
  599. <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
  600. <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
  601. <div className="flex items-center gap-0.5 sm:gap-1">
  602. {isPrinting && (
  603. <Button
  604. variant="ghost"
  605. size="sm"
  606. onClick={onStop}
  607. disabled={!hasPermission('printers:control')}
  608. title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
  609. className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
  610. >
  611. <StopCircle className="w-4 h-4" />
  612. </Button>
  613. )}
  614. {isPending && (
  615. <>
  616. {item.manual_start && (
  617. <Button
  618. variant="ghost"
  619. size="sm"
  620. onClick={onStart}
  621. disabled={!hasPermission('printers:control')}
  622. title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
  623. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
  624. >
  625. <Play className="w-4 h-4" />
  626. </Button>
  627. )}
  628. <Button
  629. variant="ghost"
  630. size="sm"
  631. onClick={onEdit}
  632. disabled={!canModify('queue', 'update', item.created_by_id)}
  633. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
  634. className="p-1.5 sm:p-2"
  635. >
  636. <Pencil className="w-4 h-4" />
  637. </Button>
  638. <Button
  639. variant="ghost"
  640. size="sm"
  641. onClick={onCancel}
  642. disabled={!canModify('queue', 'delete', item.created_by_id)}
  643. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
  644. className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
  645. >
  646. <X className="w-4 h-4" />
  647. </Button>
  648. </>
  649. )}
  650. {isHistory && (
  651. <>
  652. <Button
  653. variant="ghost"
  654. size="sm"
  655. onClick={onRequeue}
  656. disabled={!hasPermission('queue:create')}
  657. title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
  658. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
  659. >
  660. <RefreshCw className="w-4 h-4" />
  661. </Button>
  662. <Button
  663. variant="ghost"
  664. size="sm"
  665. onClick={onRemove}
  666. disabled={!canModify('queue', 'delete', item.created_by_id)}
  667. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
  668. className="p-1.5 sm:p-2"
  669. >
  670. <Trash2 className="w-4 h-4" />
  671. </Button>
  672. </>
  673. )}
  674. </div>
  675. </div>
  676. </div>
  677. </div>
  678. );
  679. }
  680. export function QueuePage() {
  681. const { t } = useTranslation();
  682. const queryClient = useQueryClient();
  683. const { showToast } = useToast();
  684. const { hasPermission, hasAnyPermission, canModify } = useAuth();
  685. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  686. const [filterStatus, setFilterStatus] = useState<string>('');
  687. const [filterLocation, setFilterLocation] = useState<string>('');
  688. const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
  689. const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
  690. const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
  691. const [confirmAction, setConfirmAction] = useState<{
  692. type: 'cancel' | 'remove' | 'stop';
  693. item: PrintQueueItem;
  694. } | null>(null);
  695. const [selectedItems, setSelectedItems] = useState<number[]>([]);
  696. const [showBulkEditModal, setShowBulkEditModal] = useState(false);
  697. const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
  698. const saved = localStorage.getItem('queue.historySortBy');
  699. return (saved as 'date' | 'name' | 'printer') || 'date';
  700. });
  701. const [historySortAsc, setHistorySortAsc] = useState(() => {
  702. const saved = localStorage.getItem('queue.historySortAsc');
  703. return saved !== null ? saved === 'true' : false;
  704. });
  705. const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
  706. const saved = localStorage.getItem('queue.pendingSortBy');
  707. return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
  708. });
  709. const [pendingSortAsc, setPendingSortAsc] = useState(() => {
  710. const saved = localStorage.getItem('queue.pendingSortAsc');
  711. return saved !== null ? saved === 'true' : true;
  712. });
  713. const [historyCollapsed, setHistoryCollapsed] = useState(() => {
  714. return localStorage.getItem('queue.historyCollapsed') !== 'false';
  715. });
  716. const [viewMode, setViewMode] = useState<'list' | 'timeline'>(() => {
  717. return (localStorage.getItem('queue.viewMode') as 'list' | 'timeline') || 'list';
  718. });
  719. // Persist sort settings to localStorage
  720. useEffect(() => {
  721. localStorage.setItem('queue.historySortBy', historySortBy);
  722. }, [historySortBy]);
  723. useEffect(() => {
  724. localStorage.setItem('queue.historySortAsc', String(historySortAsc));
  725. }, [historySortAsc]);
  726. useEffect(() => {
  727. localStorage.setItem('queue.pendingSortBy', pendingSortBy);
  728. }, [pendingSortBy]);
  729. useEffect(() => {
  730. localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
  731. }, [pendingSortAsc]);
  732. useEffect(() => {
  733. localStorage.setItem('queue.historyCollapsed', String(historyCollapsed));
  734. }, [historyCollapsed]);
  735. useEffect(() => {
  736. localStorage.setItem('queue.viewMode', viewMode);
  737. }, [viewMode]);
  738. const sensors = useSensors(
  739. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  740. useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  741. );
  742. const { data: settings } = useQuery({
  743. queryKey: ['settings'],
  744. queryFn: api.getSettings,
  745. });
  746. const timeFormat: TimeFormat = settings?.time_format || 'system';
  747. const { data: queue, isLoading } = useQuery({
  748. queryKey: ['queue', filterPrinter, filterStatus],
  749. queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
  750. refetchInterval: 5000,
  751. });
  752. const { data: printers } = useQuery({
  753. queryKey: ['printers'],
  754. queryFn: () => api.getPrinters(),
  755. });
  756. const sjfMutation = useMutation({
  757. mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
  758. onSuccess: () => {
  759. queryClient.invalidateQueries({ queryKey: ['settings'] });
  760. },
  761. });
  762. const cancelMutation = useMutation({
  763. mutationFn: (id: number) => api.cancelQueueItem(id),
  764. onSuccess: () => {
  765. queryClient.invalidateQueries({ queryKey: ['queue'] });
  766. showToast(t('queue.toast.cancelled'));
  767. },
  768. onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
  769. });
  770. const removeMutation = useMutation({
  771. mutationFn: (id: number) => api.removeFromQueue(id),
  772. onSuccess: () => {
  773. queryClient.invalidateQueries({ queryKey: ['queue'] });
  774. showToast(t('queue.toast.removed'));
  775. },
  776. onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
  777. });
  778. const stopMutation = useMutation({
  779. mutationFn: (id: number) => api.stopQueueItem(id),
  780. onSuccess: () => {
  781. queryClient.invalidateQueries({ queryKey: ['queue'] });
  782. showToast(t('queue.toast.stopped'));
  783. },
  784. onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
  785. });
  786. // Filament-deficit confirmation state (#1496). When the backend returns
  787. // 409 with `code=insufficient_filament` we stash the deficit + item id
  788. // here; the modal at the bottom of the page reads it and the "Print
  789. // Anyway" path re-issues the start with `skipFilamentCheck=true`.
  790. const [filamentShortConfirm, setFilamentShortConfirm] = useState<{
  791. itemId: number;
  792. deficit: Array<{
  793. slot_id: number;
  794. required_grams: number;
  795. remaining_grams: number | null;
  796. filament_type?: string | null;
  797. }>;
  798. } | null>(null);
  799. const startMutation = useMutation({
  800. mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
  801. api.startQueueItem(id, { skipFilamentCheck }),
  802. onSuccess: () => {
  803. queryClient.invalidateQueries({ queryKey: ['queue'] });
  804. showToast(t('queue.toast.released'));
  805. setFilamentShortConfirm(null);
  806. },
  807. onError: (error: unknown, variables) => {
  808. if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
  809. const deficitRaw = (error.detail?.deficit ?? []) as Array<{
  810. slot_id: number;
  811. required_grams: number;
  812. remaining_grams: number | null;
  813. filament_type?: string | null;
  814. }>;
  815. setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
  816. return;
  817. }
  818. showToast(t('queue.toast.startFailed'), 'error');
  819. },
  820. });
  821. const reorderMutation = useMutation({
  822. mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
  823. onSuccess: () => {
  824. queryClient.invalidateQueries({ queryKey: ['queue'] });
  825. },
  826. onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
  827. });
  828. const clearHistoryMutation = useMutation({
  829. mutationFn: async () => {
  830. const historyItems = queue?.filter(i =>
  831. ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
  832. ) || [];
  833. for (const item of historyItems) {
  834. await api.removeFromQueue(item.id);
  835. }
  836. return historyItems.length;
  837. },
  838. onSuccess: (count) => {
  839. queryClient.invalidateQueries({ queryKey: ['queue'] });
  840. showToast(t('queue.toast.historyCleared', { count }));
  841. },
  842. onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
  843. });
  844. const bulkUpdateMutation = useMutation({
  845. mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
  846. onSuccess: (result) => {
  847. queryClient.invalidateQueries({ queryKey: ['queue'] });
  848. setSelectedItems([]);
  849. setShowBulkEditModal(false);
  850. showToast(result.message);
  851. },
  852. onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
  853. });
  854. const bulkCancelMutation = useMutation({
  855. mutationFn: async (ids: number[]) => {
  856. for (const id of ids) {
  857. await api.cancelQueueItem(id);
  858. }
  859. return ids.length;
  860. },
  861. onSuccess: (count) => {
  862. queryClient.invalidateQueries({ queryKey: ['queue'] });
  863. setSelectedItems([]);
  864. showToast(t('queue.toast.bulkCancelled', { count }));
  865. },
  866. onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
  867. });
  868. const handleToggleSelect = (id: number) => {
  869. setSelectedItems(prev =>
  870. prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
  871. );
  872. };
  873. // Get unique locations from printers for the filter dropdown
  874. const uniqueLocations = useMemo(() => {
  875. const locations = new Set<string>();
  876. printers?.forEach(p => {
  877. if (p.location) locations.add(p.location);
  878. });
  879. // Also include locations from queue items (for model-based assignments)
  880. queue?.forEach(item => {
  881. if (item.target_location) locations.add(item.target_location);
  882. });
  883. return Array.from(locations).sort();
  884. }, [printers, queue]);
  885. // Helper to check if a queue item matches the location filter
  886. const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
  887. if (!filterLocation) return true;
  888. // For model-based assignments, check target_location
  889. if (item.target_location) return item.target_location === filterLocation;
  890. // For printer-based assignments, check the printer's location
  891. if (item.printer_id) {
  892. const printer = printers?.find(p => p.id === item.printer_id);
  893. return printer?.location === filterLocation;
  894. }
  895. return false;
  896. }, [filterLocation, printers]);
  897. const pendingItems = useMemo(() => {
  898. let items = queue?.filter(i => i.status === 'pending') || [];
  899. // Apply location filter
  900. if (filterLocation) {
  901. items = items.filter(matchesLocationFilter);
  902. }
  903. // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
  904. const getScheduledTime = (item: PrintQueueItem): number => {
  905. if (!item.scheduled_time) return 0;
  906. const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;
  907. // Placeholder dates (> 6 months out) are treated as ASAP
  908. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  909. return time > sixMonthsFromNow ? 0 : time;
  910. };
  911. // When SJF is enabled, override sort to match scheduler order
  912. if (settings?.queue_shortest_first) {
  913. return [...items].sort((a, b) => {
  914. // Group by printer first (nulls = model-based, grouped by target_model)
  915. const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);
  916. const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);
  917. if (aPrinter !== bPrinter) return aPrinter - bPrinter;
  918. // Within same printer/model: jumped items first (starvation guard)
  919. const aJumped = a.been_jumped ? 1 : 0;
  920. const bJumped = b.been_jumped ? 1 : 0;
  921. if (aJumped !== bJumped) return bJumped - aJumped;
  922. // Shortest print time next (nulls last)
  923. const aTime = a.print_time_seconds ?? Infinity;
  924. const bTime = b.print_time_seconds ?? Infinity;
  925. if (aTime !== bTime) return aTime - bTime;
  926. // Position as tiebreaker
  927. return a.position - b.position;
  928. });
  929. }
  930. return [...items].sort((a, b) => {
  931. let cmp: number;
  932. if (pendingSortBy === 'name') {
  933. const aName = a.archive_name || a.library_file_name || '';
  934. const bName = b.archive_name || b.library_file_name || '';
  935. cmp = aName.localeCompare(bName);
  936. } else if (pendingSortBy === 'printer') {
  937. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  938. } else if (pendingSortBy === 'time') {
  939. // Sort by scheduled start time (when print will begin)
  940. cmp = getScheduledTime(a) - getScheduledTime(b);
  941. } else {
  942. cmp = a.position - b.position;
  943. }
  944. return pendingSortAsc ? cmp : -cmp;
  945. });
  946. }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);
  947. const handleSelectAll = () => {
  948. const allPendingIds = pendingItems.map(i => i.id);
  949. if (selectedItems.length === allPendingIds.length) {
  950. setSelectedItems([]);
  951. } else {
  952. setSelectedItems(allPendingIds);
  953. }
  954. };
  955. const activeItems = useMemo(() => {
  956. let items = queue?.filter(i => i.status === 'printing') || [];
  957. if (filterLocation) {
  958. items = items.filter(matchesLocationFilter);
  959. }
  960. return items;
  961. }, [queue, filterLocation, matchesLocationFilter]);
  962. // Get unique printer IDs from active items to fetch their statuses
  963. const activePrinterIds = useMemo(() => {
  964. const ids = new Set<number>();
  965. activeItems.forEach(item => {
  966. if (item.printer_id) ids.add(item.printer_id);
  967. });
  968. return Array.from(ids);
  969. }, [activeItems]);
  970. // Fetch printer statuses for printers with active jobs
  971. const printerStatusQueries = useQueries({
  972. queries: activePrinterIds.map(printerId => ({
  973. queryKey: ['printerStatus', printerId],
  974. queryFn: () => api.getPrinterStatus(printerId),
  975. refetchInterval: 5000,
  976. })),
  977. });
  978. // Build a map of printer_id -> state for quick lookup
  979. const printerStateMap = useMemo(() => {
  980. const map: Record<number, string | null> = {};
  981. activePrinterIds.forEach((printerId, index) => {
  982. const result = printerStatusQueries[index];
  983. if (result?.data?.state) {
  984. map[printerId] = result.data.state;
  985. }
  986. });
  987. return map;
  988. }, [activePrinterIds, printerStatusQueries]);
  989. // Build a map of printer_id -> full status for timeline view
  990. const printerStatusMap = useMemo(() => {
  991. const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};
  992. activePrinterIds.forEach((printerId, index) => {
  993. const result = printerStatusQueries[index];
  994. if (result?.data) {
  995. map[printerId] = {
  996. progress: result.data.progress ?? undefined,
  997. remaining_time: result.data.remaining_time ?? undefined,
  998. state: result.data.state ?? undefined,
  999. };
  1000. }
  1001. });
  1002. return map;
  1003. }, [activePrinterIds, printerStatusQueries]);
  1004. const historyItems = useMemo(() => {
  1005. let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
  1006. if (filterLocation) {
  1007. items = items.filter(matchesLocationFilter);
  1008. }
  1009. return [...items].sort((a, b) => {
  1010. let cmp: number;
  1011. if (historySortBy === 'name') {
  1012. const aName = a.archive_name || a.library_file_name || '';
  1013. const bName = b.archive_name || b.library_file_name || '';
  1014. cmp = aName.localeCompare(bName);
  1015. } else if (historySortBy === 'printer') {
  1016. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  1017. } else {
  1018. // Default: by date - most recent first (desc) is the natural order
  1019. cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
  1020. }
  1021. return historySortAsc ? -cmp : cmp;
  1022. });
  1023. }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
  1024. // Calculate total queue time
  1025. const totalQueueTime = useMemo(() => {
  1026. return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
  1027. }, [pendingItems]);
  1028. // Calculate total material weight
  1029. const totalWeight = useMemo(() => {
  1030. return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
  1031. }, [pendingItems]);
  1032. const handleDragEnd = (event: DragEndEvent) => {
  1033. const { active, over } = event;
  1034. if (!over || active.id === over.id) return;
  1035. const oldIndex = pendingItems.findIndex(i => i.id === active.id);
  1036. const newIndex = pendingItems.findIndex(i => i.id === over.id);
  1037. if (oldIndex !== -1 && newIndex !== -1) {
  1038. const reordered = arrayMove(pendingItems, oldIndex, newIndex);
  1039. const updates = reordered.map((item, index) => ({
  1040. id: item.id,
  1041. position: index + 1,
  1042. }));
  1043. reorderMutation.mutate(updates);
  1044. }
  1045. };
  1046. return (
  1047. <div className="p-4 md:p-8">
  1048. {/* Header */}
  1049. <div className="flex items-center justify-between mb-8">
  1050. <div>
  1051. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1052. <ListOrdered className="w-7 h-7 text-bambu-green" />
  1053. {t('queue.title')}
  1054. </h1>
  1055. <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
  1056. </div>
  1057. </div>
  1058. {/* Summary Stats */}
  1059. <QueueStatsBar
  1060. activeCount={activeItems.length}
  1061. pendingCount={pendingItems.length}
  1062. totalTime={totalQueueTime}
  1063. totalWeight={totalWeight}
  1064. historyCount={historyItems.length}
  1065. t={t}
  1066. />
  1067. {/* Filters */}
  1068. <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
  1069. <select
  1070. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1071. value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
  1072. onChange={(e) => {
  1073. const val = e.target.value;
  1074. if (val === 'unassigned') setFilterPrinter(-1);
  1075. else if (val === '') setFilterPrinter(null);
  1076. else setFilterPrinter(Number(val));
  1077. }}
  1078. >
  1079. <option value="">{t('queue.filter.allPrinters')}</option>
  1080. <option value="unassigned">{t('queue.filter.unassigned')}</option>
  1081. {printers?.map((p) => (
  1082. <option key={p.id} value={p.id}>{p.name}</option>
  1083. ))}
  1084. </select>
  1085. <select
  1086. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1087. value={filterStatus}
  1088. onChange={(e) => setFilterStatus(e.target.value)}
  1089. >
  1090. <option value="">{t('queue.filter.allStatus')}</option>
  1091. <option value="pending">{t('queue.status.pending')}</option>
  1092. <option value="printing">{t('queue.status.printing')}</option>
  1093. <option value="completed">{t('queue.status.completed')}</option>
  1094. <option value="failed">{t('queue.status.failed')}</option>
  1095. <option value="skipped">{t('queue.status.skipped')}</option>
  1096. <option value="cancelled">{t('queue.status.cancelled')}</option>
  1097. </select>
  1098. {uniqueLocations.length > 0 && (
  1099. <select
  1100. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1101. value={filterLocation}
  1102. onChange={(e) => setFilterLocation(e.target.value)}
  1103. >
  1104. <option value="">{t('queue.filter.allLocations')}</option>
  1105. {uniqueLocations.map((loc) => (
  1106. <option key={loc} value={loc}>{loc}</option>
  1107. ))}
  1108. </select>
  1109. )}
  1110. <div className="hidden sm:block flex-1" />
  1111. {historyItems.length > 0 && (
  1112. <Button
  1113. className="w-full sm:w-auto"
  1114. variant="secondary"
  1115. size="sm"
  1116. onClick={() => setShowClearHistoryConfirm(true)}
  1117. disabled={!hasPermission('queue:delete_all')}
  1118. title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
  1119. >
  1120. <Trash2 className="w-4 h-4" />
  1121. {t('queue.clearHistory')}
  1122. </Button>
  1123. )}
  1124. </div>
  1125. {/* View Mode Toggle + SJF */}
  1126. <div className="flex items-center gap-3 mb-6">
  1127. <div className="hidden sm:flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  1128. <button
  1129. className={`p-2 transition-colors ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  1130. onClick={() => setViewMode('list')}
  1131. title={t('queue.timeline.listView')}
  1132. >
  1133. <List className="w-4 h-4" />
  1134. </button>
  1135. <button
  1136. className={`p-2 transition-colors ${viewMode === 'timeline' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
  1137. onClick={() => setViewMode('timeline')}
  1138. title={t('queue.timeline.timelineView')}
  1139. >
  1140. <GanttChart className="w-4 h-4" />
  1141. </button>
  1142. </div>
  1143. <button
  1144. onClick={() => {
  1145. const newValue = !(settings?.queue_shortest_first ?? false);
  1146. sjfMutation.mutate(newValue);
  1147. }}
  1148. className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${
  1149. settings?.queue_shortest_first
  1150. ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
  1151. : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  1152. }`}
  1153. title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}
  1154. >
  1155. <Snail className="w-4 h-4" />
  1156. <span className="hidden sm:inline">{t('queue.sjf.label', 'SJF')}</span>
  1157. <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />
  1158. </button>
  1159. </div>
  1160. {isLoading ? (
  1161. <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
  1162. ) : queue?.length === 0 ? (
  1163. <Card className="p-12 text-center border-dashed">
  1164. <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  1165. <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
  1166. <p className="text-bambu-gray max-w-md mx-auto">
  1167. {t('queue.empty.description')}
  1168. </p>
  1169. </Card>
  1170. ) : viewMode === 'timeline' ? (
  1171. <QueueTimelineView
  1172. queueItems={queue || []}
  1173. printerStatuses={printerStatusMap}
  1174. onItemClick={(item) => {
  1175. if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {
  1176. setRequeueItem(item);
  1177. } else if (item.status === 'pending') {
  1178. setEditItem(item);
  1179. } else if (item.status === 'printing') {
  1180. setConfirmAction({ type: 'stop', item });
  1181. }
  1182. }}
  1183. t={t}
  1184. />
  1185. ) : (
  1186. <div className="space-y-6 sm:space-y-8">
  1187. {/* Active Prints */}
  1188. {activeItems.length > 0 && (
  1189. <div>
  1190. <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
  1191. <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
  1192. {t('queue.sections.currentlyPrinting')}
  1193. </h2>
  1194. <div className="space-y-2 sm:space-y-3">
  1195. {activeItems.map((item) => (
  1196. <SortableQueueItem
  1197. key={item.id}
  1198. item={item}
  1199. onEdit={() => {}}
  1200. onCancel={() => {}}
  1201. onRemove={() => {}}
  1202. onStop={() => setConfirmAction({ type: 'stop', item })}
  1203. onRequeue={() => {}}
  1204. onStart={() => {}}
  1205. timeFormat={timeFormat}
  1206. hasPermission={hasPermission}
  1207. canModify={canModify}
  1208. printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
  1209. t={t}
  1210. />
  1211. ))}
  1212. </div>
  1213. </div>
  1214. )}
  1215. {/* Pending Queue */}
  1216. {pendingItems.length > 0 && (
  1217. <div>
  1218. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  1219. <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
  1220. <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
  1221. {t('queue.sections.queued')}
  1222. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  1223. ({t('queue.itemCount', { count: pendingItems.length })})
  1224. </span>
  1225. <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
  1226. {t('queue.dragToReorder')}
  1227. </span>
  1228. </h2>
  1229. <div className="flex items-center gap-2">
  1230. <select
  1231. className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1232. value={pendingSortBy}
  1233. onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
  1234. >
  1235. <option value="position">{t('queue.sort.byPosition')}</option>
  1236. <option value="name">{t('queue.sort.byName')}</option>
  1237. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1238. <option value="time">{t('queue.sort.bySchedule')}</option>
  1239. </select>
  1240. <Button
  1241. variant="ghost"
  1242. size="sm"
  1243. onClick={() => setPendingSortAsc(!pendingSortAsc)}
  1244. title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
  1245. className="px-2"
  1246. >
  1247. {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1248. </Button>
  1249. </div>
  1250. </div>
  1251. {/* Bulk action toolbar */}
  1252. <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg">
  1253. <Button
  1254. variant="ghost"
  1255. size="sm"
  1256. onClick={handleSelectAll}
  1257. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
  1258. >
  1259. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
  1260. <CheckSquare className="w-4 h-4 text-bambu-green" />
  1261. ) : (
  1262. <Square className="w-4 h-4" />
  1263. )}
  1264. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  1265. </Button>
  1266. {selectedItems.length > 0 && (
  1267. <>
  1268. <span className="text-xs sm:text-sm text-bambu-gray">
  1269. {t('queue.bulkEdit.selected', { count: selectedItems.length })}
  1270. </span>
  1271. <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
  1272. <Button
  1273. variant="ghost"
  1274. size="sm"
  1275. onClick={() => setShowBulkEditModal(true)}
  1276. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
  1277. disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
  1278. title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
  1279. >
  1280. <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  1281. <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
  1282. </Button>
  1283. <Button
  1284. variant="ghost"
  1285. size="sm"
  1286. onClick={() => bulkCancelMutation.mutate(selectedItems)}
  1287. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300"
  1288. disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
  1289. title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
  1290. >
  1291. <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  1292. <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
  1293. </Button>
  1294. </>
  1295. )}
  1296. </div>
  1297. <DndContext
  1298. sensors={sensors}
  1299. collisionDetection={closestCenter}
  1300. onDragEnd={handleDragEnd}
  1301. >
  1302. <SortableContext
  1303. items={pendingItems.map(i => i.id)}
  1304. strategy={verticalListSortingStrategy}
  1305. >
  1306. <div className="space-y-2 sm:space-y-3">
  1307. {pendingItems.map((item, index) => (
  1308. <SortableQueueItem
  1309. key={item.id}
  1310. item={item}
  1311. position={index + 1}
  1312. onEdit={() => setEditItem(item)}
  1313. onCancel={() => setConfirmAction({ type: 'cancel', item })}
  1314. onRemove={() => {}}
  1315. onStop={() => {}}
  1316. onRequeue={() => {}}
  1317. onStart={() => startMutation.mutate({ id: item.id })}
  1318. timeFormat={timeFormat}
  1319. isSelected={selectedItems.includes(item.id)}
  1320. onToggleSelect={() => handleToggleSelect(item.id)}
  1321. hasPermission={hasPermission}
  1322. canModify={canModify}
  1323. t={t}
  1324. />
  1325. ))}
  1326. </div>
  1327. </SortableContext>
  1328. </DndContext>
  1329. </div>
  1330. )}
  1331. {/* History */}
  1332. {historyItems.length > 0 && (
  1333. <div>
  1334. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  1335. <button
  1336. onClick={() => setHistoryCollapsed(!historyCollapsed)}
  1337. className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 hover:text-bambu-green transition-colors"
  1338. >
  1339. {historyCollapsed ? <ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" /> : <ChevronDown className="w-4 h-4 sm:w-5 sm:h-5" />}
  1340. {t('queue.sections.history')}
  1341. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  1342. ({t('queue.itemCount', { count: historyItems.length })})
  1343. </span>
  1344. </button>
  1345. {!historyCollapsed && (
  1346. <div className="flex items-center gap-2">
  1347. <select
  1348. className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1349. value={historySortBy}
  1350. onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
  1351. >
  1352. <option value="date">{t('queue.sort.byDate')}</option>
  1353. <option value="name">{t('queue.sort.byName')}</option>
  1354. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1355. </select>
  1356. <Button
  1357. variant="ghost"
  1358. size="sm"
  1359. onClick={() => setHistorySortAsc(!historySortAsc)}
  1360. title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
  1361. className="px-2"
  1362. >
  1363. {historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1364. </Button>
  1365. </div>
  1366. )}
  1367. </div>
  1368. {!historyCollapsed && (
  1369. <div className="space-y-1.5 sm:space-y-2">
  1370. {historyItems.slice(0, 50).map((item) => (
  1371. <CompactHistoryRow
  1372. key={item.id}
  1373. item={item}
  1374. onRemove={() => setConfirmAction({ type: 'remove', item })}
  1375. onRequeue={() => setRequeueItem(item)}
  1376. timeFormat={timeFormat}
  1377. hasPermission={hasPermission}
  1378. canModify={canModify}
  1379. t={t}
  1380. />
  1381. ))}
  1382. </div>
  1383. )}
  1384. </div>
  1385. )}
  1386. </div>
  1387. )}
  1388. {/* Edit Modal */}
  1389. {editItem && (
  1390. <PrintModal
  1391. mode="edit-queue-item"
  1392. archiveId={editItem.archive_id ?? undefined}
  1393. libraryFileId={editItem.library_file_id ?? undefined}
  1394. archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
  1395. queueItem={editItem}
  1396. onClose={() => setEditItem(null)}
  1397. />
  1398. )}
  1399. {/* Re-queue Modal */}
  1400. {requeueItem && (
  1401. <PrintModal
  1402. mode="add-to-queue"
  1403. archiveId={requeueItem.archive_id ?? undefined}
  1404. libraryFileId={requeueItem.library_file_id ?? undefined}
  1405. archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
  1406. onClose={() => setRequeueItem(null)}
  1407. />
  1408. )}
  1409. {/* Confirm Action Modal */}
  1410. {filamentShortConfirm && (
  1411. <ConfirmModal
  1412. title={t('queue.filamentShort.confirmTitle')}
  1413. message={
  1414. t('queue.filamentShort.confirmIntro') + '\n\n' +
  1415. filamentShortConfirm.deficit
  1416. .map((d) =>
  1417. t('queue.filamentShort.lineItem', {
  1418. slot: d.slot_id,
  1419. required: Math.round(d.required_grams),
  1420. remaining:
  1421. d.remaining_grams == null
  1422. ? t('queue.filamentShort.unknown')
  1423. : Math.round(d.remaining_grams),
  1424. }),
  1425. )
  1426. .join('\n')
  1427. }
  1428. confirmText={t('queue.filamentShort.printAnyway')}
  1429. variant="warning"
  1430. onConfirm={() => {
  1431. startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true });
  1432. }}
  1433. onCancel={() => setFilamentShortConfirm(null)}
  1434. />
  1435. )}
  1436. {confirmAction && (
  1437. <ConfirmModal
  1438. title={
  1439. confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
  1440. confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
  1441. t('queue.confirm.removeTitle')
  1442. }
  1443. message={
  1444. confirmAction.type === 'cancel'
  1445. ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  1446. : confirmAction.type === 'stop'
  1447. ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  1448. : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
  1449. }
  1450. confirmText={
  1451. confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
  1452. confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
  1453. t('common.remove')
  1454. }
  1455. variant="danger"
  1456. onConfirm={() => {
  1457. if (confirmAction.type === 'cancel') {
  1458. cancelMutation.mutate(confirmAction.item.id);
  1459. } else if (confirmAction.type === 'stop') {
  1460. stopMutation.mutate(confirmAction.item.id);
  1461. } else {
  1462. removeMutation.mutate(confirmAction.item.id);
  1463. }
  1464. setConfirmAction(null);
  1465. }}
  1466. onCancel={() => setConfirmAction(null)}
  1467. />
  1468. )}
  1469. {/* Clear History Confirm Modal */}
  1470. {showClearHistoryConfirm && (
  1471. <ConfirmModal
  1472. title={t('queue.confirm.clearHistoryTitle')}
  1473. message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
  1474. confirmText={t('queue.clearHistory')}
  1475. variant="danger"
  1476. onConfirm={() => {
  1477. clearHistoryMutation.mutate();
  1478. setShowClearHistoryConfirm(false);
  1479. }}
  1480. onCancel={() => setShowClearHistoryConfirm(false)}
  1481. />
  1482. )}
  1483. {/* Bulk Edit Modal */}
  1484. {showBulkEditModal && (
  1485. <BulkEditModal
  1486. selectedCount={selectedItems.length}
  1487. printers={printers?.map(p => ({ id: p.id, name: p.name })) || []}
  1488. onSave={(data) => {
  1489. if (Object.keys(data).length > 0) {
  1490. bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
  1491. }
  1492. }}
  1493. onClose={() => setShowBulkEditModal(false)}
  1494. isSaving={bulkUpdateMutation.isPending}
  1495. canControlPrinter={hasPermission('printers:control')}
  1496. t={t}
  1497. />
  1498. )}
  1499. </div>
  1500. );
  1501. }