import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueries, 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, Check, CheckSquare, Square, User, Pause, Weight, ChevronDown, ChevronRight, List, GanttChart, Code, Snail, } from 'lucide-react'; import { api } from '../api/client'; import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date'; import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client'; import { Card } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { PrintModal } from '../components/PrintModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { QueueStatsBar } from '../components/QueueStatsBar'; import { CompactHistoryRow } from '../components/CompactHistoryRow'; import { QueueTimelineView } from '../components/QueueTimelineView'; function formatWeight(g: number, useKg = false): string { if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`; return `${Math.round(g)}g`; } function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) { // Special case: pending with waiting_reason shows as "Waiting" if (status === 'pending' && waitingReason) { return ( {t('queue.status.waiting')} ); } // Special case: printing but printer is paused if (status === 'printing' && printerState === 'PAUSE') { return ( {t('queue.status.paused')} ); } const config = { pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') }, printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') }, completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') }, failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') }, skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') }, cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') }, }; const { icon: Icon, color, label } = config[status]; return ( {label} ); } // Bulk edit modal for multiple queue items function BulkEditModal({ selectedCount, printers, onSave, onClose, isSaving, canControlPrinter, t, }: { selectedCount: number; printers: { id: number; name: string }[]; onSave: (data: Partial) => void; onClose: () => void; isSaving: boolean; canControlPrinter: boolean; t: (key: string, options?: Record) => string; }) { const [printerId, setPrinterId] = useState('unchanged'); const [manualStart, setManualStart] = useState('unchanged'); const [autoOffAfter, setAutoOffAfter] = useState('unchanged'); const [requirePreviousSuccess, setRequirePreviousSuccess] = useState('unchanged'); const [bedLevelling, setBedLevelling] = useState('unchanged'); const [flowCali, setFlowCali] = useState('unchanged'); const [vibrationCali, setVibrationCali] = useState('unchanged'); const [layerInspect, setLayerInspect] = useState('unchanged'); const [timelapse, setTimelapse] = useState('unchanged'); const [useAms, setUseAms] = useState('unchanged'); const handleSave = () => { const data: Partial = {}; if (printerId !== 'unchanged') data.printer_id = printerId; if (manualStart !== 'unchanged') data.manual_start = manualStart; if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter; if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess; if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling; if (flowCali !== 'unchanged') data.flow_cali = flowCali; if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali; if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect; if (timelapse !== 'unchanged') data.timelapse = timelapse; if (useAms !== 'unchanged') data.use_ams = useAms; onSave(data); }; const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' || requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' || vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged'; return (

{t('queue.bulkEdit.title', { count: selectedCount })}

{t('queue.bulkEdit.description')}

{/* Printer Assignment */}
{/* Queue Options */}
{/* Print Options */}
); } // Tri-state toggle for bulk edit (unchanged / on / off) function TriStateToggle({ label, value, onChange, disabled, t, }: { label: string; value: boolean | 'unchanged'; onChange: (val: boolean | 'unchanged') => void; disabled?: boolean; t: (key: string) => string; }) { return (
{label}
); } // Sortable queue item for drag and drop function SortableQueueItem({ item, position, onEdit, onCancel, onRemove, onStop, onRequeue, onStart, timeFormat = 'system', isSelected = false, onToggleSelect, hasPermission, canModify, printerState, t, }: { item: PrintQueueItem; position?: number; onEdit: () => void; onCancel: () => void; onRemove: () => void; onStop: () => void; onRequeue: () => void; onStart: () => void; timeFormat?: TimeFormat; isSelected?: boolean; onToggleSelect?: () => void; hasPermission: (permission: Permission) => boolean; canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; printerState?: string | null; t: (key: string, options?: Record) => string; }) { // Fetch printer status every 30 seconds while printing to monitor progress const { data: status } = useQuery({ queryKey: ['printerStatus', item.printer_id], queryFn: () => api.getPrinterStatus(item.printer_id!), refetchInterval: 30000, enabled: item.printer_id != null && printerState === 'printing', }); // Determine if we're printing a library file const isLibraryFile = !!item.library_file_id && !item.archive_id; // Fetch archive plate details. Skip when the linked archive has been // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the // /plates endpoint just 404-storms the queue page. const { data: archivePlatesData } = useQuery({ queryKey: ['archive-plates', item.archive_id], queryFn: () => api.getArchivePlates(item.archive_id!), enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted, }); // Fetch library file plate details const { data: libraryPlatesData } = useQuery({ queryKey: ['library-file-plates', item.library_file_id], queryFn: () => api.getLibraryFilePlates(item.library_file_id!), enabled: isLibraryFile && !!item.library_file_id, }); // Combine plates data from either source const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData; const plates = platesData?.plates ?? []; const canReorder = hasPermission('queue:reorder'); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder }); 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); const isMobileSelectable = isPending && onToggleSelect; return (
{ if (window.innerWidth < 640) onToggleSelect(); } : undefined} > {/* Mobile selected left accent bar */} {isMobileSelectable && isSelected && (
)}
{/* Mobile selection indicator — left accent bar only, no tick */} {/* Selection checkbox for pending items - hidden on mobile, tap card instead */} {isPending && onToggleSelect && ( )} {/* Drag handle or position number - hidden on mobile */} {isPending ? (
) : position !== undefined ? (
#{position}
) : (
)} {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
{item.archive_thumbnail ? ( ) : item.library_file_thumbnail ? ( ) : (
)}
{/* Info */}

{item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`} {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}

{item.archive_id ? ( ) : item.library_file_id ? ( ) : null} {item.batch_name && ( {item.batch_name} )}
{item.target_model && !item.printer_id ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}` : item.printer_id === null ? t('queue.filter.unassigned') : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)} {item.print_time_seconds && ( {formatDuration(item.print_time_seconds)} )} {item.filament_used_grams && ( {formatWeight(item.filament_used_grams)} )} {item.created_by_username && ( {item.created_by_username} )} {isPending && !item.manual_start && ( {item.scheduled_time ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000 ? t?.('queue.time.overdue') ?? 'Overdue' : formatRelativeTime(item.scheduled_time, timeFormat, t)) : t?.('queue.time.asap') ?? 'ASAP'} )}
{/* Options badges */}
{item.manual_start && ( {t('queue.badges.staged')} )} {item.require_previous_success && ( {t('queue.badges.requiresPrevious')} )} {item.auto_off_after && ( {t('queue.badges.autoPowerOff')} )} {item.gcode_injection && ( {t('queue.badges.gcodeInjection')} )}
{/* Progress bar for printing items - TODO: integrate with WebSocket */} {isPrinting && status && (() => { // Gate progress/remaining/layer on printer actually running this print. // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress // is stale from the previous print — showing 100% then snapping back to 0% // once the new print starts. Only trust these fields when state is active. const isActive = status.state === 'RUNNING' || status.state === 'PAUSE'; const progress = isActive ? (status.progress || 0) : 0; const remaining = isActive ? status.remaining_time : null; const layerNum = isActive ? status.layer_num : null; const totalLayers = isActive ? status.total_layers : null; return (
{Math.round(progress)}%
{remaining != null && remaining > 0 && ( <> {formatDuration(remaining * 60)} ETA {formatETA(remaining, timeFormat, t)} )} {layerNum != null && totalLayers != null && totalLayers > 0 && ( {layerNum}/{totalLayers} )}
); })()} {/* Waiting reason for model-based assignments */} {item.waiting_reason && item.status === 'pending' && (

{item.waiting_reason}

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

{item.error_message}

)}
{/* Status badge + Actions */}
e.stopPropagation()}>
{isPrinting && ( )} {isPending && ( <> {item.manual_start && ( )} )} {isHistory && ( <> )}
); } export function QueuePage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, hasAnyPermission, canModify } = useAuth(); const [filterPrinter, setFilterPrinter] = useState(null); const [filterStatus, setFilterStatus] = useState(''); const [filterLocation, setFilterLocation] = 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 [selectedItems, setSelectedItems] = useState([]); const [showBulkEditModal, setShowBulkEditModal] = useState(false); 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; }); const [historyCollapsed, setHistoryCollapsed] = useState(() => { return localStorage.getItem('queue.historyCollapsed') !== 'false'; }); const [viewMode, setViewMode] = useState<'list' | 'timeline'>(() => { return (localStorage.getItem('queue.viewMode') as 'list' | 'timeline') || 'list'; }); // 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]); useEffect(() => { localStorage.setItem('queue.historyCollapsed', String(historyCollapsed)); }, [historyCollapsed]); useEffect(() => { localStorage.setItem('queue.viewMode', viewMode); }, [viewMode]); 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 sjfMutation = useMutation({ mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings'] }); }, }); const cancelMutation = useMutation({ mutationFn: (id: number) => api.cancelQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.cancelled')); }, onError: () => showToast(t('queue.toast.cancelFailed'), 'error'), }); const removeMutation = useMutation({ mutationFn: (id: number) => api.removeFromQueue(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.removed')); }, onError: () => showToast(t('queue.toast.removeFailed'), 'error'), }); const stopMutation = useMutation({ mutationFn: (id: number) => api.stopQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.stopped')); }, onError: () => showToast(t('queue.toast.stopFailed'), 'error'), }); const startMutation = useMutation({ mutationFn: (id: number) => api.startQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.released')); }, onError: () => showToast(t('queue.toast.startFailed'), 'error'), }); const reorderMutation = useMutation({ mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); }, onError: () => showToast(t('queue.toast.reorderFailed'), '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(t('queue.toast.historyCleared', { count })); }, onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'), }); const bulkUpdateMutation = useMutation({ mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); setShowBulkEditModal(false); showToast(result.message); }, onError: () => showToast(t('queue.toast.updateFailed'), 'error'), }); const bulkCancelMutation = useMutation({ mutationFn: async (ids: number[]) => { for (const id of ids) { await api.cancelQueueItem(id); } return ids.length; }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); showToast(t('queue.toast.bulkCancelled', { count })); }, onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'), }); const handleToggleSelect = (id: number) => { setSelectedItems(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] ); }; // Get unique locations from printers for the filter dropdown const uniqueLocations = useMemo(() => { const locations = new Set(); printers?.forEach(p => { if (p.location) locations.add(p.location); }); // Also include locations from queue items (for model-based assignments) queue?.forEach(item => { if (item.target_location) locations.add(item.target_location); }); return Array.from(locations).sort(); }, [printers, queue]); // Helper to check if a queue item matches the location filter const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => { if (!filterLocation) return true; // For model-based assignments, check target_location if (item.target_location) return item.target_location === filterLocation; // For printer-based assignments, check the printer's location if (item.printer_id) { const printer = printers?.find(p => p.id === item.printer_id); return printer?.location === filterLocation; } return false; }, [filterLocation, printers]); const pendingItems = useMemo(() => { let items = queue?.filter(i => i.status === 'pending') || []; // Apply location filter if (filterLocation) { items = items.filter(matchesLocationFilter); } // 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 = parseUTCDate(item.scheduled_time)?.getTime() ?? 0; // Placeholder dates (> 6 months out) are treated as ASAP const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000); return time > sixMonthsFromNow ? 0 : time; }; // When SJF is enabled, override sort to match scheduler order if (settings?.queue_shortest_first) { return [...items].sort((a, b) => { // Group by printer first (nulls = model-based, grouped by target_model) const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0); const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0); if (aPrinter !== bPrinter) return aPrinter - bPrinter; // Within same printer/model: jumped items first (starvation guard) const aJumped = a.been_jumped ? 1 : 0; const bJumped = b.been_jumped ? 1 : 0; if (aJumped !== bJumped) return bJumped - aJumped; // Shortest print time next (nulls last) const aTime = a.print_time_seconds ?? Infinity; const bTime = b.print_time_seconds ?? Infinity; if (aTime !== bTime) return aTime - bTime; // Position as tiebreaker return a.position - b.position; }); } 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, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]); const handleSelectAll = () => { const allPendingIds = pendingItems.map(i => i.id); if (selectedItems.length === allPendingIds.length) { setSelectedItems([]); } else { setSelectedItems(allPendingIds); } }; const activeItems = useMemo(() => { let items = queue?.filter(i => i.status === 'printing') || []; if (filterLocation) { items = items.filter(matchesLocationFilter); } return items; }, [queue, filterLocation, matchesLocationFilter]); // Get unique printer IDs from active items to fetch their statuses const activePrinterIds = useMemo(() => { const ids = new Set(); activeItems.forEach(item => { if (item.printer_id) ids.add(item.printer_id); }); return Array.from(ids); }, [activeItems]); // Fetch printer statuses for printers with active jobs const printerStatusQueries = useQueries({ queries: activePrinterIds.map(printerId => ({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId), refetchInterval: 5000, })), }); // Build a map of printer_id -> state for quick lookup const printerStateMap = useMemo(() => { const map: Record = {}; activePrinterIds.forEach((printerId, index) => { const result = printerStatusQueries[index]; if (result?.data?.state) { map[printerId] = result.data.state; } }); return map; }, [activePrinterIds, printerStatusQueries]); // Build a map of printer_id -> full status for timeline view const printerStatusMap = useMemo(() => { const map: Record = {}; activePrinterIds.forEach((printerId, index) => { const result = printerStatusQueries[index]; if (result?.data) { map[printerId] = { progress: result.data.progress ?? undefined, remaining_time: result.data.remaining_time ?? undefined, state: result.data.state ?? undefined, }; } }); return map; }, [activePrinterIds, printerStatusQueries]); const historyItems = useMemo(() => { let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || []; if (filterLocation) { items = items.filter(matchesLocationFilter); } 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 = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0); } return historySortAsc ? -cmp : cmp; }); }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]); // Calculate total queue time const totalQueueTime = useMemo(() => { return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0); }, [pendingItems]); // Calculate total material weight const totalWeight = useMemo(() => { return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 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 */}

{t('queue.title')}

{t('queue.subtitle')}

{/* Summary Stats */} {/* Filters */}
{uniqueLocations.length > 0 && ( )}
{historyItems.length > 0 && ( )}
{/* View Mode Toggle + SJF */}
{isLoading ? (
{t('common.loading')}
) : queue?.length === 0 ? (

{t('queue.empty.title')}

{t('queue.empty.description')}

) : viewMode === 'timeline' ? ( { if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) { setRequeueItem(item); } else if (item.status === 'pending') { setEditItem(item); } else if (item.status === 'printing') { setConfirmAction({ type: 'stop', item }); } }} t={t} /> ) : (
{/* Active Prints */} {activeItems.length > 0 && (

{t('queue.sections.currentlyPrinting')}

{activeItems.map((item) => ( {}} onCancel={() => {}} onRemove={() => {}} onStop={() => setConfirmAction({ type: 'stop', item })} onRequeue={() => {}} onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} printerState={item.printer_id ? printerStateMap[item.printer_id] : null} t={t} /> ))}
)} {/* Pending Queue */} {pendingItems.length > 0 && (

{t('queue.sections.queued')} ({t('queue.itemCount', { count: pendingItems.length })}) {t('queue.dragToReorder')}

{/* Bulk action toolbar */}
{selectedItems.length > 0 && ( <> {t('queue.bulkEdit.selected', { count: selectedItems.length })}
)}
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} isSelected={selectedItems.includes(item.id)} onToggleSelect={() => handleToggleSelect(item.id)} hasPermission={hasPermission} canModify={canModify} t={t} /> ))}
)} {/* History */} {historyItems.length > 0 && (
{!historyCollapsed && (
)}
{!historyCollapsed && (
{historyItems.slice(0, 50).map((item) => ( setConfirmAction({ type: 'remove', item })} onRequeue={() => setRequeueItem(item)} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} /> ))}
)}
)}
)} {/* 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)} /> )} {/* Bulk Edit Modal */} {showBulkEditModal && ( ({ id: p.id, name: p.name })) || []} onSave={(data) => { if (Object.keys(data).length > 0) { bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data }); } }} onClose={() => setShowBulkEditModal(false)} isSaving={bulkUpdateMutation.isPending} canControlPrinter={hasPermission('printers:control')} t={t} /> )}
); }