QueuePage.tsx 69 KB

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