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, DragOverlay, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; import { 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, Package, PackageOpen, Ungroup, Ban, PlayCircle, Workflow, } from 'lucide-react'; import { api, ApiError } from '../api/client'; import { PipelineRunsView } from './PipelineRunsPage'; import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date'; import { getBedTypeInfo } from '../utils/bedType'; import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } 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-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 border-blue-200 dark: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-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10 border-orange-200 dark: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; nozzle_count?: number }[]; 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 [nozzleOffsetCali, setNozzleOffsetCali] = useState('unchanged'); // Show the dual-nozzle-only toggle when the user has at least one // dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle // queues never see it — the MQTT layer ignores the field anyway. const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2); 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; if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali; onSave(data); }; const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' || requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' || vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' || nozzleOffsetCali !== 'unchanged'; return (

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

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

{/* Printer Assignment */}
{/* Queue Options */}
{/* Print Options */}
{hasDualNozzlePrinter && ( )}
); } // 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}
); } // Four-state selector for the tri-state calibration options in bulk edit // (unchanged / off / auto / on). Mirrors TriStateToggle's chrome. function CalibrationModeToggle({ label, value, onChange, t, }: { label: string; value: CalibrationMode | 'unchanged'; onChange: (val: CalibrationMode | 'unchanged') => void; t: (key: string) => string; }) { const modes: Array<{ key: CalibrationMode | 'unchanged'; label: string; active: string }> = [ { key: 'unchanged', label: '—', active: 'bg-bambu-dark-tertiary text-white' }, { key: 'off', label: t('settings.calibrationMode_off'), active: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' }, { key: 'auto', label: t('settings.calibrationMode_auto'), active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' }, { key: 'on', label: t('settings.calibrationMode_on'), active: 'bg-bambu-green/20 text-bambu-green' }, ]; return (
{label}
{modes.map(({ key, label: modeLabel, active }) => ( ))}
); } // 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)} )} {(() => { // Build plate badge so the user knows which plate to mount before // walking to the printer (#1281). Hidden when the 3MF doesn't // carry curr_bed_type or the slicer used an unknown label. const bed = getBedTypeInfo(item.bed_type); if (!bed) return null; return ( {bed.label} ); })()} {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}

)} {/* Filament-short flag from the dispatch pre-flight (#1496). */} {item.filament_short && item.status === 'pending' && (

{t('queue.filamentShort.rowBadge')}

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

{item.error_message}

)}
{/* Status badge + Actions */}
e.stopPropagation()}>
{isPrinting && ( )} {isPending && ( <> {item.manual_start && ( )} )} {isHistory && ( <> )}
); } type QueueRow = | { kind: 'item'; item: PrintQueueItem } | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] }; interface QueueRowRenderProps { row: QueueRow; collapsed: boolean; onToggleBatch?: () => void; onUngroup?: () => void; setEditItem: (item: PrintQueueItem) => void; setConfirmAction: (a: { type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem }) => void; startMutation: { mutate: (vars: { id: number; skipFilamentCheck?: boolean }) => void }; selectedItems: number[]; handleToggleSelect: (id: number) => void; timeFormat: TimeFormat; // eslint-disable-next-line @typescript-eslint/no-explicit-any hasPermission: (p: any) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any canModify: (resource: any, action: any, createdById?: number | null) => boolean; t: (key: string, options?: Record) => string; aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number }; } /** Renders either a single item or a collapsible batch group containing N * sibling items. The batch parent shows aggregate stats; children render * with the existing SortableQueueItem (only draggable inside the batch). */ function QueueRowRender(props: QueueRowRenderProps) { const { row, setEditItem, setConfirmAction, startMutation, selectedItems, handleToggleSelect, timeFormat, hasPermission, canModify, t, } = props; if (row.kind === 'item') { return ( setEditItem(row.item)} onCancel={() => setConfirmAction({ type: 'cancel', item: row.item })} onRemove={() => {}} onStop={() => {}} onRequeue={() => {}} onStart={() => startMutation.mutate({ id: row.item.id })} timeFormat={timeFormat} isSelected={selectedItems.includes(row.item.id)} onToggleSelect={() => handleToggleSelect(row.item.id)} hasPermission={hasPermission} canModify={canModify} t={t} /> ); } return ; } /** Batch parent header registered with dnd-kit so the whole group can be * reordered as one unit. Drag handle lives in the header itself; children * remain individually draggable while expanded for within-batch reorder. */ function SortableBatchRow({ row, collapsed, onToggleBatch, onUngroup, setEditItem, setConfirmAction, startMutation, selectedItems, handleToggleSelect, timeFormat, hasPermission, canModify, t, aggregateForRows, }: QueueRowRenderProps) { // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch'; // narrow up-front so the hook below can reference batchId unconditionally. const batchRow = row as Extract; const canReorder = hasPermission('queue:reorder'); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder }); const style = { transform: CSS.Transform.toString(transform), transition, }; const agg = aggregateForRows([batchRow]); const allChildIds = batchRow.items.map((i) => i.id); const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id)); // Status rollup: worst-of-children (failed > printing > pending). const childStatuses = new Set(batchRow.items.map((i) => i.status)); // We never put non-pending into a batch grouping but render defensively. const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed') ? 'failed' : childStatuses.has('printing') ? 'printing' : 'pending'; const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length; return (
{/* Parent header */}
{canReorder && (
)}
{collapsed ? ( ) : ( )}

{batchRow.batchName}

{t('queue.batch.label', { count: agg.count })}
{agg.time > 0 && ( {formatDuration(agg.time)} )} {agg.weight > 0 && ( {formatWeight(agg.weight)} )} {pendingChildren > 0 && rollupStatus === 'pending' && ( {t('queue.batch.pendingCount', { count: pendingChildren })} )}
{onUngroup && ( )}
{/* Children (only when expanded) */} {!collapsed && (
{batchRow.items.map((child) => ( setEditItem(child)} onCancel={() => setConfirmAction({ type: 'cancel', item: child })} onRemove={() => {}} onStop={() => {}} onRequeue={() => {}} onStart={() => startMutation.mutate({ id: child.id })} timeFormat={timeFormat} isSelected={selectedItems.includes(child.id)} onToggleSelect={() => handleToggleSelect(child.id)} hasPermission={hasPermission} canModify={canModify} t={t} /> ))}
)}
); } type HistoryRow = | { kind: 'item'; item: PrintQueueItem } | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] }; interface HistorySectionProps { items: PrintQueueItem[]; collapsed: boolean; sortBy: 'date' | 'name' | 'printer'; sortAsc: boolean; onSortByChange: (v: 'date' | 'name' | 'printer') => void; onSortAscToggle: () => void; onRemove: (item: PrintQueueItem) => void; onRequeue: (item: PrintQueueItem) => void; timeFormat: TimeFormat; batchCollapsed: Record; toggleBatchCollapsed: (id: number) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any hasPermission: (p: any) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any canModify: (resource: any, action: any, createdById?: number | null) => boolean; t: (key: string, options?: Record) => string; } function HistorySection({ items, sortBy, sortAsc, onSortByChange, onSortAscToggle, onRemove, onRequeue, timeFormat, batchCollapsed, toggleBatchCollapsed, hasPermission, canModify, t, }: HistorySectionProps) { if (items.length === 0) { return (

{t('queue.history.emptyTitle')}

{t('queue.history.emptyDescription')}

); } // Group siblings sharing a batch_id into a single collapsible row. // First-seen order is preserved for batches; items keep their sorted // position from the parent's sort selector. const rows: HistoryRow[] = []; const seenBatches = new Set(); for (const item of items.slice(0, 50)) { if (item.batch_id != null) { if (seenBatches.has(item.batch_id)) continue; seenBatches.add(item.batch_id); const siblings = items.filter((s) => s.batch_id === item.batch_id); rows.push({ kind: 'batch', batchId: item.batch_id, batchName: item.batch_name || t('queue.batch.defaultName'), items: siblings, }); } else { rows.push({ kind: 'item', item }); } } return (

{t('queue.sections.history')} ({t('queue.itemCount', { count: items.length })})

{rows.map((row) => { if (row.kind === 'item') { return ( onRemove(row.item)} onRequeue={() => onRequeue(row.item)} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} /> ); } // Batch group — spans the full grid width so it visually anchors // its children below it. The children themselves render in the // same responsive grid pattern inside the expanded body. const collapsed = batchCollapsed[row.batchId] ?? true; const completed = row.items.filter((i) => i.status === 'completed').length; const failed = row.items.filter((i) => i.status === 'failed').length; const skipped = row.items.filter((i) => i.status === 'skipped').length; const cancelled = row.items.filter((i) => i.status === 'cancelled').length; const latest = row.items .map((i) => i.completed_at || i.created_at) .filter((v): v is string => !!v) .sort() .at(-1); return (
{!collapsed && (
{row.items.map((child) => ( onRemove(child)} onRequeue={() => onRequeue(child)} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} /> ))}
)}
); })}
); } 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); // #1818: per-printer Resume-after-failure confirm modal. Tracks which // printer's gate the user is about to clear; null when no modal is open. const [resumeConfirm, setResumeConfirm] = useState<{ printerId: number; printerName: string; skippedCount: number; } | 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; }); // historyCollapsed legacy state retained only for localStorage migration; the // History tab renders unconditionally so this no longer drives the UI. // Tabbed page structure: Active queue stays as the main view; History // and Timeline split off. Persists per-user via localStorage. const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => { // URL deep-link wins so the legacy /pipelines/runs redirect lands on the // right tab. localStorage holds the per-user last-selected fallback. const search = new URLSearchParams(window.location.search); const url = search.get('tab'); if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') { return url; } const saved = localStorage.getItem('queue.activeTab'); if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved; return 'queue'; }); // Active-tab layout toggle. "position" = today's flat list; "printer" // groups items under per-printer section headers with aggregate stats. const [activeLayout, setActiveLayout] = useState<'position' | 'printer'>(() => { const saved = localStorage.getItem('queue.activeLayout'); return saved === 'printer' ? 'printer' : 'position'; }); // Per-batch collapse state, keyed by batch_id. Default = collapsed // (matches the SimplyPrint/Files convention — show the rollup first). const [batchCollapsed, setBatchCollapsed] = useState>(() => { try { const saved = localStorage.getItem('queue.batchCollapsed'); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); // Multi-drag bookkeeping for DragOverlay. Numeric for single items, string // `batch-` when a whole group is being dragged. const [activeDragId, setActiveDragId] = useState(null); // "Group as batch" modal. const [groupBatchModal, setGroupBatchModal] = useState(false); // Ungroup confirm. const [ungroupBatchId, setUngroupBatchId] = useState(null); // 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.activeTab', activeTab); }, [activeTab]); useEffect(() => { localStorage.setItem('queue.activeLayout', activeLayout); }, [activeLayout]); useEffect(() => { localStorage.setItem('queue.batchCollapsed', JSON.stringify(batchCollapsed)); }, [batchCollapsed]); 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'), }); // Filament-deficit confirmation state (#1496). When the backend returns // 409 with `code=insufficient_filament` we stash the deficit + item id // here; the modal at the bottom of the page reads it and the "Print // Anyway" path re-issues the start with `skipFilamentCheck=true`. const [filamentShortConfirm, setFilamentShortConfirm] = useState<{ itemId: number; deficit: Array<{ slot_id: number; required_grams: number; remaining_grams: number | null; filament_type?: string | null; }>; } | null>(null); const startMutation = useMutation({ mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) => api.startQueueItem(id, { skipFilamentCheck }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.released')); setFilamentShortConfirm(null); }, onError: (error: unknown, variables) => { if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') { const deficitRaw = (error.detail?.deficit ?? []) as Array<{ slot_id: number; required_grams: number; remaining_grams: number | null; filament_type?: string | null; }>; setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw }); return; } 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 resumeAfterFailureMutation = useMutation({ mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setResumeConfirm(null); showToast( t('queue.toast.resumedAfterFailure', { restored: result.restored, acknowledged: result.acknowledged, }), ); }, onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'), }); const createBatchMutation = useMutation({ mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data), onSuccess: (batch) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); setGroupBatchModal(false); // New batches start expanded so the user sees what they just grouped. setBatchCollapsed((prev) => ({ ...prev, [batch.id]: false })); showToast(t('queue.toast.batchCreated', { name: batch.name })); }, onError: () => showToast(t('queue.toast.batchCreateFailed'), 'error'), }); const ungroupBatchMutation = useMutation({ mutationFn: (id: number) => api.ungroupBatch(id), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setUngroupBatchId(null); showToast(t('queue.toast.batchUngrouped', { count: result.ungrouped_count })); }, onError: () => showToast(t('queue.toast.batchUngroupFailed'), '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 handleDragStart = (event: DragStartEvent) => { const id = event.active.id; setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveDragId(null); if (!over || active.id === over.id) return; // Resolve dragged source → movingIds (preserving order from pendingItems). // - `batch-`: every child of that batch, in their current order // - selected + dragged is one of them: contiguous multi-drag block // - otherwise: single row let movingIds: number[]; const activeId = active.id; if (typeof activeId === 'string' && activeId.startsWith('batch-')) { const batchId = Number(activeId.slice('batch-'.length)); movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id); } else { const draggedId = activeId as number; movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1 ? selectedItems.slice().sort((a, b) => { const ai = pendingItems.findIndex((i) => i.id === a); const bi = pendingItems.findIndex((i) => i.id === b); return ai - bi; }) : [draggedId]; } if (movingIds.length === 0) return; // Resolve drop target → index inside pendingItems. A `batch-` drop // target anchors at the batch's first child, so dropping above another // batch lands the moving block immediately before it. let overIndex: number; const overId = over.id; if (typeof overId === 'string' && overId.startsWith('batch-')) { const overBatchId = Number(overId.slice('batch-'.length)); overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId); } else { overIndex = pendingItems.findIndex((i) => i.id === overId); } if (overIndex === -1) return; // Remove the moving items, then re-insert at overIndex (adjusted). const overAnchor = pendingItems[overIndex]; const remaining = pendingItems.filter((i) => !movingIds.includes(i.id)); let insertAt = remaining.findIndex((i) => i.id === overAnchor.id); if (insertAt === -1) insertAt = overIndex; // If dragging downward across the drop target, insert AFTER it; upward // = before. dnd-kit's `over` is the row under the pointer, not the gap. const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]); if (firstMovingIndex < overIndex) insertAt += 1; const reordered = [ ...remaining.slice(0, insertAt), ...movingIds .map((id) => pendingItems.find((i) => i.id === id)) .filter((x): x is PrintQueueItem => !!x), ...remaining.slice(insertAt), ]; const updates = reordered.map((item, index) => ({ id: item.id, position: index + 1, })); reorderMutation.mutate(updates); }; // Group pending items by batch_id. Items with batch_id null render as // standalone rows; items sharing a batch_id render as a collapsible // group keyed by that id. Items inside a group keep their original // relative order from pendingItems. const groupedRows = useMemo(() => { const rows: QueueRow[] = []; const seenBatches = new Set(); for (const item of pendingItems) { if (item.batch_id != null) { if (seenBatches.has(item.batch_id)) continue; seenBatches.add(item.batch_id); const siblings = pendingItems.filter((s) => s.batch_id === item.batch_id); rows.push({ kind: 'batch', batchId: item.batch_id, batchName: item.batch_name || t('queue.batch.defaultName'), items: siblings, }); } else { rows.push({ kind: 'item', item }); } } return rows; }, [pendingItems, t]); // SortableContext ID list. // - Standalone pending items: their numeric id. // - Batch parents: the synthetic `batch-` string, always present so the // group itself is draggable and acts as a drop target whether collapsed // or expanded. // - Expanded batch children: their numeric id, so within-batch reorder // keeps working. Collapsed children are detached from the DOM and // intentionally omitted to keep dnd-kit's collision resolver clean. const sortableIds = useMemo<(number | string)[]>(() => { const ids: (number | string)[] = []; for (const row of groupedRows) { if (row.kind === 'item') { ids.push(row.item.id); } else { ids.push(`batch-${row.batchId}`); const collapsed = batchCollapsed[row.batchId] ?? true; if (!collapsed) { for (const child of row.items) ids.push(child.id); } } } return ids; }, [groupedRows, batchCollapsed]); // Items already in a batch can't be grouped; "Group as batch" only shows // when 2+ ungrouped items are selected. const canGroupSelected = useMemo(() => { if (selectedItems.length < 2) return false; return selectedItems.every((id) => { const item = pendingItems.find((p) => p.id === id); return item && item.batch_id == null; }); }, [selectedItems, pendingItems]); const toggleBatchCollapsed = (id: number) => { setBatchCollapsed((prev) => ({ ...prev, [id]: !(prev[id] ?? true) })); }; // Group by printer view. Items are bucketed by printer_id (null = model // assignment or unassigned, keyed by target_model or "unassigned"). type PrinterBucket = { key: string; printerId: number | null; targetModel: string | null; label: string; isUnassigned: boolean; rows: QueueRow[]; }; const printerBuckets = useMemo(() => { const buckets = new Map(); const bucketForItem = (item: PrintQueueItem): { key: string; label: string; printerId: number | null; targetModel: string | null; isUnassigned: boolean } => { if (item.printer_id) { return { key: `printer:${item.printer_id}`, label: item.printer_name || `Printer #${item.printer_id}`, printerId: item.printer_id, targetModel: null, isUnassigned: false, }; } if (item.target_model) { return { key: `model:${item.target_model}`, label: `${t('queue.filter.any')} ${item.target_model}`, printerId: null, targetModel: item.target_model, isUnassigned: false, }; } return { key: 'unassigned', label: t('queue.filter.unassigned'), printerId: null, targetModel: null, isUnassigned: true, }; }; for (const row of groupedRows) { const representative = row.kind === 'item' ? row.item : row.items[0]; if (!representative) continue; const meta = bucketForItem(representative); if (!buckets.has(meta.key)) { buckets.set(meta.key, { ...meta, rows: [] }); } buckets.get(meta.key)!.rows.push(row); } return Array.from(buckets.values()).sort((a, b) => { if (a.isUnassigned && !b.isUnassigned) return 1; if (!a.isUnassigned && b.isUnassigned) return -1; return a.label.localeCompare(b.label); }); }, [groupedRows, t]); // #1818: printers whose queue is gated by a prior failure that's poisoning // downstream `require_previous_success` items. We surface a per-printer // Resume banner above the active queue so the user can clear the gate + // restore the skipped jobs in one click, without re-queuing each one. // Detection key: skipped + the scheduler's exact gate string. Other skip // reasons (filament deficit, etc.) get their own UX and stay untouched. const gateBlockedPrinters = useMemo< Array<{ printerId: number; printerName: string; skippedCount: number }> >(() => { const counts = new Map(); queue?.forEach((item) => { if ( item.status === 'skipped' && item.error_message === 'Previous print failed or was aborted' && item.printer_id ) { const existing = counts.get(item.printer_id); if (existing) { existing.count += 1; } else { counts.set(item.printer_id, { name: item.printer_name || `Printer #${item.printer_id}`, count: 1, }); } } }); return Array.from(counts.entries()) .map(([printerId, { name, count }]) => ({ printerId, printerName: name, skippedCount: count, })) .sort((a, b) => a.printerName.localeCompare(b.printerName)); }, [queue]); const aggregateForRows = (rows: QueueRow[]) => { let count = 0; let time = 0; let weight = 0; for (const row of rows) { const items = row.kind === 'item' ? [row.item] : row.items; for (const item of items) { count += 1; time += item.print_time_seconds || 0; weight += item.filament_used_grams || 0; } } return { count, time, weight }; }; return (
{/* Header */}

{t('queue.title')}

{t('queue.subtitle')}

{/* Tab strip — Active queue is the main view; History and Timeline live in their own tabs so the queue page stays focused. */}
{([ { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length }, { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length }, { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null }, // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of // its own sidebar entry so the Print Queue page is the single // place an operator looks for "what's running / what ran". { id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null }, ]).map(({ id, label, icon: Icon, count }) => ( ))}
{/* Summary Stats — about the print queue, not pipelines. */} {activeTab !== 'pipelines' && } {/* #1818: Resume-after-failure banner. One row per printer whose queue is gated by a prior failed/aborted print. Visible regardless of tab/layout so the user can clear the gate without hunting for skipped items. Hidden entirely when no gates are active. */} {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
{gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
{t('queue.resumeAfterFailure.banner', { printer: printerName, count: skippedCount, })}
{t('queue.resumeAfterFailure.bannerHint')}
))}
)} {/* Filters — about the print queue items (printer / status / location). The Pipelines tab has its own pipeline + status filters inside the dashboard, so this row is hidden when that tab is active. */} {activeTab !== 'pipelines' && (
{uniqueLocations.length > 0 && ( )}
{activeTab === 'history' && historyItems.length > 0 && ( )}
)} {/* Queue-tab controls: layout toggle (Position / Printer) + SJF. Hidden on History/Timeline tabs since they don't apply. */} {activeTab === 'queue' && (
)} {/* Pipelines tab short-circuits before the queue-empty branch so the dashboard renders even when the regular queue is empty. */} {activeTab === 'pipelines' ? ( ) : isLoading ? (
{t('common.loading')}
) : queue?.length === 0 ? (

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

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

) : activeTab === '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} /> ) : activeTab === 'history' ? ( setHistorySortAsc(!historySortAsc)} onRemove={(item) => setConfirmAction({ type: 'remove', item })} onRequeue={setRequeueItem} timeFormat={timeFormat} batchCollapsed={batchCollapsed} toggleBatchCollapsed={toggleBatchCollapsed} hasPermission={hasPermission} canModify={canModify} 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 (now with "Group as batch") */}
{selectedItems.length > 0 && ( <> {t('queue.bulkEdit.selected', { count: selectedItems.length })}
{canGroupSelected && ( )} )}
{activeLayout === 'position' ? (
{groupedRows.map((row) => ( toggleBatchCollapsed(row.batchId) : undefined} onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined} setEditItem={setEditItem} setConfirmAction={setConfirmAction} startMutation={startMutation} selectedItems={selectedItems} handleToggleSelect={handleToggleSelect} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} aggregateForRows={aggregateForRows} /> ))}
) : (
{printerBuckets.map((bucket) => { const agg = aggregateForRows(bucket.rows); return (
{bucket.label} {t('queue.itemCount', { count: agg.count })} {agg.time > 0 && {formatDuration(agg.time)}} {agg.weight > 0 && {formatWeight(agg.weight)}}
{bucket.rows.map((row) => ( toggleBatchCollapsed(row.batchId) : undefined} onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined} setEditItem={setEditItem} setConfirmAction={setConfirmAction} startMutation={startMutation} selectedItems={selectedItems} handleToggleSelect={handleToggleSelect} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} aggregateForRows={aggregateForRows} /> ))}
); })}
)}
{(() => { if (activeDragId === null) return null; // Batch drag — show the group ghost with copy count. if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) { const batchId = Number(activeDragId.slice('batch-'.length)); const siblings = pendingItems.filter((i) => i.batch_id === batchId); if (siblings.length === 0) return null; const name = siblings[0].batch_name || t('queue.batch.defaultName'); return (
{t('queue.dragGhost.batch', { defaultValue: '{{name}} ({{count}} copies)', name, count: siblings.length, })}
); } // Multi-row drag — show the N-item ghost. if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) { return (
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
); } return null; })()}
)}
)} {/* Edit Modal */} {editItem && ( setEditItem(null)} /> )} {/* Re-queue Modal */} {requeueItem && ( setRequeueItem(null)} /> )} {/* Confirm Action Modal */} {filamentShortConfirm && ( t('queue.filamentShort.lineItem', { slot: d.slot_id, required: Math.round(d.required_grams), remaining: d.remaining_grams == null ? t('queue.filamentShort.unknown') : Math.round(d.remaining_grams), }), ) .join('\n') } confirmText={t('queue.filamentShort.printAnyway')} variant="warning" onConfirm={() => { startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true }); }} onCancel={() => setFilamentShortConfirm(null)} /> )} {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)} /> )} {/* #1818: Resume-after-failure confirm */} {resumeConfirm && ( resumeAfterFailureMutation.mutate(resumeConfirm.printerId)} onCancel={() => setResumeConfirm(null)} /> )} {/* Clear History Confirm Modal */} {showClearHistoryConfirm && ( { clearHistoryMutation.mutate(); setShowClearHistoryConfirm(false); }} onCancel={() => setShowClearHistoryConfirm(false)} /> )} {/* Bulk Edit Modal */} {showBulkEditModal && ( ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []} 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} /> )} {/* Group as batch modal — name prompt */} {groupBatchModal && ( { // Suggest a name derived from the first selected item's source. const first = pendingItems.find((i) => selectedItems.includes(i.id)); const raw = first?.archive_name || first?.library_file_name || ''; const cleaned = raw.replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, ''); return cleaned ? `${cleaned}` : t('queue.batch.defaultName'); })()} onSave={(name) => createBatchMutation.mutate({ name, item_ids: selectedItems })} onClose={() => setGroupBatchModal(false)} t={t} /> )} {/* Ungroup batch confirm */} {ungroupBatchId !== null && ( ungroupBatchMutation.mutate(ungroupBatchId)} onCancel={() => setUngroupBatchId(null)} /> )}
); } interface GroupBatchModalProps { itemCount: number; defaultName: string; isSaving: boolean; onSave: (name: string) => void; onClose: () => void; t: (key: string, options?: Record) => string; } function GroupBatchModal({ itemCount, defaultName, isSaving, onSave, onClose, t }: GroupBatchModalProps) { const [name, setName] = useState(defaultName); return (

{t('queue.batch.groupAsBatch')}

{t('queue.batch.groupAsBatchDescription', { count: itemCount })}

setName(e.target.value)} placeholder={t('queue.batch.namePlaceholder')} maxLength={120} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none mb-5" />
); }