QueuePage.tsx 30 KB

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