QueuePage.tsx 61 KB

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