QueuePage.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
  1. import { useState, useMemo, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Link } from 'react-router-dom';
  4. import {
  5. DndContext,
  6. closestCenter,
  7. KeyboardSensor,
  8. PointerSensor,
  9. useSensor,
  10. useSensors,
  11. } from '@dnd-kit/core';
  12. import type { DragEndEvent } from '@dnd-kit/core';
  13. import {
  14. arrayMove,
  15. SortableContext,
  16. sortableKeyboardCoordinates,
  17. useSortable,
  18. verticalListSortingStrategy,
  19. } from '@dnd-kit/sortable';
  20. import { CSS } from '@dnd-kit/utilities';
  21. import {
  22. Clock,
  23. Trash2,
  24. Play,
  25. X,
  26. CheckCircle,
  27. XCircle,
  28. AlertCircle,
  29. Calendar,
  30. Printer,
  31. GripVertical,
  32. SkipForward,
  33. ExternalLink,
  34. Power,
  35. StopCircle,
  36. Pencil,
  37. RefreshCw,
  38. Timer,
  39. ListOrdered,
  40. Layers,
  41. ArrowUp,
  42. ArrowDown,
  43. Hand,
  44. } from 'lucide-react';
  45. import { api } from '../api/client';
  46. import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
  47. import type { PrintQueueItem } from '../api/client';
  48. import { Card, CardContent } from '../components/Card';
  49. import { Button } from '../components/Button';
  50. import { ConfirmModal } from '../components/ConfirmModal';
  51. import { EditQueueItemModal } from '../components/EditQueueItemModal';
  52. import { AddToQueueModal } from '../components/AddToQueueModal';
  53. import { useToast } from '../contexts/ToastContext';
  54. function formatDuration(seconds: number | null | undefined): string {
  55. if (!seconds) return '--';
  56. const hours = Math.floor(seconds / 3600);
  57. const minutes = Math.floor((seconds % 3600) / 60);
  58. if (hours > 0) return `${hours}h ${minutes}m`;
  59. return `${minutes}m`;
  60. }
  61. function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
  62. if (!dateString) return 'ASAP';
  63. const date = parseUTCDate(dateString);
  64. if (!date) return 'ASAP';
  65. const now = new Date();
  66. const diff = date.getTime() - now.getTime();
  67. if (diff < -60000) return 'Overdue';
  68. if (diff < 0) return 'Now';
  69. if (diff < 60000) return 'In less than a minute';
  70. if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
  71. if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
  72. return formatDateTime(dateString, timeFormat);
  73. }
  74. function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
  75. const config = {
  76. pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', label: 'Pending' },
  77. printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
  78. completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10 border-green-400/20', label: 'Completed' },
  79. failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10 border-red-400/20', label: 'Failed' },
  80. skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
  81. cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
  82. };
  83. const { icon: Icon, color, label } = config[status];
  84. return (
  85. <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
  86. <Icon className="w-3.5 h-3.5" />
  87. {label}
  88. </span>
  89. );
  90. }
  91. // Sortable queue item for drag and drop
  92. function SortableQueueItem({
  93. item,
  94. position,
  95. onEdit,
  96. onCancel,
  97. onRemove,
  98. onStop,
  99. onRequeue,
  100. onStart,
  101. timeFormat = 'system',
  102. }: {
  103. item: PrintQueueItem;
  104. position?: number;
  105. onEdit: () => void;
  106. onCancel: () => void;
  107. onRemove: () => void;
  108. onStop: () => void;
  109. onRequeue: () => void;
  110. onStart: () => void;
  111. timeFormat?: TimeFormat;
  112. }) {
  113. const {
  114. attributes,
  115. listeners,
  116. setNodeRef,
  117. transform,
  118. transition,
  119. isDragging,
  120. } = useSortable({ id: item.id, disabled: item.status !== 'pending' });
  121. const style = {
  122. transform: CSS.Transform.toString(transform),
  123. transition,
  124. };
  125. const isPrinting = item.status === 'printing';
  126. const isPending = item.status === 'pending';
  127. const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
  128. return (
  129. <div
  130. ref={setNodeRef}
  131. style={style}
  132. className={`
  133. group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
  134. transition-all duration-200 hover:border-bambu-dark-tertiary/80
  135. ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
  136. ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
  137. `}
  138. >
  139. <div className="flex items-center gap-4 p-4">
  140. {/* Drag handle or position number */}
  141. {isPending ? (
  142. <div
  143. {...attributes}
  144. {...listeners}
  145. 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"
  146. >
  147. <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
  148. </div>
  149. ) : position !== undefined ? (
  150. <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
  151. #{position}
  152. </div>
  153. ) : (
  154. <div className="w-8" />
  155. )}
  156. {/* Thumbnail */}
  157. <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  158. {item.archive_thumbnail ? (
  159. <img
  160. src={api.getArchiveThumbnail(item.archive_id)}
  161. alt=""
  162. className="w-full h-full object-cover"
  163. />
  164. ) : (
  165. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  166. <Layers className="w-6 h-6" />
  167. </div>
  168. )}
  169. </div>
  170. {/* Info */}
  171. <div className="flex-1 min-w-0">
  172. <div className="flex items-center gap-2 mb-1">
  173. <p className="text-white font-medium truncate">
  174. {item.archive_name || `Archive #${item.archive_id}`}
  175. </p>
  176. <Link
  177. to={`/archives?highlight=${item.archive_id}`}
  178. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  179. title="View archive"
  180. >
  181. <ExternalLink className="w-3.5 h-3.5" />
  182. </Link>
  183. </div>
  184. <div className="flex items-center gap-3 text-sm text-bambu-gray">
  185. <span className="flex items-center gap-1.5">
  186. <Printer className="w-3.5 h-3.5" />
  187. {item.printer_name || `Printer #${item.printer_id}`}
  188. </span>
  189. {item.print_time_seconds && (
  190. <span className="flex items-center gap-1.5">
  191. <Timer className="w-3.5 h-3.5" />
  192. {formatDuration(item.print_time_seconds)}
  193. </span>
  194. )}
  195. {isPending && !item.manual_start && (
  196. <span className="flex items-center gap-1.5">
  197. <Clock className="w-3.5 h-3.5" />
  198. {formatRelativeTime(item.scheduled_time, timeFormat)}
  199. </span>
  200. )}
  201. </div>
  202. {/* Options badges */}
  203. <div className="flex items-center gap-2 mt-2">
  204. {item.manual_start && (
  205. <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">
  206. <Hand className="w-3 h-3" />
  207. Staged
  208. </span>
  209. )}
  210. {item.require_previous_success && (
  211. <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
  212. Requires previous success
  213. </span>
  214. )}
  215. {item.auto_off_after && (
  216. <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">
  217. <Power className="w-3 h-3" />
  218. Auto power off
  219. </span>
  220. )}
  221. </div>
  222. {/* Progress bar for printing items - TODO: integrate with WebSocket */}
  223. {isPrinting && (
  224. <div className="mt-3">
  225. <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
  226. <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
  227. </div>
  228. <p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
  229. </div>
  230. )}
  231. {/* Error message */}
  232. {item.error_message && (
  233. <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
  234. <AlertCircle className="w-3 h-3" />
  235. {item.error_message}
  236. </p>
  237. )}
  238. </div>
  239. {/* Status badge */}
  240. <StatusBadge status={item.status} />
  241. {/* Actions */}
  242. <div className="flex items-center gap-1">
  243. {isPrinting && (
  244. <Button
  245. variant="ghost"
  246. size="sm"
  247. onClick={onStop}
  248. title="Stop Print"
  249. className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
  250. >
  251. <StopCircle className="w-4 h-4" />
  252. </Button>
  253. )}
  254. {isPending && (
  255. <>
  256. {item.manual_start && (
  257. <Button
  258. variant="ghost"
  259. size="sm"
  260. onClick={onStart}
  261. title="Start Print"
  262. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
  263. >
  264. <Play className="w-4 h-4" />
  265. </Button>
  266. )}
  267. <Button
  268. variant="ghost"
  269. size="sm"
  270. onClick={onEdit}
  271. title="Edit"
  272. >
  273. <Pencil className="w-4 h-4" />
  274. </Button>
  275. <Button
  276. variant="ghost"
  277. size="sm"
  278. onClick={onCancel}
  279. title="Cancel"
  280. className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
  281. >
  282. <X className="w-4 h-4" />
  283. </Button>
  284. </>
  285. )}
  286. {isHistory && (
  287. <>
  288. <Button
  289. variant="ghost"
  290. size="sm"
  291. onClick={onRequeue}
  292. title="Re-queue"
  293. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
  294. >
  295. <RefreshCw className="w-4 h-4" />
  296. </Button>
  297. <Button
  298. variant="ghost"
  299. size="sm"
  300. onClick={onRemove}
  301. title="Remove"
  302. >
  303. <Trash2 className="w-4 h-4" />
  304. </Button>
  305. </>
  306. )}
  307. </div>
  308. </div>
  309. </div>
  310. );
  311. }
  312. export function QueuePage() {
  313. const queryClient = useQueryClient();
  314. const { showToast } = useToast();
  315. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  316. const [filterStatus, setFilterStatus] = useState<string>('');
  317. const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
  318. const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
  319. const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
  320. const [confirmAction, setConfirmAction] = useState<{
  321. type: 'cancel' | 'remove' | 'stop';
  322. item: PrintQueueItem;
  323. } | null>(null);
  324. const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
  325. const saved = localStorage.getItem('queue.historySortBy');
  326. return (saved as 'date' | 'name' | 'printer') || 'date';
  327. });
  328. const [historySortAsc, setHistorySortAsc] = useState(() => {
  329. const saved = localStorage.getItem('queue.historySortAsc');
  330. return saved !== null ? saved === 'true' : false;
  331. });
  332. const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
  333. const saved = localStorage.getItem('queue.pendingSortBy');
  334. return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
  335. });
  336. const [pendingSortAsc, setPendingSortAsc] = useState(() => {
  337. const saved = localStorage.getItem('queue.pendingSortAsc');
  338. return saved !== null ? saved === 'true' : true;
  339. });
  340. // Persist sort settings to localStorage
  341. useEffect(() => {
  342. localStorage.setItem('queue.historySortBy', historySortBy);
  343. }, [historySortBy]);
  344. useEffect(() => {
  345. localStorage.setItem('queue.historySortAsc', String(historySortAsc));
  346. }, [historySortAsc]);
  347. useEffect(() => {
  348. localStorage.setItem('queue.pendingSortBy', pendingSortBy);
  349. }, [pendingSortBy]);
  350. useEffect(() => {
  351. localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
  352. }, [pendingSortAsc]);
  353. const sensors = useSensors(
  354. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  355. useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  356. );
  357. const { data: settings } = useQuery({
  358. queryKey: ['settings'],
  359. queryFn: api.getSettings,
  360. });
  361. const timeFormat: TimeFormat = settings?.time_format || 'system';
  362. const { data: queue, isLoading } = useQuery({
  363. queryKey: ['queue', filterPrinter, filterStatus],
  364. queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
  365. refetchInterval: 5000,
  366. });
  367. const { data: printers } = useQuery({
  368. queryKey: ['printers'],
  369. queryFn: () => api.getPrinters(),
  370. });
  371. const cancelMutation = useMutation({
  372. mutationFn: (id: number) => api.cancelQueueItem(id),
  373. onSuccess: () => {
  374. queryClient.invalidateQueries({ queryKey: ['queue'] });
  375. showToast('Queue item cancelled');
  376. },
  377. onError: () => showToast('Failed to cancel item', 'error'),
  378. });
  379. const removeMutation = useMutation({
  380. mutationFn: (id: number) => api.removeFromQueue(id),
  381. onSuccess: () => {
  382. queryClient.invalidateQueries({ queryKey: ['queue'] });
  383. showToast('Queue item removed');
  384. },
  385. onError: () => showToast('Failed to remove item', 'error'),
  386. });
  387. const stopMutation = useMutation({
  388. mutationFn: (id: number) => api.stopQueueItem(id),
  389. onSuccess: () => {
  390. queryClient.invalidateQueries({ queryKey: ['queue'] });
  391. showToast('Print stopped');
  392. },
  393. onError: () => showToast('Failed to stop print', 'error'),
  394. });
  395. const startMutation = useMutation({
  396. mutationFn: (id: number) => api.startQueueItem(id),
  397. onSuccess: () => {
  398. queryClient.invalidateQueries({ queryKey: ['queue'] });
  399. showToast('Print released to queue');
  400. },
  401. onError: () => showToast('Failed to start print', 'error'),
  402. });
  403. const reorderMutation = useMutation({
  404. mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
  405. onSuccess: () => {
  406. queryClient.invalidateQueries({ queryKey: ['queue'] });
  407. },
  408. onError: () => showToast('Failed to reorder queue', 'error'),
  409. });
  410. const clearHistoryMutation = useMutation({
  411. mutationFn: async () => {
  412. const historyItems = queue?.filter(i =>
  413. ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
  414. ) || [];
  415. for (const item of historyItems) {
  416. await api.removeFromQueue(item.id);
  417. }
  418. return historyItems.length;
  419. },
  420. onSuccess: (count) => {
  421. queryClient.invalidateQueries({ queryKey: ['queue'] });
  422. showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
  423. },
  424. onError: () => showToast('Failed to clear history', 'error'),
  425. });
  426. const pendingItems = useMemo(() => {
  427. const items = queue?.filter(i => i.status === 'pending') || [];
  428. // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
  429. const getScheduledTime = (item: PrintQueueItem): number => {
  430. if (!item.scheduled_time) return 0;
  431. const time = new Date(item.scheduled_time).getTime();
  432. // Placeholder dates (> 6 months out) are treated as ASAP
  433. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  434. return time > sixMonthsFromNow ? 0 : time;
  435. };
  436. return [...items].sort((a, b) => {
  437. let cmp: number;
  438. if (pendingSortBy === 'name') {
  439. cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
  440. } else if (pendingSortBy === 'printer') {
  441. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  442. } else if (pendingSortBy === 'time') {
  443. // Sort by scheduled start time (when print will begin)
  444. cmp = getScheduledTime(a) - getScheduledTime(b);
  445. } else {
  446. cmp = a.position - b.position;
  447. }
  448. return pendingSortAsc ? cmp : -cmp;
  449. });
  450. }, [queue, pendingSortBy, pendingSortAsc]);
  451. const activeItems = queue?.filter(i => i.status === 'printing') || [];
  452. const historyItems = useMemo(() => {
  453. const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
  454. return [...items].sort((a, b) => {
  455. let cmp: number;
  456. if (historySortBy === 'name') {
  457. cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
  458. } else if (historySortBy === 'printer') {
  459. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  460. } else {
  461. // Default: by date - most recent first (desc) is the natural order
  462. cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
  463. }
  464. return historySortAsc ? -cmp : cmp;
  465. });
  466. }, [queue, historySortBy, historySortAsc]);
  467. // Calculate total queue time
  468. const totalQueueTime = useMemo(() => {
  469. return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
  470. }, [pendingItems]);
  471. const handleDragEnd = (event: DragEndEvent) => {
  472. const { active, over } = event;
  473. if (!over || active.id === over.id) return;
  474. const oldIndex = pendingItems.findIndex(i => i.id === active.id);
  475. const newIndex = pendingItems.findIndex(i => i.id === over.id);
  476. if (oldIndex !== -1 && newIndex !== -1) {
  477. const reordered = arrayMove(pendingItems, oldIndex, newIndex);
  478. const updates = reordered.map((item, index) => ({
  479. id: item.id,
  480. position: index + 1,
  481. }));
  482. reorderMutation.mutate(updates);
  483. }
  484. };
  485. return (
  486. <div className="p-4 md:p-8">
  487. {/* Header */}
  488. <div className="flex items-center justify-between mb-8">
  489. <div>
  490. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  491. <ListOrdered className="w-7 h-7 text-bambu-green" />
  492. Print Queue
  493. </h1>
  494. <p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
  495. </div>
  496. </div>
  497. {/* Summary Cards */}
  498. <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
  499. <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
  500. <CardContent className="p-4">
  501. <div className="flex items-center gap-3">
  502. <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
  503. <Play className="w-5 h-5 text-blue-400" />
  504. </div>
  505. <div>
  506. <p className="text-2xl font-bold text-white">{activeItems.length}</p>
  507. <p className="text-sm text-bambu-gray">Printing</p>
  508. </div>
  509. </div>
  510. </CardContent>
  511. </Card>
  512. <Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
  513. <CardContent className="p-4">
  514. <div className="flex items-center gap-3">
  515. <div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
  516. <Clock className="w-5 h-5 text-yellow-400" />
  517. </div>
  518. <div>
  519. <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
  520. <p className="text-sm text-bambu-gray">Queued</p>
  521. </div>
  522. </div>
  523. </CardContent>
  524. </Card>
  525. <Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
  526. <CardContent className="p-4">
  527. <div className="flex items-center gap-3">
  528. <div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
  529. <Timer className="w-5 h-5 text-bambu-green" />
  530. </div>
  531. <div>
  532. <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
  533. <p className="text-sm text-bambu-gray">Total Queue Time</p>
  534. </div>
  535. </div>
  536. </CardContent>
  537. </Card>
  538. <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
  539. <CardContent className="p-4">
  540. <div className="flex items-center gap-3">
  541. <div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
  542. <CheckCircle className="w-5 h-5 text-gray-400" />
  543. </div>
  544. <div>
  545. <p className="text-2xl font-bold text-white">{historyItems.length}</p>
  546. <p className="text-sm text-bambu-gray">History</p>
  547. </div>
  548. </div>
  549. </CardContent>
  550. </Card>
  551. </div>
  552. {/* Filters */}
  553. <div className="flex items-center gap-4 mb-6">
  554. <select
  555. 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"
  556. value={filterPrinter || ''}
  557. onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
  558. >
  559. <option value="">All Printers</option>
  560. {printers?.map((p) => (
  561. <option key={p.id} value={p.id}>{p.name}</option>
  562. ))}
  563. </select>
  564. <select
  565. 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"
  566. value={filterStatus}
  567. onChange={(e) => setFilterStatus(e.target.value)}
  568. >
  569. <option value="">All Status</option>
  570. <option value="pending">Pending</option>
  571. <option value="printing">Printing</option>
  572. <option value="completed">Completed</option>
  573. <option value="failed">Failed</option>
  574. <option value="skipped">Skipped</option>
  575. <option value="cancelled">Cancelled</option>
  576. </select>
  577. <div className="flex-1" />
  578. {historyItems.length > 0 && (
  579. <Button
  580. variant="secondary"
  581. size="sm"
  582. onClick={() => setShowClearHistoryConfirm(true)}
  583. >
  584. <Trash2 className="w-4 h-4" />
  585. Clear History
  586. </Button>
  587. )}
  588. </div>
  589. {isLoading ? (
  590. <div className="text-center py-12 text-bambu-gray">Loading...</div>
  591. ) : queue?.length === 0 ? (
  592. <Card className="p-12 text-center border-dashed">
  593. <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  594. <h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
  595. <p className="text-bambu-gray max-w-md mx-auto">
  596. Schedule a print from the Archives page using the "Schedule" option in the context menu,
  597. or drag and drop files to get started.
  598. </p>
  599. </Card>
  600. ) : (
  601. <div className="space-y-8">
  602. {/* Active Prints */}
  603. {activeItems.length > 0 && (
  604. <div>
  605. <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
  606. <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
  607. Currently Printing
  608. </h2>
  609. <div className="space-y-3">
  610. {activeItems.map((item) => (
  611. <SortableQueueItem
  612. key={item.id}
  613. item={item}
  614. onEdit={() => {}}
  615. onCancel={() => {}}
  616. onRemove={() => {}}
  617. onStop={() => setConfirmAction({ type: 'stop', item })}
  618. onRequeue={() => {}}
  619. onStart={() => {}}
  620. timeFormat={timeFormat}
  621. />
  622. ))}
  623. </div>
  624. </div>
  625. )}
  626. {/* Pending Queue */}
  627. {pendingItems.length > 0 && (
  628. <div>
  629. <div className="flex items-center justify-between mb-4">
  630. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  631. <Clock className="w-5 h-5 text-yellow-400" />
  632. Queued
  633. <span className="text-sm font-normal text-bambu-gray">
  634. ({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
  635. </span>
  636. <span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
  637. Drag to reorder (ASAP only)
  638. </span>
  639. </h2>
  640. <div className="flex items-center gap-2">
  641. <select
  642. 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"
  643. value={pendingSortBy}
  644. onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
  645. >
  646. <option value="position">Sort by Position</option>
  647. <option value="name">Sort by Name</option>
  648. <option value="printer">Sort by Printer</option>
  649. <option value="time">Sort by Schedule</option>
  650. </select>
  651. <Button
  652. variant="ghost"
  653. size="sm"
  654. onClick={() => setPendingSortAsc(!pendingSortAsc)}
  655. title={pendingSortAsc ? 'Ascending' : 'Descending'}
  656. className="px-2"
  657. >
  658. {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  659. </Button>
  660. </div>
  661. </div>
  662. <DndContext
  663. sensors={sensors}
  664. collisionDetection={closestCenter}
  665. onDragEnd={handleDragEnd}
  666. >
  667. <SortableContext
  668. items={pendingItems.map(i => i.id)}
  669. strategy={verticalListSortingStrategy}
  670. >
  671. <div className="space-y-3">
  672. {pendingItems.map((item, index) => (
  673. <SortableQueueItem
  674. key={item.id}
  675. item={item}
  676. position={index + 1}
  677. onEdit={() => setEditItem(item)}
  678. onCancel={() => setConfirmAction({ type: 'cancel', item })}
  679. onRemove={() => {}}
  680. onStop={() => {}}
  681. onRequeue={() => {}}
  682. onStart={() => startMutation.mutate(item.id)}
  683. timeFormat={timeFormat}
  684. />
  685. ))}
  686. </div>
  687. </SortableContext>
  688. </DndContext>
  689. </div>
  690. )}
  691. {/* History */}
  692. {historyItems.length > 0 && (
  693. <div>
  694. <div className="flex items-center justify-between mb-4">
  695. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  696. <CheckCircle className="w-5 h-5 text-bambu-gray" />
  697. History
  698. <span className="text-sm font-normal text-bambu-gray">
  699. ({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
  700. </span>
  701. </h2>
  702. <div className="flex items-center gap-2">
  703. <select
  704. 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"
  705. value={historySortBy}
  706. onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
  707. >
  708. <option value="date">Sort by Date</option>
  709. <option value="name">Sort by Name</option>
  710. <option value="printer">Sort by Printer</option>
  711. </select>
  712. <Button
  713. variant="ghost"
  714. size="sm"
  715. onClick={() => setHistorySortAsc(!historySortAsc)}
  716. title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
  717. className="px-2"
  718. >
  719. {historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  720. </Button>
  721. </div>
  722. </div>
  723. <div className="space-y-3">
  724. {historyItems.slice(0, 20).map((item, index) => (
  725. <SortableQueueItem
  726. key={item.id}
  727. item={item}
  728. position={index + 1}
  729. onEdit={() => {}}
  730. onCancel={() => {}}
  731. onRemove={() => setConfirmAction({ type: 'remove', item })}
  732. onStop={() => {}}
  733. onRequeue={() => setRequeueItem(item)}
  734. onStart={() => {}}
  735. timeFormat={timeFormat}
  736. />
  737. ))}
  738. </div>
  739. </div>
  740. )}
  741. </div>
  742. )}
  743. {/* Edit Modal */}
  744. {editItem && (
  745. <EditQueueItemModal
  746. item={editItem}
  747. onClose={() => setEditItem(null)}
  748. />
  749. )}
  750. {/* Re-queue Modal */}
  751. {requeueItem && (
  752. <AddToQueueModal
  753. archiveId={requeueItem.archive_id}
  754. archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
  755. onClose={() => setRequeueItem(null)}
  756. />
  757. )}
  758. {/* Confirm Action Modal */}
  759. {confirmAction && (
  760. <ConfirmModal
  761. title={
  762. confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
  763. confirmAction.type === 'stop' ? 'Stop Print' :
  764. 'Remove from History'
  765. }
  766. message={
  767. confirmAction.type === 'cancel'
  768. ? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
  769. : confirmAction.type === 'stop'
  770. ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
  771. : `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
  772. }
  773. confirmText={
  774. confirmAction.type === 'cancel' ? 'Cancel Print' :
  775. confirmAction.type === 'stop' ? 'Stop Print' :
  776. 'Remove'
  777. }
  778. variant="danger"
  779. onConfirm={() => {
  780. if (confirmAction.type === 'cancel') {
  781. cancelMutation.mutate(confirmAction.item.id);
  782. } else if (confirmAction.type === 'stop') {
  783. stopMutation.mutate(confirmAction.item.id);
  784. } else {
  785. removeMutation.mutate(confirmAction.item.id);
  786. }
  787. setConfirmAction(null);
  788. }}
  789. onCancel={() => setConfirmAction(null)}
  790. />
  791. )}
  792. {/* Clear History Confirm Modal */}
  793. {showClearHistoryConfirm && (
  794. <ConfirmModal
  795. title="Clear History"
  796. message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
  797. confirmText="Clear History"
  798. variant="danger"
  799. onConfirm={() => {
  800. clearHistoryMutation.mutate();
  801. setShowClearHistoryConfirm(false);
  802. }}
  803. onCancel={() => setShowClearHistoryConfirm(false)}
  804. />
  805. )}
  806. </div>
  807. );
  808. }