BatchOrdersView.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { useEffect, useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { PrintBatch, PrintBatchPlateProgress, Permission } from '../api/client';
  6. import { Card } from './Card';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { formatDuration, parseUTCDate } from '../utils/date';
  11. import { getCurrencySymbol } from '../utils/currency';
  12. type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
  13. interface BatchOrdersViewProps {
  14. hasPermission: (p: Permission) => boolean;
  15. t: (key: string, options?: Record<string, unknown>) => string;
  16. /** A batch to scroll to and highlight (``/queue?batch=<id>``, e.g. from Bambuddy Orders). */
  17. focusBatchId?: number | null;
  18. }
  19. /**
  20. * Batch orders tab (#342).
  21. *
  22. * An order lives longer than the queue it spawned: once its runs finish they
  23. * leave the active queue entirely, so the Queue and History tabs each hold
  24. * only half the picture. This is the one place that shows what was asked for
  25. * against what has actually been produced — including the runs that failed and
  26. * are therefore still owed.
  27. */
  28. export function BatchOrdersView({ hasPermission, t, focusBatchId = null }: BatchOrdersViewProps) {
  29. const queryClient = useQueryClient();
  30. const { showToast } = useToast();
  31. // A linked batch may be finished or cancelled: show every status so it's there.
  32. const [statusFilter, setStatusFilter] = useState<StatusFilter>(focusBatchId ? 'all' : 'active');
  33. const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
  34. const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
  35. const currency = getCurrencySymbol(settings?.currency || 'USD');
  36. const { data: batches, isLoading } = useQuery({
  37. queryKey: ['batches', statusFilter],
  38. queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter),
  39. });
  40. const focusFound = focusBatchId != null && !!batches?.some((b) => b.id === focusBatchId);
  41. useEffect(() => {
  42. if (focusFound) document.getElementById(`batch-${focusBatchId}`)?.scrollIntoView({ block: 'center' });
  43. }, [focusFound, focusBatchId]);
  44. const invalidate = () => {
  45. queryClient.invalidateQueries({ queryKey: ['batches'] });
  46. queryClient.invalidateQueries({ queryKey: ['queue'] });
  47. };
  48. const dispatchMutation = useMutation({
  49. mutationFn: ({ id, plateId }: { id: number; plateId?: number | null }) =>
  50. api.dispatchBatch(id, plateId !== undefined ? { plate_id: plateId, only_plate: true } : {}),
  51. onSuccess: (batch) => {
  52. invalidate();
  53. showToast(t('queue.batchOrders.dispatched', { name: batch.name }), 'success');
  54. },
  55. onError: (error: Error) => showToast(error.message, 'error'),
  56. });
  57. const cancelMutation = useMutation({
  58. mutationFn: (id: number) => api.cancelBatch(id),
  59. onSuccess: () => {
  60. invalidate();
  61. setCancelTarget(null);
  62. showToast(t('queue.batchCancelled'), 'success');
  63. },
  64. onError: (error: Error) => showToast(error.message, 'error'),
  65. });
  66. const canDispatch = hasPermission('queue:create' as Permission);
  67. const canCancel = hasPermission('queue:delete_all' as Permission);
  68. const filters: StatusFilter[] = ['active', 'completed', 'cancelled', 'all'];
  69. return (
  70. <div>
  71. <div className="flex flex-wrap items-center gap-2 mb-4">
  72. <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
  73. <h2 className="text-base sm:text-lg font-semibold text-white">{t('queue.batchOrders.title')}</h2>
  74. <div className="flex gap-1 ml-auto">
  75. {filters.map((value) => (
  76. <button
  77. key={value}
  78. type="button"
  79. onClick={() => setStatusFilter(value)}
  80. className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
  81. statusFilter === value
  82. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  83. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
  84. }`}
  85. >
  86. {t(`queue.batchOrders.filter.${value}`)}
  87. </button>
  88. ))}
  89. </div>
  90. </div>
  91. {isLoading ? (
  92. <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
  93. ) : !batches?.length ? (
  94. <Card className="p-12 text-center border-dashed">
  95. <Package className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  96. <h3 className="text-xl font-medium text-white mb-2">{t('queue.batchOrders.emptyTitle')}</h3>
  97. <p className="text-bambu-gray max-w-md mx-auto">{t('queue.batchOrders.emptyDescription')}</p>
  98. </Card>
  99. ) : (
  100. <div className="space-y-4">
  101. {batches.map((batch) => (
  102. <BatchOrderCard
  103. key={batch.id}
  104. batch={batch}
  105. currency={currency}
  106. canDispatch={canDispatch}
  107. canCancel={canCancel}
  108. isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
  109. highlighted={batch.id === focusBatchId}
  110. onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
  111. onCancel={() => setCancelTarget(batch)}
  112. t={t}
  113. />
  114. ))}
  115. </div>
  116. )}
  117. {cancelTarget && (
  118. <ConfirmModal
  119. title={t('queue.cancelBatchConfirmTitle')}
  120. message={t('queue.cancelBatchConfirmMessage')}
  121. confirmText={t('queue.cancelBatch')}
  122. variant="warning"
  123. onConfirm={() => cancelMutation.mutate(cancelTarget.id)}
  124. onCancel={() => setCancelTarget(null)}
  125. />
  126. )}
  127. </div>
  128. );
  129. }
  130. const STATUS_STYLES: Record<string, string> = {
  131. active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
  132. completed: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300',
  133. cancelled: 'bg-bambu-dark-tertiary text-bambu-gray',
  134. };
  135. function BatchOrderCard({
  136. batch,
  137. currency,
  138. canDispatch,
  139. canCancel,
  140. isDispatching,
  141. onDispatch,
  142. onCancel,
  143. t,
  144. highlighted = false,
  145. }: {
  146. batch: PrintBatch;
  147. currency: string;
  148. canDispatch: boolean;
  149. canCancel: boolean;
  150. isDispatching: boolean;
  151. onDispatch: (plateId?: number | null) => void;
  152. onCancel: () => void;
  153. t: (key: string, options?: Record<string, unknown>) => string;
  154. highlighted?: boolean;
  155. }) {
  156. // Progress is measured against the target, not against what was queued —
  157. // that is the whole difference between an order and a grouping.
  158. const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
  159. + batch.printing_count + batch.failed_count;
  160. const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
  161. // Runs the order owes that nothing can produce any more: their plate's last
  162. // queue item was deleted, so there is no configuration left to clone (#2960).
  163. const strandedCount = batch.has_targets ? batch.remaining_count - batch.dispatchable_count : 0;
  164. const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
  165. const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
  166. return (
  167. <Card id={`batch-${batch.id}`} className={`p-4 ${highlighted ? 'ring-2 ring-bambu-green' : ''}`}>
  168. <div className="flex flex-wrap items-start gap-3 mb-3">
  169. <div className="min-w-0 flex-1">
  170. <div className="flex items-center gap-2 flex-wrap">
  171. <p className="text-white font-medium truncate">{batch.name}</p>
  172. <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_STYLES[batch.status] ?? STATUS_STYLES.cancelled}`}>
  173. {t(`queue.batchOrders.status.${batch.status}`)}
  174. </span>
  175. {!batch.has_targets && (
  176. <span
  177. className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray"
  178. title={t('queue.batchOrders.noTargetsHint')}
  179. >
  180. {t('queue.batchOrders.noTargets')}
  181. </span>
  182. )}
  183. </div>
  184. <p className="text-xs text-bambu-gray mt-1">
  185. {batch.created_by_username
  186. ? t('queue.addedBy', { name: batch.created_by_username })
  187. : null}
  188. {dueDate && (
  189. <span className={isOverdue ? 'text-orange-600 dark:text-orange-400' : ''}>
  190. {batch.created_by_username ? ' • ' : ''}
  191. {t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })}
  192. </span>
  193. )}
  194. </p>
  195. {batch.notes && <p className="text-xs text-bambu-gray mt-1 whitespace-pre-wrap">{batch.notes}</p>}
  196. </div>
  197. <div className="flex items-center gap-2 flex-shrink-0">
  198. {batch.has_targets && batch.dispatchable_count > 0 && batch.status !== 'cancelled' && canDispatch && (
  199. <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
  200. <PlayCircle className="w-4 h-4 mr-1" />
  201. {t('queue.batchOrders.dispatchRemaining', { count: batch.dispatchable_count })}
  202. </Button>
  203. )}
  204. {/* Cancel is the only way to close an order out, so it must not be
  205. gated on there being pending items to cancel: an order whose runs
  206. were all deleted has none, and is exactly the one that needs
  207. closing (#2960). */}
  208. {batch.status === 'active' && canCancel && (
  209. <Button variant="ghost" size="sm" onClick={onCancel}>
  210. <XCircle className="w-4 h-4 mr-1" />
  211. {t('queue.cancelBatch')}
  212. </Button>
  213. )}
  214. </div>
  215. </div>
  216. <div className="flex items-center gap-3 mb-2">
  217. <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
  218. <div
  219. className={`h-full rounded-full transition-all ${
  220. batch.status === 'completed' ? 'bg-bambu-green' : 'bg-blue-500'
  221. }`}
  222. style={{ width: `${Math.min(100, percent)}%` }}
  223. />
  224. </div>
  225. <span className="text-xs text-bambu-gray whitespace-nowrap tabular-nums">
  226. {t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
  227. </span>
  228. </div>
  229. <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray mb-1">
  230. {batch.printing_count > 0 && <span>{t('queue.batchOrders.printing', { count: batch.printing_count })}</span>}
  231. {batch.pending_count > 0 && <span>{t('queue.batch.pendingCount', { count: batch.pending_count })}</span>}
  232. {batch.failed_count > 0 && (
  233. <span className="text-orange-600 dark:text-orange-400 flex items-center gap-1">
  234. <AlertTriangle className="w-3 h-3" />
  235. {t('queue.batchOrders.failed', { count: batch.failed_count })}
  236. </span>
  237. )}
  238. {batch.has_targets && batch.remaining_count > 0 && (
  239. <span>{t('queue.batchOrders.remaining', { count: batch.remaining_count })}</span>
  240. )}
  241. {batch.print_time_seconds > 0 && (
  242. <span className="flex items-center gap-1">
  243. <Clock className="w-3 h-3" />
  244. {formatDuration(batch.print_time_seconds)}
  245. </span>
  246. )}
  247. {batch.actual_cost != null && (
  248. <span className="flex items-center gap-1">
  249. <Coins className="w-3 h-3" />
  250. <span>
  251. {t('queue.batchOrders.costSoFar', {
  252. amount: `${currency} ${batch.actual_cost.toFixed(2)}`,
  253. })}
  254. </span>
  255. {batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && (
  256. <span>
  257. {t('queue.batchOrders.costRemaining', {
  258. amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`,
  259. })}
  260. </span>
  261. )}
  262. </span>
  263. )}
  264. </div>
  265. {strandedCount > 0 && batch.status !== 'cancelled' && (
  266. <p className="flex items-start gap-1.5 text-xs text-orange-600 dark:text-orange-400 mb-1">
  267. <AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  268. <span>{t('queue.batchOrders.strandedNotice', { runs: strandedCount, owed: batch.remaining_count })}</span>
  269. </p>
  270. )}
  271. {batch.has_targets && batch.plates.length > 0 && (
  272. <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
  273. {batch.plates.map((plate) => (
  274. <PlateRow
  275. key={`${plate.plate_id ?? 'file'}`}
  276. plate={plate}
  277. batchStatus={batch.status}
  278. currency={currency}
  279. canDispatch={canDispatch}
  280. isDispatching={isDispatching}
  281. onDispatch={() => onDispatch(plate.plate_id)}
  282. t={t}
  283. />
  284. ))}
  285. </div>
  286. )}
  287. </Card>
  288. );
  289. }
  290. function PlateRow({
  291. plate,
  292. batchStatus,
  293. currency,
  294. canDispatch,
  295. isDispatching,
  296. onDispatch,
  297. t,
  298. }: {
  299. plate: PrintBatchPlateProgress;
  300. batchStatus: string;
  301. currency: string;
  302. canDispatch: boolean;
  303. isDispatching: boolean;
  304. onDispatch: () => void;
  305. t: (key: string, options?: Record<string, unknown>) => string;
  306. }) {
  307. const label = plate.plate_name
  308. || (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile'));
  309. return (
  310. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
  311. <Layers className="w-3.5 h-3.5 text-bambu-gray flex-shrink-0" />
  312. <span className="text-white min-w-0 truncate">{label}</span>
  313. <span className="text-bambu-gray tabular-nums">
  314. {t('queue.batchOrders.plateProgress', {
  315. completed: plate.completed_count,
  316. target: plate.quantity_target,
  317. })}
  318. </span>
  319. {plate.failed_count > 0 && (
  320. <span className="text-orange-600 dark:text-orange-400">
  321. {t('queue.batchOrders.failed', { count: plate.failed_count })}
  322. </span>
  323. )}
  324. {plate.actual_cost != null && (
  325. <span className="text-bambu-gray tabular-nums">{`${currency} ${plate.actual_cost.toFixed(2)}`}</span>
  326. )}
  327. {plate.remaining > 0 && batchStatus !== 'cancelled' && (
  328. <span className="ml-auto flex items-center gap-2">
  329. <span className="text-bambu-gray">
  330. {t('queue.batchOrders.remaining', { count: plate.remaining })}
  331. </span>
  332. {!plate.can_dispatch ? (
  333. <span className="text-orange-600 dark:text-orange-400">
  334. {t('queue.batchOrders.strandedPlate')}
  335. </span>
  336. ) : (
  337. canDispatch && (
  338. <button
  339. type="button"
  340. onClick={onDispatch}
  341. disabled={isDispatching}
  342. className="text-bambu-green hover:underline disabled:opacity-50"
  343. >
  344. {t('queue.batchOrders.dispatchPlate')}
  345. </button>
  346. )
  347. )}
  348. </span>
  349. )}
  350. </div>
  351. );
  352. }