import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react'; import { api } from '../api/client'; import type { PrintBatch, PrintBatchPlateProgress, Permission } from '../api/client'; import { Card } from './Card'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { formatDuration, parseUTCDate } from '../utils/date'; import { getCurrencySymbol } from '../utils/currency'; type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all'; interface BatchOrdersViewProps { hasPermission: (p: Permission) => boolean; t: (key: string, options?: Record) => string; } /** * Batch orders tab (#342). * * An order lives longer than the queue it spawned: once its runs finish they * leave the active queue entirely, so the Queue and History tabs each hold * only half the picture. This is the one place that shows what was asked for * against what has actually been produced — including the runs that failed and * are therefore still owed. */ export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) { const queryClient = useQueryClient(); const { showToast } = useToast(); const [statusFilter, setStatusFilter] = useState('active'); const [cancelTarget, setCancelTarget] = useState(null); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings }); const currency = getCurrencySymbol(settings?.currency || 'USD'); const { data: batches, isLoading } = useQuery({ queryKey: ['batches', statusFilter], queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter), }); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['batches'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); }; const dispatchMutation = useMutation({ mutationFn: ({ id, plateId }: { id: number; plateId?: number | null }) => api.dispatchBatch(id, plateId !== undefined ? { plate_id: plateId, only_plate: true } : {}), onSuccess: (batch) => { invalidate(); showToast(t('queue.batchOrders.dispatched', { name: batch.name }), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const cancelMutation = useMutation({ mutationFn: (id: number) => api.cancelBatch(id), onSuccess: () => { invalidate(); setCancelTarget(null); showToast(t('queue.batchCancelled'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const canDispatch = hasPermission('queue:create' as Permission); const canCancel = hasPermission('queue:delete_all' as Permission); const filters: StatusFilter[] = ['active', 'completed', 'cancelled', 'all']; return (

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

{filters.map((value) => ( ))}
{isLoading ? (
{t('common.loading')}
) : !batches?.length ? (

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

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

) : (
{batches.map((batch) => ( dispatchMutation.mutate({ id: batch.id, plateId })} onCancel={() => setCancelTarget(batch)} t={t} /> ))}
)} {cancelTarget && ( cancelMutation.mutate(cancelTarget.id)} onCancel={() => setCancelTarget(null)} /> )}
); } const STATUS_STYLES: Record = { active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300', completed: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300', cancelled: 'bg-bambu-dark-tertiary text-bambu-gray', }; function BatchOrderCard({ batch, currency, canDispatch, canCancel, isDispatching, onDispatch, onCancel, t, }: { batch: PrintBatch; currency: string; canDispatch: boolean; canCancel: boolean; isDispatching: boolean; onDispatch: (plateId?: number | null) => void; onCancel: () => void; t: (key: string, options?: Record) => string; }) { // Progress is measured against the target, not against what was queued — // that is the whole difference between an order and a grouping. const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count + batch.printing_count + batch.failed_count; const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0; // Runs the order owes that nothing can produce any more: their plate's last // queue item was deleted, so there is no configuration left to clone (#2960). const strandedCount = batch.has_targets ? batch.remaining_count - batch.dispatchable_count : 0; const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null; const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now(); return (

{batch.name}

{t(`queue.batchOrders.status.${batch.status}`)} {!batch.has_targets && ( {t('queue.batchOrders.noTargets')} )}

{batch.created_by_username ? t('queue.addedBy', { name: batch.created_by_username }) : null} {dueDate && ( {batch.created_by_username ? ' • ' : ''} {t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })} )}

{batch.notes &&

{batch.notes}

}
{batch.has_targets && batch.dispatchable_count > 0 && batch.status !== 'cancelled' && canDispatch && ( )} {/* Cancel is the only way to close an order out, so it must not be gated on there being pending items to cancel: an order whose runs were all deleted has none, and is exactly the one that needs closing (#2960). */} {batch.status === 'active' && canCancel && ( )}
{t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
{batch.printing_count > 0 && {t('queue.batchOrders.printing', { count: batch.printing_count })}} {batch.pending_count > 0 && {t('queue.batch.pendingCount', { count: batch.pending_count })}} {batch.failed_count > 0 && ( {t('queue.batchOrders.failed', { count: batch.failed_count })} )} {batch.has_targets && batch.remaining_count > 0 && ( {t('queue.batchOrders.remaining', { count: batch.remaining_count })} )} {batch.print_time_seconds > 0 && ( {formatDuration(batch.print_time_seconds)} )} {batch.actual_cost != null && ( {t('queue.batchOrders.costSoFar', { amount: `${currency} ${batch.actual_cost.toFixed(2)}`, })} {batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && ( {t('queue.batchOrders.costRemaining', { amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`, })} )} )}
{strandedCount > 0 && batch.status !== 'cancelled' && (

{t('queue.batchOrders.strandedNotice', { runs: strandedCount, owed: batch.remaining_count })}

)} {batch.has_targets && batch.plates.length > 0 && (
{batch.plates.map((plate) => ( onDispatch(plate.plate_id)} t={t} /> ))}
)} ); } function PlateRow({ plate, batchStatus, currency, canDispatch, isDispatching, onDispatch, t, }: { plate: PrintBatchPlateProgress; batchStatus: string; currency: string; canDispatch: boolean; isDispatching: boolean; onDispatch: () => void; t: (key: string, options?: Record) => string; }) { const label = plate.plate_name || (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile')); return (
{label} {t('queue.batchOrders.plateProgress', { completed: plate.completed_count, target: plate.quantity_target, })} {plate.failed_count > 0 && ( {t('queue.batchOrders.failed', { count: plate.failed_count })} )} {plate.actual_cost != null && ( {`${currency} ${plate.actual_cost.toFixed(2)}`} )} {plate.remaining > 0 && batchStatus !== 'cancelled' && ( {t('queue.batchOrders.remaining', { count: plate.remaining })} {!plate.can_dispatch ? ( {t('queue.batchOrders.strandedPlate')} ) : ( canDispatch && ( ) )} )}
); }