import { useEffect, useRef, useState, type ReactNode } from 'react'; import { ArrowLeft, Loader2, TriangleAlert } from 'lucide-react'; import { toast } from 'sonner'; import { FORM_SUBMISSION_STATUS_LABELS, FormSubmission, useFormSubmissionsService, } from '../../../services/FormSubmissionsService.tsx'; import { useQuestionnaireServiceContext } from '../../../services/QuestionnaireService.tsx'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '../../../components/ui/dialog.tsx'; import { operationStatusToast } from '../../../components/OperationStatusToast/OperationStatusToast.tsx'; import AttachSubmissionToPatient from './AttachSubmissionToPatient.tsx'; import { attachedPatientLabel } from './attachedPatientLabel'; import FormSubmissionReply, { SentReplies } from './FormSubmissionReply.tsx'; import QuarantinedAttachments from './QuarantinedAttachments.tsx'; import { SubmissionStatus, fullWhen } from './submissionDisplay.tsx'; import './FormSubmissionsPage.css'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { usePatientNamesByIds } from '../../../hooks/useAppointmentPatientNames'; type PendingAction = 'in_progress' | 'resolve' | null; type Step = 'review' | 'attach' | 'reply' | 'discard'; type Outcome = { kind: 'progress' | 'attached' | 'replied'; text: string }; interface FormSubmissionDetailDialogProps { submission: FormSubmission | null; open: boolean; onOpenChange: (open: boolean) => void; onUpdated: (submission: FormSubmission) => void; /** Which form it came from, as the list names it. */ formLabel?: string; } const STEP_TITLES: Record = { review: 'Form Submission', attach: 'Attach to a patient', reply: 'Reply by email', discard: 'Discard this reply?', }; // Turns a slug-style Questionnaire linkId (e.g. "emergency_contact-phone" or "preferredName") // into a readable fallback label. Only used when the real question text can't be resolved from // the Questionnaire AND the linkId isn't an opaque id. const prettifyLinkId = (linkId: string): string => { if (!linkId) { return 'Answer'; } const spaced = linkId .replace(/[-_]+/g, ' ') .replace(/([a-z0-9])([A-Z])/g, '$1 $2'); return spaced .split(' ') .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); }; // A UUID-shaped linkId carries no human meaning, so it falls back to a positional label. const isUuidLike = (linkId: string): boolean => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(linkId); // Walk a Questionnaire's items (groups nest) and build linkId -> item.text. The submission only // stores {linkId, value}, so this map is what turns the id into the real question label. type QLabelItem = { linkId?: string; text?: string; item?: QLabelItem[] }; const collectQuestionnaireLabels = ( items: QLabelItem[] | undefined, into: Record = {}, ): Record => { for (const item of items ?? []) { if (item.linkId && item.text) { into[item.linkId] = item.text; } if (item.item?.length) { collectQuestionnaireLabels(item.item, into); } } return into; }; const formatAnswerValue = (value: unknown): string => { if (value === null || value === undefined || value === '') { return '—'; } if (typeof value === 'boolean') { return value ? 'Yes' : 'No'; } if (Array.isArray(value)) { return value.length ? value.map((entry) => formatAnswerValue(entry)).join(', ') : '—'; } if (typeof value === 'object') { try { return JSON.stringify(value); } catch { return String(value); } } return String(value); }; const Alert = ({ title, children }: { title: string; children: ReactNode }) => (
); /** * One overlay for one submission. Keyed by its id, so nothing typed or loaded for one submission - * a reply draft, a note, a search, a late answer - can appear under another. */ const FormSubmissionDetailDialog = (props: FormSubmissionDetailDialogProps) => props.submission ? : null; const SubmissionOverlay = ({ submission, open, onOpenChange, onUpdated, formLabel, }: FormSubmissionDetailDialogProps & { submission: FormSubmission }) => { const { updateSubmission } = useFormSubmissionsService(); const { fetchQuestionnaire } = useQuestionnaireServiceContext(); const [current, setCurrent] = useState(submission); const [step, setStep] = useState('review'); const [outcome, setOutcome] = useState(null); const [draft, setDraft] = useState(''); const [resolutionNote, setResolutionNote] = useState(''); const [pendingAction, setPendingAction] = useState(null); const [childBusy, setChildBusy] = useState(false); const [progressError, setProgressError] = useState(); const [resolveError, setResolveError] = useState(); const [replyError, setReplyError] = useState(); const [answerLabels, setAnswerLabels] = useState>({}); // A write can outlive this overlay; its answer still reaches the list but never this screen. const alive = useRef(true); useEffect(() => () => { alive.current = false; }, []); // Real question labels from the Questionnaire; a failed read falls back to the linkId labels. useEffect(() => { const questionnaireId = submission.questionnaireId; if (!questionnaireId) return; let cancelled = false; fetchQuestionnaire(questionnaireId) .then((questionnaire) => { if (!cancelled) { setAnswerLabels(collectQuestionnaireLabels(questionnaire?.item as QLabelItem[] | undefined)); } }) .catch((err) => { console.error('Failed to load questionnaire labels for form submission:', err); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [submission.questionnaireId]); const busy = pendingAction !== null || childBusy; const status = current.status; const isTerminal = status === 'resolved' || status === 'converted'; const name = current.submitterContact?.name; const recipient = current.submitterContact?.email?.trim(); const replies = current.replies ?? []; const { names: patientNames, birthDates } = usePatientNamesByIds(status === 'converted' ? [current.promotedPatientId] : []); const attachedName = current.promotedPatientId && patientNames[current.promotedPatientId] ? attachedPatientLabel(patientNames[current.promotedPatientId], birthDates?.[current.promotedPatientId]) : undefined; const runUpdate = async ( action: Exclude, update: () => Promise, ): Promise => { setPendingAction(action); const toastId = operationStatusToast.saving(); try { const updated = await update(); onUpdated(updated); if (!alive.current) return 'gone'; setCurrent(updated); return updated; } catch (err) { console.error('Failed to update form submission:', err); if (!alive.current) return 'gone'; const message = relayServerFailure(err, 'The submission was not changed.'); if (action === 'resolve') setResolveError(message); else setProgressError(message); return 'failed'; } finally { operationStatusToast.dismiss(toastId); if (alive.current) setPendingAction(null); } }; const handleMarkInProgress = async () => { if (busy) return; setProgressError(undefined); const result = await runUpdate('in_progress', () => updateSubmission(current.submissionId, { status: 'in_progress' }), ); if (typeof result === 'object') { setOutcome({ kind: 'progress', text: 'This submission is now in progress. No reply was sent and no patient record was attached.' }); } }; const handleResolve = async () => { if (busy) return; const trimmedNote = resolutionNote.trim(); const result = await runUpdate('resolve', () => updateSubmission(current.submissionId, { status: 'resolved', ...(trimmedNote ? { resolutionNote: trimmedNote } : {}), }), ); // Resolve closes to the same list; only a confirmed save does. if (typeof result === 'object') { setResolveError(undefined); toast.success('Submission resolved'); onOpenChange(false); } }; // X, Escape and the scrim all come here. An unsent reply asks first; a write in flight holds. const requestClose = () => { if (busy) return; if (step === 'discard') { setStep('reply'); return; } if (draft.trim()) { setStep('discard'); return; } onOpenChange(false); }; const backToReview = () => { setStep('review'); setOutcome(null); }; const facts = (
Submitted by
{name || 'Anonymous / no contact provided'}
{formLabel && (
Form
{formLabel}
)} {recipient && (
Email entered on form
{recipient}
)}
Submitted
{fullWhen(current.createdAt)}
{current.submitterContact?.phone && (
Phone entered on form
{current.submitterContact.phone}
)}
); // The form copies its Message answer into `message` for the list's one-line summary; the answers already say it. const messageIsAnAnswer = !!current.message && current.answers.some((answer) => formatAnswerValue(answer.value).trim() === current.message?.trim()); const answers = ( <> {(current.message || current.answers.length > 0) && (

Answers

)} {current.message && !messageIsAnAnswer && (
Message

{current.message}

)} {current.answers.map((answer, index) => (
{answerLabels[answer.linkId] || (isUuidLike(answer.linkId) ? `Question ${index + 1}` : prettifyLinkId(answer.linkId))}

{formatAnswerValue(answer.value)}

))} {/* The files a stranger attached are part of what was sent in, so they follow the answers. */} ); // What staff did about it: shown on the details, not repeated under a success message. const history = ( <> {current.resolutionNote && ( <>

Internal note

{current.resolutionNote}

)} {replies.length > 0 && ( <>

Replies sent

)} ); const noteField = (