QueuePage.tsx 34 KB

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