QueuePage.tsx 57 KB

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