QueuePage.tsx 31 KB

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