QueuePage.tsx 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354
  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. } from 'lucide-react';
  51. import { api } from '../api/client';
  52. import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
  53. import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
  54. import { Card, CardContent } from '../components/Card';
  55. import { Button } from '../components/Button';
  56. import { ConfirmModal } from '../components/ConfirmModal';
  57. import { PrintModal } from '../components/PrintModal';
  58. import { useToast } from '../contexts/ToastContext';
  59. import { useAuth } from '../contexts/AuthContext';
  60. function formatDuration(seconds: number | null | undefined): string {
  61. if (!seconds) return '--';
  62. const hours = Math.floor(seconds / 3600);
  63. const minutes = Math.floor((seconds % 3600) / 60);
  64. if (hours > 0) return `${hours}h ${minutes}m`;
  65. return `${minutes}m`;
  66. }
  67. function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
  68. if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
  69. const date = parseUTCDate(dateString);
  70. if (!date) return t?.('queue.time.asap') ?? 'ASAP';
  71. const now = new Date();
  72. const diff = date.getTime() - now.getTime();
  73. if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
  74. if (diff < 0) return t?.('queue.time.now') ?? 'Now';
  75. if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
  76. if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
  77. if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
  78. return formatDateTime(dateString, timeFormat);
  79. }
  80. function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
  81. // Special case: pending with waiting_reason shows as "Waiting"
  82. if (status === 'pending' && waitingReason) {
  83. return (
  84. <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">
  85. <Clock className="w-3.5 h-3.5" />
  86. {t('queue.status.waiting')}
  87. </span>
  88. );
  89. }
  90. // Special case: printing but printer is paused
  91. if (status === 'printing' && (printerState === 'PAUSE' || printerState === 'PAUSED')) {
  92. return (
  93. <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">
  94. <Pause className="w-3.5 h-3.5" />
  95. {t('queue.status.paused')}
  96. </span>
  97. );
  98. }
  99. const config = {
  100. pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
  101. printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
  102. completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
  103. failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
  104. skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
  105. cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
  106. };
  107. const { icon: Icon, color, label } = config[status];
  108. return (
  109. <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
  110. <Icon className="w-3.5 h-3.5" />
  111. {label}
  112. </span>
  113. );
  114. }
  115. // Bulk edit modal for multiple queue items
  116. function BulkEditModal({
  117. selectedCount,
  118. printers,
  119. onSave,
  120. onClose,
  121. isSaving,
  122. t,
  123. }: {
  124. selectedCount: number;
  125. printers: { id: number; name: string }[];
  126. onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
  127. onClose: () => void;
  128. isSaving: boolean;
  129. t: (key: string, options?: Record<string, unknown>) => string;
  130. }) {
  131. const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
  132. const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
  133. const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
  134. const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
  135. const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');
  136. const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');
  137. const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
  138. const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
  139. const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
  140. const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
  141. const handleSave = () => {
  142. const data: Partial<PrintQueueBulkUpdate> = {};
  143. if (printerId !== 'unchanged') data.printer_id = printerId;
  144. if (manualStart !== 'unchanged') data.manual_start = manualStart;
  145. if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
  146. if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
  147. if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
  148. if (flowCali !== 'unchanged') data.flow_cali = flowCali;
  149. if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
  150. if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
  151. if (timelapse !== 'unchanged') data.timelapse = timelapse;
  152. if (useAms !== 'unchanged') data.use_ams = useAms;
  153. onSave(data);
  154. };
  155. const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
  156. requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
  157. vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged';
  158. return (
  159. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
  160. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
  161. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  162. <h2 className="text-lg font-semibold text-white">
  163. {t('queue.bulkEdit.title', { count: selectedCount })}
  164. </h2>
  165. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  166. <X className="w-5 h-5 text-bambu-gray" />
  167. </button>
  168. </div>
  169. <div className="p-4 space-y-4">
  170. <p className="text-sm text-bambu-gray">
  171. {t('queue.bulkEdit.description')}
  172. </p>
  173. {/* Printer Assignment */}
  174. <div>
  175. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
  176. <select
  177. value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
  178. onChange={(e) => {
  179. const val = e.target.value;
  180. if (val === 'unchanged') setPrinterId('unchanged');
  181. else if (val === 'null') setPrinterId(null);
  182. else setPrinterId(Number(val));
  183. }}
  184. 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"
  185. >
  186. <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
  187. <option value="null">{t('queue.filter.unassigned')}</option>
  188. {printers.map(p => (
  189. <option key={p.id} value={p.id}>{p.name}</option>
  190. ))}
  191. </select>
  192. </div>
  193. {/* Queue Options */}
  194. <div>
  195. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
  196. <div className="space-y-2">
  197. <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
  198. <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} t={t} />
  199. <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
  200. </div>
  201. </div>
  202. {/* Print Options */}
  203. <div>
  204. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
  205. <div className="space-y-2">
  206. <TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
  207. <TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
  208. <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
  209. <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
  210. <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
  211. <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
  212. </div>
  213. </div>
  214. </div>
  215. <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  216. <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
  217. <Button
  218. onClick={handleSave}
  219. disabled={!hasChanges || isSaving}
  220. >
  221. {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
  222. </Button>
  223. </div>
  224. </div>
  225. </div>
  226. );
  227. }
  228. // Tri-state toggle for bulk edit (unchanged / on / off)
  229. function TriStateToggle({
  230. label,
  231. value,
  232. onChange,
  233. t,
  234. }: {
  235. label: string;
  236. value: boolean | 'unchanged';
  237. onChange: (val: boolean | 'unchanged') => void;
  238. t: (key: string) => string;
  239. }) {
  240. return (
  241. <div className="flex items-center justify-between py-1">
  242. <span className="text-sm text-bambu-gray">{label}</span>
  243. <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
  244. <button
  245. onClick={() => onChange('unchanged')}
  246. className={`px-2 py-1 text-xs rounded ${value === 'unchanged' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'}`}
  247. >
  248. —
  249. </button>
  250. <button
  251. onClick={() => onChange(false)}
  252. className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'}`}
  253. >
  254. {t('common.off')}
  255. </button>
  256. <button
  257. onClick={() => onChange(true)}
  258. className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'}`}
  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. const canReorder = hasPermission('queue:reorder');
  301. const {
  302. attributes,
  303. listeners,
  304. setNodeRef,
  305. transform,
  306. transition,
  307. isDragging,
  308. } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
  309. const style = {
  310. transform: CSS.Transform.toString(transform),
  311. transition,
  312. };
  313. const isPrinting = item.status === 'printing';
  314. const isPending = item.status === 'pending';
  315. const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
  316. return (
  317. <div
  318. ref={setNodeRef}
  319. style={style}
  320. className={`
  321. group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
  322. transition-all duration-200 hover:border-bambu-dark-tertiary/80
  323. ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
  324. ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
  325. `}
  326. >
  327. <div className="flex items-center gap-4 p-4">
  328. {/* Selection checkbox for pending items */}
  329. {isPending && onToggleSelect && (
  330. <button
  331. onClick={(e) => {
  332. e.stopPropagation();
  333. onToggleSelect();
  334. }}
  335. className={`flex items-center justify-center w-6 h-6 rounded border transition-colors ${
  336. isSelected
  337. ? 'bg-bambu-green border-bambu-green text-white'
  338. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  339. }`}
  340. >
  341. {isSelected && <Check className="w-4 h-4" />}
  342. </button>
  343. )}
  344. {/* Drag handle or position number */}
  345. {isPending ? (
  346. <div
  347. {...attributes}
  348. {...listeners}
  349. className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
  350. >
  351. <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
  352. </div>
  353. ) : position !== undefined ? (
  354. <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
  355. #{position}
  356. </div>
  357. ) : (
  358. <div className="w-8" />
  359. )}
  360. {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
  361. <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  362. {item.archive_thumbnail ? (
  363. <img
  364. src={
  365. item.plate_id != null
  366. ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
  367. : api.getArchiveThumbnail(item.archive_id!)
  368. }
  369. alt=""
  370. className="w-full h-full object-cover"
  371. />
  372. ) : item.library_file_thumbnail ? (
  373. <img
  374. src={
  375. item.plate_id != null
  376. ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
  377. : api.getLibraryFileThumbnailUrl(item.library_file_id!)
  378. }
  379. alt=""
  380. className="w-full h-full object-cover"
  381. />
  382. ) : (
  383. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  384. <Layers className="w-6 h-6" />
  385. </div>
  386. )}
  387. </div>
  388. {/* Info */}
  389. <div className="flex-1 min-w-0">
  390. <div className="flex items-center gap-2 mb-1">
  391. <p className="text-white font-medium truncate">
  392. {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
  393. </p>
  394. {item.archive_id ? (
  395. <Link
  396. to={`/archives?highlight=${item.archive_id}`}
  397. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  398. title={t('queue.viewArchive')}
  399. >
  400. <ExternalLink className="w-3.5 h-3.5" />
  401. </Link>
  402. ) : item.library_file_id ? (
  403. <Link
  404. to={`/library?highlight=${item.library_file_id}`}
  405. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  406. title={t('queue.viewInFileManager')}
  407. >
  408. <ExternalLink className="w-3.5 h-3.5" />
  409. </Link>
  410. ) : null}
  411. </div>
  412. <div className="flex items-center gap-3 text-sm text-bambu-gray">
  413. <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
  414. <Printer className="w-3.5 h-3.5" />
  415. {item.target_model
  416. ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
  417. : item.printer_id === null
  418. ? t('queue.filter.unassigned')
  419. : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
  420. </span>
  421. {item.print_time_seconds && (
  422. <span className="flex items-center gap-1.5">
  423. <Timer className="w-3.5 h-3.5" />
  424. {formatDuration(item.print_time_seconds)}
  425. </span>
  426. )}
  427. {item.created_by_username && (
  428. <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
  429. <User className="w-3.5 h-3.5" />
  430. {item.created_by_username}
  431. </span>
  432. )}
  433. {isPending && !item.manual_start && (
  434. <span className="flex items-center gap-1.5">
  435. <Clock className="w-3.5 h-3.5" />
  436. {formatRelativeTime(item.scheduled_time, timeFormat, t)}
  437. </span>
  438. )}
  439. </div>
  440. {/* Options badges */}
  441. <div className="flex items-center gap-2 mt-2">
  442. {item.manual_start && (
  443. <span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
  444. <Hand className="w-3 h-3" />
  445. {t('queue.badges.staged')}
  446. </span>
  447. )}
  448. {item.require_previous_success && (
  449. <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
  450. {t('queue.badges.requiresPrevious')}
  451. </span>
  452. )}
  453. {item.auto_off_after && (
  454. <span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
  455. <Power className="w-3 h-3" />
  456. {t('queue.badges.autoPowerOff')}
  457. </span>
  458. )}
  459. </div>
  460. {/* Progress bar for printing items - TODO: integrate with WebSocket */}
  461. {isPrinting && (
  462. <div className="mt-3">
  463. <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
  464. <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
  465. </div>
  466. <p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
  467. </div>
  468. )}
  469. {/* Waiting reason for model-based assignments */}
  470. {item.waiting_reason && item.status === 'pending' && (
  471. <p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
  472. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  473. <span>{item.waiting_reason}</span>
  474. </p>
  475. )}
  476. {/* Error message */}
  477. {item.error_message && (
  478. <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
  479. <AlertCircle className="w-3 h-3" />
  480. {item.error_message}
  481. </p>
  482. )}
  483. </div>
  484. {/* Status badge */}
  485. <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
  486. {/* Actions */}
  487. <div className="flex items-center gap-1">
  488. {isPrinting && (
  489. <Button
  490. variant="ghost"
  491. size="sm"
  492. onClick={onStop}
  493. disabled={!hasPermission('printers:control')}
  494. title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
  495. className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
  496. >
  497. <StopCircle className="w-4 h-4" />
  498. </Button>
  499. )}
  500. {isPending && (
  501. <>
  502. {item.manual_start && (
  503. <Button
  504. variant="ghost"
  505. size="sm"
  506. onClick={onStart}
  507. disabled={!hasPermission('printers:control')}
  508. title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
  509. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
  510. >
  511. <Play className="w-4 h-4" />
  512. </Button>
  513. )}
  514. <Button
  515. variant="ghost"
  516. size="sm"
  517. onClick={onEdit}
  518. disabled={!canModify('queue', 'update', item.created_by_id)}
  519. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
  520. >
  521. <Pencil className="w-4 h-4" />
  522. </Button>
  523. <Button
  524. variant="ghost"
  525. size="sm"
  526. onClick={onCancel}
  527. disabled={!canModify('queue', 'delete', item.created_by_id)}
  528. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
  529. className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
  530. >
  531. <X className="w-4 h-4" />
  532. </Button>
  533. </>
  534. )}
  535. {isHistory && (
  536. <>
  537. <Button
  538. variant="ghost"
  539. size="sm"
  540. onClick={onRequeue}
  541. disabled={!hasPermission('queue:create')}
  542. title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
  543. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
  544. >
  545. <RefreshCw className="w-4 h-4" />
  546. </Button>
  547. <Button
  548. variant="ghost"
  549. size="sm"
  550. onClick={onRemove}
  551. disabled={!canModify('queue', 'delete', item.created_by_id)}
  552. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
  553. >
  554. <Trash2 className="w-4 h-4" />
  555. </Button>
  556. </>
  557. )}
  558. </div>
  559. </div>
  560. </div>
  561. );
  562. }
  563. export function QueuePage() {
  564. const { t } = useTranslation();
  565. const queryClient = useQueryClient();
  566. const { showToast } = useToast();
  567. const { hasPermission, hasAnyPermission, canModify } = useAuth();
  568. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  569. const [filterStatus, setFilterStatus] = useState<string>('');
  570. const [filterLocation, setFilterLocation] = useState<string>('');
  571. const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
  572. const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
  573. const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
  574. const [confirmAction, setConfirmAction] = useState<{
  575. type: 'cancel' | 'remove' | 'stop';
  576. item: PrintQueueItem;
  577. } | null>(null);
  578. const [selectedItems, setSelectedItems] = useState<number[]>([]);
  579. const [showBulkEditModal, setShowBulkEditModal] = useState(false);
  580. const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
  581. const saved = localStorage.getItem('queue.historySortBy');
  582. return (saved as 'date' | 'name' | 'printer') || 'date';
  583. });
  584. const [historySortAsc, setHistorySortAsc] = useState(() => {
  585. const saved = localStorage.getItem('queue.historySortAsc');
  586. return saved !== null ? saved === 'true' : false;
  587. });
  588. const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
  589. const saved = localStorage.getItem('queue.pendingSortBy');
  590. return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
  591. });
  592. const [pendingSortAsc, setPendingSortAsc] = useState(() => {
  593. const saved = localStorage.getItem('queue.pendingSortAsc');
  594. return saved !== null ? saved === 'true' : true;
  595. });
  596. // Persist sort settings to localStorage
  597. useEffect(() => {
  598. localStorage.setItem('queue.historySortBy', historySortBy);
  599. }, [historySortBy]);
  600. useEffect(() => {
  601. localStorage.setItem('queue.historySortAsc', String(historySortAsc));
  602. }, [historySortAsc]);
  603. useEffect(() => {
  604. localStorage.setItem('queue.pendingSortBy', pendingSortBy);
  605. }, [pendingSortBy]);
  606. useEffect(() => {
  607. localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
  608. }, [pendingSortAsc]);
  609. const sensors = useSensors(
  610. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  611. useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  612. );
  613. const { data: settings } = useQuery({
  614. queryKey: ['settings'],
  615. queryFn: api.getSettings,
  616. });
  617. const timeFormat: TimeFormat = settings?.time_format || 'system';
  618. const { data: queue, isLoading } = useQuery({
  619. queryKey: ['queue', filterPrinter, filterStatus],
  620. queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
  621. refetchInterval: 5000,
  622. });
  623. const { data: printers } = useQuery({
  624. queryKey: ['printers'],
  625. queryFn: () => api.getPrinters(),
  626. });
  627. const cancelMutation = useMutation({
  628. mutationFn: (id: number) => api.cancelQueueItem(id),
  629. onSuccess: () => {
  630. queryClient.invalidateQueries({ queryKey: ['queue'] });
  631. showToast(t('queue.toast.cancelled'));
  632. },
  633. onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
  634. });
  635. const removeMutation = useMutation({
  636. mutationFn: (id: number) => api.removeFromQueue(id),
  637. onSuccess: () => {
  638. queryClient.invalidateQueries({ queryKey: ['queue'] });
  639. showToast(t('queue.toast.removed'));
  640. },
  641. onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
  642. });
  643. const stopMutation = useMutation({
  644. mutationFn: (id: number) => api.stopQueueItem(id),
  645. onSuccess: () => {
  646. queryClient.invalidateQueries({ queryKey: ['queue'] });
  647. showToast(t('queue.toast.stopped'));
  648. },
  649. onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
  650. });
  651. const startMutation = useMutation({
  652. mutationFn: (id: number) => api.startQueueItem(id),
  653. onSuccess: () => {
  654. queryClient.invalidateQueries({ queryKey: ['queue'] });
  655. showToast(t('queue.toast.released'));
  656. },
  657. onError: () => showToast(t('queue.toast.startFailed'), 'error'),
  658. });
  659. const reorderMutation = useMutation({
  660. mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
  661. onSuccess: () => {
  662. queryClient.invalidateQueries({ queryKey: ['queue'] });
  663. },
  664. onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
  665. });
  666. const clearHistoryMutation = useMutation({
  667. mutationFn: async () => {
  668. const historyItems = queue?.filter(i =>
  669. ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
  670. ) || [];
  671. for (const item of historyItems) {
  672. await api.removeFromQueue(item.id);
  673. }
  674. return historyItems.length;
  675. },
  676. onSuccess: (count) => {
  677. queryClient.invalidateQueries({ queryKey: ['queue'] });
  678. showToast(t('queue.toast.historyCleared', { count }));
  679. },
  680. onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
  681. });
  682. const bulkUpdateMutation = useMutation({
  683. mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
  684. onSuccess: (result) => {
  685. queryClient.invalidateQueries({ queryKey: ['queue'] });
  686. setSelectedItems([]);
  687. setShowBulkEditModal(false);
  688. showToast(result.message);
  689. },
  690. onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
  691. });
  692. const bulkCancelMutation = useMutation({
  693. mutationFn: async (ids: number[]) => {
  694. for (const id of ids) {
  695. await api.cancelQueueItem(id);
  696. }
  697. return ids.length;
  698. },
  699. onSuccess: (count) => {
  700. queryClient.invalidateQueries({ queryKey: ['queue'] });
  701. setSelectedItems([]);
  702. showToast(t('queue.toast.bulkCancelled', { count }));
  703. },
  704. onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
  705. });
  706. const handleToggleSelect = (id: number) => {
  707. setSelectedItems(prev =>
  708. prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
  709. );
  710. };
  711. // Get unique locations from printers for the filter dropdown
  712. const uniqueLocations = useMemo(() => {
  713. const locations = new Set<string>();
  714. printers?.forEach(p => {
  715. if (p.location) locations.add(p.location);
  716. });
  717. // Also include locations from queue items (for model-based assignments)
  718. queue?.forEach(item => {
  719. if (item.target_location) locations.add(item.target_location);
  720. });
  721. return Array.from(locations).sort();
  722. }, [printers, queue]);
  723. // Helper to check if a queue item matches the location filter
  724. const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
  725. if (!filterLocation) return true;
  726. // For model-based assignments, check target_location
  727. if (item.target_location) return item.target_location === filterLocation;
  728. // For printer-based assignments, check the printer's location
  729. if (item.printer_id) {
  730. const printer = printers?.find(p => p.id === item.printer_id);
  731. return printer?.location === filterLocation;
  732. }
  733. return false;
  734. }, [filterLocation, printers]);
  735. const pendingItems = useMemo(() => {
  736. let items = queue?.filter(i => i.status === 'pending') || [];
  737. // Apply location filter
  738. if (filterLocation) {
  739. items = items.filter(matchesLocationFilter);
  740. }
  741. // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
  742. const getScheduledTime = (item: PrintQueueItem): number => {
  743. if (!item.scheduled_time) return 0;
  744. const time = new Date(item.scheduled_time).getTime();
  745. // Placeholder dates (> 6 months out) are treated as ASAP
  746. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  747. return time > sixMonthsFromNow ? 0 : time;
  748. };
  749. return [...items].sort((a, b) => {
  750. let cmp: number;
  751. if (pendingSortBy === 'name') {
  752. const aName = a.archive_name || a.library_file_name || '';
  753. const bName = b.archive_name || b.library_file_name || '';
  754. cmp = aName.localeCompare(bName);
  755. } else if (pendingSortBy === 'printer') {
  756. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  757. } else if (pendingSortBy === 'time') {
  758. // Sort by scheduled start time (when print will begin)
  759. cmp = getScheduledTime(a) - getScheduledTime(b);
  760. } else {
  761. cmp = a.position - b.position;
  762. }
  763. return pendingSortAsc ? cmp : -cmp;
  764. });
  765. }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
  766. const handleSelectAll = () => {
  767. const allPendingIds = pendingItems.map(i => i.id);
  768. if (selectedItems.length === allPendingIds.length) {
  769. setSelectedItems([]);
  770. } else {
  771. setSelectedItems(allPendingIds);
  772. }
  773. };
  774. const activeItems = useMemo(() => {
  775. let items = queue?.filter(i => i.status === 'printing') || [];
  776. if (filterLocation) {
  777. items = items.filter(matchesLocationFilter);
  778. }
  779. return items;
  780. }, [queue, filterLocation, matchesLocationFilter]);
  781. // Get unique printer IDs from active items to fetch their statuses
  782. const activePrinterIds = useMemo(() => {
  783. const ids = new Set<number>();
  784. activeItems.forEach(item => {
  785. if (item.printer_id) ids.add(item.printer_id);
  786. });
  787. return Array.from(ids);
  788. }, [activeItems]);
  789. // Fetch printer statuses for printers with active jobs
  790. const printerStatusQueries = useQueries({
  791. queries: activePrinterIds.map(printerId => ({
  792. queryKey: ['printerStatus', printerId],
  793. queryFn: () => api.getPrinterStatus(printerId),
  794. refetchInterval: 5000,
  795. })),
  796. });
  797. // Build a map of printer_id -> state for quick lookup
  798. const printerStateMap = useMemo(() => {
  799. const map: Record<number, string | null> = {};
  800. activePrinterIds.forEach((printerId, index) => {
  801. const result = printerStatusQueries[index];
  802. if (result?.data?.state) {
  803. map[printerId] = result.data.state;
  804. }
  805. });
  806. return map;
  807. }, [activePrinterIds, printerStatusQueries]);
  808. const historyItems = useMemo(() => {
  809. let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
  810. if (filterLocation) {
  811. items = items.filter(matchesLocationFilter);
  812. }
  813. return [...items].sort((a, b) => {
  814. let cmp: number;
  815. if (historySortBy === 'name') {
  816. const aName = a.archive_name || a.library_file_name || '';
  817. const bName = b.archive_name || b.library_file_name || '';
  818. cmp = aName.localeCompare(bName);
  819. } else if (historySortBy === 'printer') {
  820. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  821. } else {
  822. // Default: by date - most recent first (desc) is the natural order
  823. cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
  824. }
  825. return historySortAsc ? -cmp : cmp;
  826. });
  827. }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
  828. // Calculate total queue time
  829. const totalQueueTime = useMemo(() => {
  830. return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
  831. }, [pendingItems]);
  832. const handleDragEnd = (event: DragEndEvent) => {
  833. const { active, over } = event;
  834. if (!over || active.id === over.id) return;
  835. const oldIndex = pendingItems.findIndex(i => i.id === active.id);
  836. const newIndex = pendingItems.findIndex(i => i.id === over.id);
  837. if (oldIndex !== -1 && newIndex !== -1) {
  838. const reordered = arrayMove(pendingItems, oldIndex, newIndex);
  839. const updates = reordered.map((item, index) => ({
  840. id: item.id,
  841. position: index + 1,
  842. }));
  843. reorderMutation.mutate(updates);
  844. }
  845. };
  846. return (
  847. <div className="p-4 md:p-8">
  848. {/* Header */}
  849. <div className="flex items-center justify-between mb-8">
  850. <div>
  851. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  852. <ListOrdered className="w-7 h-7 text-bambu-green" />
  853. {t('queue.title')}
  854. </h1>
  855. <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
  856. </div>
  857. </div>
  858. {/* Summary Cards */}
  859. <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
  860. <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
  861. <CardContent className="p-4">
  862. <div className="flex items-center gap-3">
  863. <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
  864. <Play className="w-5 h-5 text-blue-400" />
  865. </div>
  866. <div>
  867. <p className="text-2xl font-bold text-white">{activeItems.length}</p>
  868. <p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
  869. </div>
  870. </div>
  871. </CardContent>
  872. </Card>
  873. <Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
  874. <CardContent className="p-4">
  875. <div className="flex items-center gap-3">
  876. <div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
  877. <Clock className="w-5 h-5 text-yellow-400" />
  878. </div>
  879. <div>
  880. <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
  881. <p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
  882. </div>
  883. </div>
  884. </CardContent>
  885. </Card>
  886. <Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
  887. <CardContent className="p-4">
  888. <div className="flex items-center gap-3">
  889. <div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
  890. <Timer className="w-5 h-5 text-bambu-green" />
  891. </div>
  892. <div>
  893. <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
  894. <p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
  895. </div>
  896. </div>
  897. </CardContent>
  898. </Card>
  899. <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
  900. <CardContent className="p-4">
  901. <div className="flex items-center gap-3">
  902. <div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
  903. <CheckCircle className="w-5 h-5 text-gray-400" />
  904. </div>
  905. <div>
  906. <p className="text-2xl font-bold text-white">{historyItems.length}</p>
  907. <p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
  908. </div>
  909. </div>
  910. </CardContent>
  911. </Card>
  912. </div>
  913. {/* Filters */}
  914. <div className="flex items-center gap-4 mb-6">
  915. <select
  916. className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  917. value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
  918. onChange={(e) => {
  919. const val = e.target.value;
  920. if (val === 'unassigned') setFilterPrinter(-1);
  921. else if (val === '') setFilterPrinter(null);
  922. else setFilterPrinter(Number(val));
  923. }}
  924. >
  925. <option value="">{t('queue.filter.allPrinters')}</option>
  926. <option value="unassigned">{t('queue.filter.unassigned')}</option>
  927. {printers?.map((p) => (
  928. <option key={p.id} value={p.id}>{p.name}</option>
  929. ))}
  930. </select>
  931. <select
  932. className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  933. value={filterStatus}
  934. onChange={(e) => setFilterStatus(e.target.value)}
  935. >
  936. <option value="">{t('queue.filter.allStatus')}</option>
  937. <option value="pending">{t('queue.status.pending')}</option>
  938. <option value="printing">{t('queue.status.printing')}</option>
  939. <option value="completed">{t('queue.status.completed')}</option>
  940. <option value="failed">{t('queue.status.failed')}</option>
  941. <option value="skipped">{t('queue.status.skipped')}</option>
  942. <option value="cancelled">{t('queue.status.cancelled')}</option>
  943. </select>
  944. {uniqueLocations.length > 0 && (
  945. <select
  946. className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  947. value={filterLocation}
  948. onChange={(e) => setFilterLocation(e.target.value)}
  949. >
  950. <option value="">{t('queue.filter.allLocations')}</option>
  951. {uniqueLocations.map((loc) => (
  952. <option key={loc} value={loc}>{loc}</option>
  953. ))}
  954. </select>
  955. )}
  956. <div className="flex-1" />
  957. {historyItems.length > 0 && (
  958. <Button
  959. variant="secondary"
  960. size="sm"
  961. onClick={() => setShowClearHistoryConfirm(true)}
  962. disabled={!hasPermission('queue:delete_all')}
  963. title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
  964. >
  965. <Trash2 className="w-4 h-4" />
  966. {t('queue.clearHistory')}
  967. </Button>
  968. )}
  969. </div>
  970. {isLoading ? (
  971. <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
  972. ) : queue?.length === 0 ? (
  973. <Card className="p-12 text-center border-dashed">
  974. <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  975. <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
  976. <p className="text-bambu-gray max-w-md mx-auto">
  977. {t('queue.empty.description')}
  978. </p>
  979. </Card>
  980. ) : (
  981. <div className="space-y-8">
  982. {/* Active Prints */}
  983. {activeItems.length > 0 && (
  984. <div>
  985. <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
  986. <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
  987. {t('queue.sections.currentlyPrinting')}
  988. </h2>
  989. <div className="space-y-3">
  990. {activeItems.map((item) => (
  991. <SortableQueueItem
  992. key={item.id}
  993. item={item}
  994. onEdit={() => {}}
  995. onCancel={() => {}}
  996. onRemove={() => {}}
  997. onStop={() => setConfirmAction({ type: 'stop', item })}
  998. onRequeue={() => {}}
  999. onStart={() => {}}
  1000. timeFormat={timeFormat}
  1001. hasPermission={hasPermission}
  1002. canModify={canModify}
  1003. printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
  1004. t={t}
  1005. />
  1006. ))}
  1007. </div>
  1008. </div>
  1009. )}
  1010. {/* Pending Queue */}
  1011. {pendingItems.length > 0 && (
  1012. <div>
  1013. <div className="flex items-center justify-between mb-4">
  1014. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1015. <Clock className="w-5 h-5 text-yellow-400" />
  1016. {t('queue.sections.queued')}
  1017. <span className="text-sm font-normal text-bambu-gray">
  1018. ({t('queue.itemCount', { count: pendingItems.length })})
  1019. </span>
  1020. <span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
  1021. {t('queue.dragToReorder')}
  1022. </span>
  1023. </h2>
  1024. <div className="flex items-center gap-2">
  1025. <select
  1026. className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1027. value={pendingSortBy}
  1028. onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
  1029. >
  1030. <option value="position">{t('queue.sort.byPosition')}</option>
  1031. <option value="name">{t('queue.sort.byName')}</option>
  1032. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1033. <option value="time">{t('queue.sort.bySchedule')}</option>
  1034. </select>
  1035. <Button
  1036. variant="ghost"
  1037. size="sm"
  1038. onClick={() => setPendingSortAsc(!pendingSortAsc)}
  1039. title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
  1040. className="px-2"
  1041. >
  1042. {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1043. </Button>
  1044. </div>
  1045. </div>
  1046. {/* Bulk action toolbar */}
  1047. <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-dark rounded-lg">
  1048. <Button
  1049. variant="ghost"
  1050. size="sm"
  1051. onClick={handleSelectAll}
  1052. className="flex items-center gap-2"
  1053. >
  1054. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
  1055. <CheckSquare className="w-4 h-4 text-bambu-green" />
  1056. ) : (
  1057. <Square className="w-4 h-4" />
  1058. )}
  1059. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  1060. </Button>
  1061. {selectedItems.length > 0 && (
  1062. <>
  1063. <span className="text-sm text-bambu-gray">
  1064. {t('queue.bulkEdit.selected', { count: selectedItems.length })}
  1065. </span>
  1066. <div className="h-4 w-px bg-bambu-dark-tertiary" />
  1067. <Button
  1068. variant="ghost"
  1069. size="sm"
  1070. onClick={() => setShowBulkEditModal(true)}
  1071. className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
  1072. disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
  1073. title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
  1074. >
  1075. <Pencil className="w-4 h-4" />
  1076. {t('queue.bulkEdit.editSelected')}
  1077. </Button>
  1078. <Button
  1079. variant="ghost"
  1080. size="sm"
  1081. onClick={() => bulkCancelMutation.mutate(selectedItems)}
  1082. className="flex items-center gap-2 text-red-400 hover:text-red-300"
  1083. disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
  1084. title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
  1085. >
  1086. <X className="w-4 h-4" />
  1087. {t('queue.bulkEdit.cancelSelected')}
  1088. </Button>
  1089. </>
  1090. )}
  1091. </div>
  1092. <DndContext
  1093. sensors={sensors}
  1094. collisionDetection={closestCenter}
  1095. onDragEnd={handleDragEnd}
  1096. >
  1097. <SortableContext
  1098. items={pendingItems.map(i => i.id)}
  1099. strategy={verticalListSortingStrategy}
  1100. >
  1101. <div className="space-y-3">
  1102. {pendingItems.map((item, index) => (
  1103. <SortableQueueItem
  1104. key={item.id}
  1105. item={item}
  1106. position={index + 1}
  1107. onEdit={() => setEditItem(item)}
  1108. onCancel={() => setConfirmAction({ type: 'cancel', item })}
  1109. onRemove={() => {}}
  1110. onStop={() => {}}
  1111. onRequeue={() => {}}
  1112. onStart={() => startMutation.mutate(item.id)}
  1113. timeFormat={timeFormat}
  1114. isSelected={selectedItems.includes(item.id)}
  1115. onToggleSelect={() => handleToggleSelect(item.id)}
  1116. hasPermission={hasPermission}
  1117. canModify={canModify}
  1118. t={t}
  1119. />
  1120. ))}
  1121. </div>
  1122. </SortableContext>
  1123. </DndContext>
  1124. </div>
  1125. )}
  1126. {/* History */}
  1127. {historyItems.length > 0 && (
  1128. <div>
  1129. <div className="flex items-center justify-between mb-4">
  1130. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1131. <CheckCircle className="w-5 h-5 text-bambu-gray" />
  1132. {t('queue.sections.history')}
  1133. <span className="text-sm font-normal text-bambu-gray">
  1134. ({t('queue.itemCount', { count: historyItems.length })})
  1135. </span>
  1136. </h2>
  1137. <div className="flex items-center gap-2">
  1138. <select
  1139. className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1140. value={historySortBy}
  1141. onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
  1142. >
  1143. <option value="date">{t('queue.sort.byDate')}</option>
  1144. <option value="name">{t('queue.sort.byName')}</option>
  1145. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1146. </select>
  1147. <Button
  1148. variant="ghost"
  1149. size="sm"
  1150. onClick={() => setHistorySortAsc(!historySortAsc)}
  1151. title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
  1152. className="px-2"
  1153. >
  1154. {historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1155. </Button>
  1156. </div>
  1157. </div>
  1158. <div className="space-y-3">
  1159. {historyItems.slice(0, 20).map((item, index) => (
  1160. <SortableQueueItem
  1161. key={item.id}
  1162. item={item}
  1163. position={index + 1}
  1164. onEdit={() => {}}
  1165. onCancel={() => {}}
  1166. onRemove={() => setConfirmAction({ type: 'remove', item })}
  1167. onStop={() => {}}
  1168. onRequeue={() => setRequeueItem(item)}
  1169. onStart={() => {}}
  1170. timeFormat={timeFormat}
  1171. hasPermission={hasPermission}
  1172. canModify={canModify}
  1173. t={t}
  1174. />
  1175. ))}
  1176. </div>
  1177. </div>
  1178. )}
  1179. </div>
  1180. )}
  1181. {/* Edit Modal */}
  1182. {editItem && (
  1183. <PrintModal
  1184. mode="edit-queue-item"
  1185. archiveId={editItem.archive_id ?? undefined}
  1186. libraryFileId={editItem.library_file_id ?? undefined}
  1187. archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
  1188. queueItem={editItem}
  1189. onClose={() => setEditItem(null)}
  1190. />
  1191. )}
  1192. {/* Re-queue Modal */}
  1193. {requeueItem && (
  1194. <PrintModal
  1195. mode="add-to-queue"
  1196. archiveId={requeueItem.archive_id ?? undefined}
  1197. libraryFileId={requeueItem.library_file_id ?? undefined}
  1198. archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
  1199. onClose={() => setRequeueItem(null)}
  1200. />
  1201. )}
  1202. {/* Confirm Action Modal */}
  1203. {confirmAction && (
  1204. <ConfirmModal
  1205. title={
  1206. confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
  1207. confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
  1208. t('queue.confirm.removeTitle')
  1209. }
  1210. message={
  1211. confirmAction.type === 'cancel'
  1212. ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  1213. : confirmAction.type === 'stop'
  1214. ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  1215. : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
  1216. }
  1217. confirmText={
  1218. confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
  1219. confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
  1220. t('common.remove')
  1221. }
  1222. variant="danger"
  1223. onConfirm={() => {
  1224. if (confirmAction.type === 'cancel') {
  1225. cancelMutation.mutate(confirmAction.item.id);
  1226. } else if (confirmAction.type === 'stop') {
  1227. stopMutation.mutate(confirmAction.item.id);
  1228. } else {
  1229. removeMutation.mutate(confirmAction.item.id);
  1230. }
  1231. setConfirmAction(null);
  1232. }}
  1233. onCancel={() => setConfirmAction(null)}
  1234. />
  1235. )}
  1236. {/* Clear History Confirm Modal */}
  1237. {showClearHistoryConfirm && (
  1238. <ConfirmModal
  1239. title={t('queue.confirm.clearHistoryTitle')}
  1240. message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
  1241. confirmText={t('queue.clearHistory')}
  1242. variant="danger"
  1243. onConfirm={() => {
  1244. clearHistoryMutation.mutate();
  1245. setShowClearHistoryConfirm(false);
  1246. }}
  1247. onCancel={() => setShowClearHistoryConfirm(false)}
  1248. />
  1249. )}
  1250. {/* Bulk Edit Modal */}
  1251. {showBulkEditModal && (
  1252. <BulkEditModal
  1253. selectedCount={selectedItems.length}
  1254. printers={printers?.map(p => ({ id: p.id, name: p.name })) || []}
  1255. onSave={(data) => {
  1256. if (Object.keys(data).length > 0) {
  1257. bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
  1258. }
  1259. }}
  1260. onClose={() => setShowBulkEditModal(false)}
  1261. isSaving={bulkUpdateMutation.isPending}
  1262. t={t}
  1263. />
  1264. )}
  1265. </div>
  1266. );
  1267. }