ConfirmOutcomeDialog.tsx 8.5 KB

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