BatchOrdersView.tsx 15 KB

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