QueuePage.tsx 64 KB

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