QueuePage.tsx 61 KB

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