BatchOrdersView.tsx 13 KB

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