import { useState, useMemo, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Clock, Trash2, Play, X, CheckCircle, XCircle, AlertCircle, Calendar, Printer, GripVertical, SkipForward, ExternalLink, Power, StopCircle, Pencil, RefreshCw, Timer, ListOrdered, Layers, ArrowUp, ArrowDown, } from 'lucide-react'; import { api } from '../api/client'; import type { PrintQueueItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { EditQueueItemModal } from '../components/EditQueueItemModal'; import { useToast } from '../contexts/ToastContext'; function formatDuration(seconds: number | null | undefined): string { if (!seconds) return '--'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } function formatRelativeTime(dateString: string | null): string { if (!dateString) return 'ASAP'; const date = new Date(dateString); const now = new Date(); const diff = date.getTime() - now.getTime(); if (diff < -60000) return 'Overdue'; if (diff < 0) return 'Now'; if (diff < 60000) return 'In less than a minute'; if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`; if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`; return date.toLocaleString(); } function StatusBadge({ status }: { status: PrintQueueItem['status'] }) { const config = { pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', label: 'Pending' }, printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' }, completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10 border-green-400/20', label: 'Completed' }, failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10 border-red-400/20', label: 'Failed' }, skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' }, cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' }, }; const { icon: Icon, color, label } = config[status]; return ( {label} ); } // Sortable queue item for drag and drop function SortableQueueItem({ item, position, onEdit, onCancel, onRemove, onStop, onRequeue, }: { item: PrintQueueItem; position?: number; onEdit: () => void; onCancel: () => void; onRemove: () => void; onStop: () => void; onRequeue: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item.id, disabled: item.status !== 'pending' }); const style = { transform: CSS.Transform.toString(transform), transition, }; const isPrinting = item.status === 'printing'; const isPending = item.status === 'pending'; const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status); return (
{/* Drag handle or position number */} {isPending ? (
) : position !== undefined ? (
#{position}
) : (
)} {/* Thumbnail */}
{item.archive_thumbnail ? ( ) : (
)}
{/* Info */}

{item.archive_name || `Archive #${item.archive_id}`}

{item.printer_name || `Printer #${item.printer_id}`} {item.print_time_seconds && ( {formatDuration(item.print_time_seconds)} )} {isPending && ( {formatRelativeTime(item.scheduled_time)} )}
{/* Options badges */}
{item.require_previous_success && ( Requires previous success )} {item.auto_off_after && ( Auto power off )}
{/* Progress bar for printing items - TODO: integrate with WebSocket */} {isPrinting && (

Printing in progress...

)} {/* Error message */} {item.error_message && (

{item.error_message}

)}
{/* Status badge */} {/* Actions */}
{isPrinting && ( )} {isPending && ( <> )} {isHistory && ( <> )}
); } export function QueuePage() { const queryClient = useQueryClient(); const { showToast } = useToast(); const [filterPrinter, setFilterPrinter] = useState(null); const [filterStatus, setFilterStatus] = useState(''); const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false); const [editItem, setEditItem] = useState(null); const [confirmAction, setConfirmAction] = useState<{ type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem; } | null>(null); const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => { const saved = localStorage.getItem('queue.historySortBy'); return (saved as 'date' | 'name' | 'printer') || 'date'; }); const [historySortAsc, setHistorySortAsc] = useState(() => { const saved = localStorage.getItem('queue.historySortAsc'); return saved !== null ? saved === 'true' : false; }); const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => { const saved = localStorage.getItem('queue.pendingSortBy'); return (saved as 'position' | 'name' | 'printer' | 'time') || 'position'; }); const [pendingSortAsc, setPendingSortAsc] = useState(() => { const saved = localStorage.getItem('queue.pendingSortAsc'); return saved !== null ? saved === 'true' : true; }); // Persist sort settings to localStorage useEffect(() => { localStorage.setItem('queue.historySortBy', historySortBy); }, [historySortBy]); useEffect(() => { localStorage.setItem('queue.historySortAsc', String(historySortAsc)); }, [historySortAsc]); useEffect(() => { localStorage.setItem('queue.pendingSortBy', pendingSortBy); }, [pendingSortBy]); useEffect(() => { localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc)); }, [pendingSortAsc]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); const { data: queue, isLoading } = useQuery({ queryKey: ['queue', filterPrinter, filterStatus], queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined), refetchInterval: 5000, }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); const cancelMutation = useMutation({ mutationFn: (id: number) => api.cancelQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast('Queue item cancelled'); }, onError: () => showToast('Failed to cancel item', 'error'), }); const removeMutation = useMutation({ mutationFn: (id: number) => api.removeFromQueue(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast('Queue item removed'); }, onError: () => showToast('Failed to remove item', 'error'), }); const stopMutation = useMutation({ mutationFn: (id: number) => api.stopQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast('Print stopped'); }, onError: () => showToast('Failed to stop print', 'error'), }); const reorderMutation = useMutation({ mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); }, onError: () => showToast('Failed to reorder queue', 'error'), }); const requeueMutation = useMutation({ mutationFn: (item: PrintQueueItem) => { // Schedule far in future so it doesn't start immediately const futureDate = new Date(); futureDate.setFullYear(futureDate.getFullYear() + 1); return api.addToQueue({ printer_id: item.printer_id, archive_id: item.archive_id, scheduled_time: futureDate.toISOString(), require_previous_success: false, auto_off_after: false, }); }, onSuccess: (newItem) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast('Added back to queue - please set schedule'); // Open edit modal for the new item setEditItem(newItem); }, onError: (error: Error) => showToast(error.message || 'Failed to re-queue item', 'error'), }); const clearHistoryMutation = useMutation({ mutationFn: async () => { const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status) ) || []; for (const item of historyItems) { await api.removeFromQueue(item.id); } return historyItems.length; }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`); }, onError: () => showToast('Failed to clear history', 'error'), }); const pendingItems = useMemo(() => { const items = queue?.filter(i => i.status === 'pending') || []; // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest) const getScheduledTime = (item: PrintQueueItem): number => { if (!item.scheduled_time) return 0; const time = new Date(item.scheduled_time).getTime(); // Placeholder dates (> 6 months out) are treated as ASAP const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000); return time > sixMonthsFromNow ? 0 : time; }; return [...items].sort((a, b) => { let cmp: number; if (pendingSortBy === 'name') { cmp = (a.archive_name || '').localeCompare(b.archive_name || ''); } else if (pendingSortBy === 'printer') { cmp = (a.printer_name || '').localeCompare(b.printer_name || ''); } else if (pendingSortBy === 'time') { // Sort by scheduled start time (when print will begin) cmp = getScheduledTime(a) - getScheduledTime(b); } else { cmp = a.position - b.position; } return pendingSortAsc ? cmp : -cmp; }); }, [queue, pendingSortBy, pendingSortAsc]); const activeItems = queue?.filter(i => i.status === 'printing') || []; const historyItems = useMemo(() => { const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || []; return [...items].sort((a, b) => { let cmp: number; if (historySortBy === 'name') { cmp = (a.archive_name || '').localeCompare(b.archive_name || ''); } else if (historySortBy === 'printer') { cmp = (a.printer_name || '').localeCompare(b.printer_name || ''); } else { // Default: by date - most recent first (desc) is the natural order cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime(); } return historySortAsc ? -cmp : cmp; }); }, [queue, historySortBy, historySortAsc]); // Calculate total queue time const totalQueueTime = useMemo(() => { return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0); }, [pendingItems]); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = pendingItems.findIndex(i => i.id === active.id); const newIndex = pendingItems.findIndex(i => i.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { const reordered = arrayMove(pendingItems, oldIndex, newIndex); const updates = reordered.map((item, index) => ({ id: item.id, position: index + 1, })); reorderMutation.mutate(updates); } }; return (
{/* Header */}

Print Queue

Schedule and manage your print jobs

{/* Summary Cards */}

{activeItems.length}

Printing

{pendingItems.length}

Queued

{formatDuration(totalQueueTime)}

Total Queue Time

{historyItems.length}

History

{/* Filters */}
{historyItems.length > 0 && ( )}
{isLoading ? (
Loading...
) : queue?.length === 0 ? (

No prints scheduled

Schedule a print from the Archives page using the "Schedule" option in the context menu, or drag and drop files to get started.

) : (
{/* Active Prints */} {activeItems.length > 0 && (

Currently Printing

{activeItems.map((item) => ( {}} onCancel={() => {}} onRemove={() => {}} onStop={() => setConfirmAction({ type: 'stop', item })} onRequeue={() => {}} /> ))}
)} {/* Pending Queue */} {pendingItems.length > 0 && (

Queued ({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''}) Drag to reorder (ASAP only)

i.id)} strategy={verticalListSortingStrategy} >
{pendingItems.map((item, index) => ( setEditItem(item)} onCancel={() => setConfirmAction({ type: 'cancel', item })} onRemove={() => {}} onStop={() => {}} onRequeue={() => {}} /> ))}
)} {/* History */} {historyItems.length > 0 && (

History ({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})

{historyItems.slice(0, 20).map((item, index) => ( {}} onCancel={() => {}} onRemove={() => setConfirmAction({ type: 'remove', item })} onStop={() => {}} onRequeue={() => requeueMutation.mutate(item)} /> ))}
)}
)} {/* Edit Modal */} {editItem && ( setEditItem(null)} /> )} {/* Confirm Action Modal */} {confirmAction && ( { if (confirmAction.type === 'cancel') { cancelMutation.mutate(confirmAction.item.id); } else if (confirmAction.type === 'stop') { stopMutation.mutate(confirmAction.item.id); } else { removeMutation.mutate(confirmAction.item.id); } setConfirmAction(null); }} onCancel={() => setConfirmAction(null)} /> )} {/* Clear History Confirm Modal */} {showClearHistoryConfirm && ( { clearHistoryMutation.mutate(); setShowClearHistoryConfirm(false); }} onCancel={() => setShowClearHistoryConfirm(false)} /> )}
); }