| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- 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, unknown>) => 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<StatusFilter>('active');
- const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(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 (
- <div>
- <div className="flex flex-wrap items-center gap-2 mb-4">
- <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
- <h2 className="text-base sm:text-lg font-semibold text-white">{t('queue.batchOrders.title')}</h2>
- <div className="flex gap-1 ml-auto">
- {filters.map((value) => (
- <button
- key={value}
- type="button"
- onClick={() => setStatusFilter(value)}
- className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
- statusFilter === value
- ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
- : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
- }`}
- >
- {t(`queue.batchOrders.filter.${value}`)}
- </button>
- ))}
- </div>
- </div>
- {isLoading ? (
- <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
- ) : !batches?.length ? (
- <Card className="p-12 text-center border-dashed">
- <Package className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
- <h3 className="text-xl font-medium text-white mb-2">{t('queue.batchOrders.emptyTitle')}</h3>
- <p className="text-bambu-gray max-w-md mx-auto">{t('queue.batchOrders.emptyDescription')}</p>
- </Card>
- ) : (
- <div className="space-y-4">
- {batches.map((batch) => (
- <BatchOrderCard
- key={batch.id}
- batch={batch}
- currency={currency}
- canDispatch={canDispatch}
- canCancel={canCancel}
- isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
- onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
- onCancel={() => setCancelTarget(batch)}
- t={t}
- />
- ))}
- </div>
- )}
- {cancelTarget && (
- <ConfirmModal
- title={t('queue.cancelBatchConfirmTitle')}
- message={t('queue.cancelBatchConfirmMessage')}
- confirmText={t('queue.cancelBatch')}
- variant="warning"
- onConfirm={() => cancelMutation.mutate(cancelTarget.id)}
- onCancel={() => setCancelTarget(null)}
- />
- )}
- </div>
- );
- }
- const STATUS_STYLES: Record<string, string> = {
- 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, unknown>) => 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;
- const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
- const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
- return (
- <Card className="p-4">
- <div className="flex flex-wrap items-start gap-3 mb-3">
- <div className="min-w-0 flex-1">
- <div className="flex items-center gap-2 flex-wrap">
- <p className="text-white font-medium truncate">{batch.name}</p>
- <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_STYLES[batch.status] ?? STATUS_STYLES.cancelled}`}>
- {t(`queue.batchOrders.status.${batch.status}`)}
- </span>
- {!batch.has_targets && (
- <span
- className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray"
- title={t('queue.batchOrders.noTargetsHint')}
- >
- {t('queue.batchOrders.noTargets')}
- </span>
- )}
- </div>
- <p className="text-xs text-bambu-gray mt-1">
- {batch.created_by_username
- ? t('queue.addedBy', { name: batch.created_by_username })
- : null}
- {dueDate && (
- <span className={isOverdue ? 'text-orange-600 dark:text-orange-400' : ''}>
- {batch.created_by_username ? ' • ' : ''}
- {t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })}
- </span>
- )}
- </p>
- {batch.notes && <p className="text-xs text-bambu-gray mt-1 whitespace-pre-wrap">{batch.notes}</p>}
- </div>
- <div className="flex items-center gap-2 flex-shrink-0">
- {batch.has_targets && batch.remaining_count > 0 && batch.status !== 'cancelled' && canDispatch && (
- <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
- <PlayCircle className="w-4 h-4 mr-1" />
- {t('queue.batchOrders.dispatchRemaining', { count: batch.remaining_count })}
- </Button>
- )}
- {batch.status === 'active' && batch.pending_count > 0 && canCancel && (
- <Button variant="ghost" size="sm" onClick={onCancel}>
- <XCircle className="w-4 h-4 mr-1" />
- {t('queue.cancelBatch')}
- </Button>
- )}
- </div>
- </div>
- <div className="flex items-center gap-3 mb-2">
- <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
- <div
- className={`h-full rounded-full transition-all ${
- batch.status === 'completed' ? 'bg-bambu-green' : 'bg-blue-500'
- }`}
- style={{ width: `${Math.min(100, percent)}%` }}
- />
- </div>
- <span className="text-xs text-bambu-gray whitespace-nowrap tabular-nums">
- {t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
- </span>
- </div>
- <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray mb-1">
- {batch.printing_count > 0 && <span>{t('queue.batchOrders.printing', { count: batch.printing_count })}</span>}
- {batch.pending_count > 0 && <span>{t('queue.batch.pendingCount', { count: batch.pending_count })}</span>}
- {batch.failed_count > 0 && (
- <span className="text-orange-600 dark:text-orange-400 flex items-center gap-1">
- <AlertTriangle className="w-3 h-3" />
- {t('queue.batchOrders.failed', { count: batch.failed_count })}
- </span>
- )}
- {batch.has_targets && batch.remaining_count > 0 && (
- <span>{t('queue.batchOrders.remaining', { count: batch.remaining_count })}</span>
- )}
- {batch.print_time_seconds > 0 && (
- <span className="flex items-center gap-1">
- <Clock className="w-3 h-3" />
- {formatDuration(batch.print_time_seconds)}
- </span>
- )}
- {batch.actual_cost != null && (
- <span className="flex items-center gap-1">
- <Coins className="w-3 h-3" />
- <span>
- {t('queue.batchOrders.costSoFar', {
- amount: `${currency} ${batch.actual_cost.toFixed(2)}`,
- })}
- </span>
- {batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && (
- <span>
- {t('queue.batchOrders.costRemaining', {
- amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`,
- })}
- </span>
- )}
- </span>
- )}
- </div>
- {batch.has_targets && batch.plates.length > 0 && (
- <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
- {batch.plates.map((plate) => (
- <PlateRow
- key={`${plate.plate_id ?? 'file'}`}
- plate={plate}
- batchStatus={batch.status}
- currency={currency}
- canDispatch={canDispatch}
- isDispatching={isDispatching}
- onDispatch={() => onDispatch(plate.plate_id)}
- t={t}
- />
- ))}
- </div>
- )}
- </Card>
- );
- }
- 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, unknown>) => string;
- }) {
- const label = plate.plate_name
- || (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile'));
- return (
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
- <Layers className="w-3.5 h-3.5 text-bambu-gray flex-shrink-0" />
- <span className="text-white min-w-0 truncate">{label}</span>
- <span className="text-bambu-gray tabular-nums">
- {t('queue.batchOrders.plateProgress', {
- completed: plate.completed_count,
- target: plate.quantity_target,
- })}
- </span>
- {plate.failed_count > 0 && (
- <span className="text-orange-600 dark:text-orange-400">
- {t('queue.batchOrders.failed', { count: plate.failed_count })}
- </span>
- )}
- {plate.actual_cost != null && (
- <span className="text-bambu-gray tabular-nums">{`${currency} ${plate.actual_cost.toFixed(2)}`}</span>
- )}
- {plate.remaining > 0 && batchStatus !== 'cancelled' && (
- <span className="ml-auto flex items-center gap-2">
- <span className="text-bambu-gray">
- {t('queue.batchOrders.remaining', { count: plate.remaining })}
- </span>
- {canDispatch && (
- <button
- type="button"
- onClick={onDispatch}
- disabled={isDispatching}
- className="text-bambu-green hover:underline disabled:opacity-50"
- >
- {t('queue.batchOrders.dispatchPlate')}
- </button>
- )}
- </span>
- )}
- </div>
- );
- }
|