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,
Hand,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
import type { PrintQueueItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
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, timeFormat: TimeFormat = 'system'): string {
if (!dateString) return 'ASAP';
const date = parseUTCDate(dateString);
if (!date) return 'ASAP';
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 formatDateTime(dateString, timeFormat);
}
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,
onStart,
timeFormat = 'system',
}: {
item: PrintQueueItem;
position?: number;
onEdit: () => void;
onCancel: () => void;
onRemove: () => void;
onStop: () => void;
onRequeue: () => void;
onStart: () => void;
timeFormat?: TimeFormat;
}) {
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 ? (
})
) : item.library_file_thumbnail ? (
})
) : (
)}
{/* Info */}
{item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
{item.archive_id ? (
) : item.library_file_id ? (
) : null}
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
{item.print_time_seconds && (
{formatDuration(item.print_time_seconds)}
)}
{isPending && !item.manual_start && (
{formatRelativeTime(item.scheduled_time, timeFormat)}
)}
{/* Options badges */}
{item.manual_start && (
Staged
)}
{item.require_previous_success && (
Requires previous success
)}
{item.auto_off_after && (
Auto power off
)}
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
{isPrinting && (
)}
{/* Error message */}
{item.error_message && (
{item.error_message}
)}
{/* Status badge */}
{/* Actions */}
{isPrinting && (
)}
{isPending && (
<>
{item.manual_start && (
)}
>
)}
{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 [requeueItem, setRequeueItem] = 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: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
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 startMutation = useMutation({
mutationFn: (id: number) => api.startQueueItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Print released to queue');
},
onError: () => showToast('Failed to start 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 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') {
const aName = a.archive_name || a.library_file_name || '';
const bName = b.archive_name || b.library_file_name || '';
cmp = aName.localeCompare(bName);
} 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') {
const aName = a.archive_name || a.library_file_name || '';
const bName = b.archive_name || b.library_file_name || '';
cmp = aName.localeCompare(bName);
} 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={() => {}}
onStart={() => {}}
timeFormat={timeFormat}
/>
))}
)}
{/* 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={() => {}}
onStart={() => startMutation.mutate(item.id)}
timeFormat={timeFormat}
/>
))}
)}
{/* 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={() => setRequeueItem(item)}
onStart={() => {}}
timeFormat={timeFormat}
/>
))}
)}
)}
{/* Edit Modal */}
{editItem && (
setEditItem(null)}
/>
)}
{/* Re-queue Modal */}
{requeueItem && (
setRequeueItem(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)}
/>
)}
);
}