ConfirmOutcomeDialog.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { ThumbsUp, ThumbsDown, X, RotateCcw, ImageOff } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { FAILURE_REASON_KEYS } from './EditArchiveModal';
  7. import { verdictSourceKey } from '../utils/verdictSource';
  8. import { PrintModal } from './PrintModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. interface ConfirmOutcomeDialogProps {
  11. archiveId: number;
  12. // The run asked about: plates of one archive each have their own question.
  13. // Omitted (an archive card's menu, an older link) means its latest run.
  14. runId?: number | null;
  15. onClose: () => void;
  16. }
  17. /**
  18. * Post-print outcome confirmation (#1898), Formlabs-style: finish photo plus
  19. * Good / Reject. Opens from the print_confirm_request WebSocket event (via
  20. * Layout), from the pending badge on an archive card, and from the
  21. * ?confirm=<id> deep link a push notification carries. Rejecting offers an
  22. * optional reason and a one-click reprint through the regular PrintModal.
  23. */
  24. export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutcomeDialogProps) {
  25. const { t } = useTranslation();
  26. const { showToast } = useToast();
  27. const queryClient = useQueryClient();
  28. const [rejecting, setRejecting] = useState(false);
  29. const [rejectReason, setRejectReason] = useState('');
  30. const [showReprint, setShowReprint] = useState(false);
  31. const { data: archive } = useQuery({
  32. queryKey: ['archive', archiveId, runId ?? null],
  33. queryFn: () => api.getArchive(archiveId, runId),
  34. });
  35. // The archive the answer goes to: a link to one merged into another
  36. // (archive redesign P2) loads that one, and the merged id writes nothing.
  37. const targetId = archive?.id ?? archiveId;
  38. const verdictMutation = useMutation({
  39. mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) =>
  40. api.updateArchive(targetId, {
  41. ...data,
  42. user_verdict_source: 'dialog',
  43. ...(runId != null ? { run_id: runId } : {}),
  44. }),
  45. onSuccess: (_updated, variables) => {
  46. queryClient.invalidateQueries({ queryKey: ['archives'] });
  47. queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });
  48. queryClient.invalidateQueries({ queryKey: ['archive', targetId] });
  49. // The archive page lists each print with its verdict.
  50. queryClient.invalidateQueries({ queryKey: ['archive-runs', targetId] });
  51. showToast(
  52. variables.user_verdict === 'good'
  53. ? t('confirmOutcome.savedGood')
  54. : t('confirmOutcome.savedReject'),
  55. 'success',
  56. );
  57. if (variables.user_verdict === 'good') {
  58. onClose();
  59. }
  60. },
  61. onError: () => showToast(t('confirmOutcome.saveFailed'), 'error'),
  62. });
  63. const name = archive?.print_name || archive?.filename || '';
  64. // The finish photo is prepended to archive.photos at print end (#1397);
  65. // fall back to the thumbnail when no camera shot exists.
  66. const photoFilename = archive?.photos?.[0] ?? null;
  67. const photoUrl = photoFilename ? api.getArchivePhotoUrl(targetId, photoFilename) : null;
  68. const alreadyDecided = archive?.user_verdict != null;
  69. // Why this print already has a verdict — the plate-clear default answers
  70. // prompts on its own, and without this the answer looks like it appeared
  71. // from nowhere (#1898).
  72. const sourceKey = verdictSourceKey(archive?.user_verdict_source);
  73. const handleGood = () => verdictMutation.mutate({ user_verdict: 'good' });
  74. const handleRejectSave = (reprint: boolean) => {
  75. verdictMutation.mutate(
  76. { user_verdict: 'reject', ...(rejectReason ? { failure_reason: rejectReason } : {}) },
  77. {
  78. onSuccess: () => {
  79. if (reprint) {
  80. setShowReprint(true);
  81. } else {
  82. onClose();
  83. }
  84. },
  85. },
  86. );
  87. };
  88. if (showReprint && archive) {
  89. return (
  90. <PrintModal
  91. mode="create"
  92. archiveId={archiveId}
  93. archiveName={name}
  94. preselectPlateId={archive.plate_id}
  95. onClose={onClose}
  96. />
  97. );
  98. }
  99. return (
  100. <div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" data-testid="confirm-outcome-dialog">
  101. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md overflow-hidden">
  102. {/* Header */}
  103. <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
  104. <h3 className="text-white font-semibold">{t('confirmOutcome.title')}</h3>
  105. <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors" title={t('common.close')}>
  106. <X className="w-5 h-5" />
  107. </button>
  108. </div>
  109. {/* Photo */}
  110. <div className="bg-bambu-dark aspect-video flex items-center justify-center overflow-hidden">
  111. {photoUrl ? (
  112. <img src={photoUrl} alt={name} className="w-full h-full object-contain" />
  113. ) : (
  114. <div className="flex flex-col items-center gap-2 text-bambu-gray/60">
  115. <ImageOff className="w-10 h-10" />
  116. <span className="text-xs">{t('confirmOutcome.noPhoto')}</span>
  117. </div>
  118. )}
  119. </div>
  120. <div className="p-4">
  121. <p className="text-sm text-white text-center mb-1 truncate" title={name}>{name}</p>
  122. {alreadyDecided ? (
  123. <>
  124. <p className="text-sm text-bambu-gray text-center">
  125. {archive?.user_verdict === 'good'
  126. ? t('confirmOutcome.alreadyGood')
  127. : t('confirmOutcome.alreadyRejected')}
  128. </p>
  129. {sourceKey && (
  130. <p className="text-xs text-bambu-gray/70 text-center mt-1" data-testid="verdict-source-hint">
  131. {t(sourceKey)}
  132. </p>
  133. )}
  134. </>
  135. ) : !rejecting ? (
  136. <div className="flex items-center justify-center gap-6 mt-3">
  137. <button
  138. type="button"
  139. onClick={() => setRejecting(true)}
  140. disabled={verdictMutation.isPending}
  141. className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
  142. >
  143. <ThumbsDown className="w-7 h-7" />
  144. <span className="text-xs">{t('confirmOutcome.reject')}</span>
  145. </button>
  146. <button
  147. type="button"
  148. onClick={handleGood}
  149. disabled={verdictMutation.isPending}
  150. className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
  151. >
  152. <ThumbsUp className="w-7 h-7" />
  153. <span className="text-xs">{t('confirmOutcome.good')}</span>
  154. </button>
  155. </div>
  156. ) : (
  157. <div className="mt-3">
  158. <label className="block text-xs text-bambu-gray mb-1">{t('confirmOutcome.rejectReason')}</label>
  159. <select
  160. value={rejectReason}
  161. onChange={(e) => setRejectReason(e.target.value)}
  162. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  163. >
  164. <option value="">{t('confirmOutcome.noReason')}</option>
  165. {FAILURE_REASON_KEYS.map((key) => (
  166. <option key={key} value={key}>{t(`editArchive.failureReasons.${key}`)}</option>
  167. ))}
  168. </select>
  169. <div className="flex gap-2 mt-3">
  170. <button
  171. type="button"
  172. onClick={() => handleRejectSave(false)}
  173. disabled={verdictMutation.isPending}
  174. className="flex-1 px-3 py-2 rounded-lg bg-red-500/80 hover:bg-red-500 text-white text-sm transition-colors"
  175. >
  176. {t('confirmOutcome.saveReject')}
  177. </button>
  178. <button
  179. type="button"
  180. onClick={() => handleRejectSave(true)}
  181. disabled={verdictMutation.isPending}
  182. className="flex-1 px-3 py-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green/60 text-white text-sm transition-colors flex items-center justify-center gap-1.5"
  183. >
  184. <RotateCcw className="w-4 h-4" />
  185. {t('confirmOutcome.rejectAndReprint')}
  186. </button>
  187. </div>
  188. <button
  189. type="button"
  190. onClick={() => setRejecting(false)}
  191. className="w-full mt-2 text-xs text-bambu-gray hover:text-white transition-colors"
  192. >
  193. {t('common.back')}
  194. </button>
  195. </div>
  196. )}
  197. {!alreadyDecided && !rejecting && (
  198. <button
  199. type="button"
  200. onClick={onClose}
  201. className="w-full mt-4 text-xs text-bambu-gray hover:text-white transition-colors"
  202. >
  203. {t('confirmOutcome.later')}
  204. </button>
  205. )}
  206. </div>
  207. </div>
  208. </div>
  209. );
  210. }