ConfirmOutcomeDialog.tsx 9.9 KB

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