import { useEffect, useRef, useState } from 'react'; import type { Bundle } from 'fhir/r4'; import { AnchorTamperedError, useCareViewAnchorServiceContext } from '../../services'; import { downloadBundleJson } from '../../utils/downloadBundleJson'; import { Checkbox } from '../ui/checkbox'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { LegalRecordPrintView } from './LegalRecordPrintView'; import { TamperedBanner } from './TamperedBanner'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; /** * Format a Date as a `YYYY-MM-DDTHH:mm` string in the user's LOCAL wall-clock time - the same * approach Cap A's "as of" picker uses (`src/components/PatientSnapshot/PatientSnapshot.tsx`). * Duplicated (not imported) because that file only has an inline, unexported helper. */ function toLocalInputValue(d: Date): string { const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000); return local.toISOString().slice(0, 16); } export interface ExportAuthority { viewerId?: string; practiceId?: string; patientId?: string; actingAsId?: string; sessionId?: number; viewerEpoch?: number; } export interface ExportLegalRecordDialogProps { open: boolean; anchorId: string; openingToken: number; authority: ExportAuthority; getLiveAuthority: () => ExportAuthority; isOpeningCurrent: (token: number) => boolean; onClose: (token: number) => void; } /** * The B3 "Export legal record" flow, opened from `AnchorView`'s export button (shared component - * surfaces on both the staff and patient sides). Two rendering states: * * 1. **Form** (Radix `ui/dialog`): a toggle "Also include full record as of" + a date/time picker * (disabled until the toggle is on; default = now), and a single primary `btn btn-blue` "Export" * action. No Cancel button - the dialog's built-in X close handles cancel. * 2. **Success**: once `exportCareViewAnchor` resolves, the JSON download fires immediately AND the * signed Bundle is handed to `LegalRecordPrintView`, rendered in a full-screen container - * deliberately NOT inside the Radix dialog's centered, max-width-constrained box - so an * on-screen review and a browser Print/Save-as-PDF both capture the record itself, not dialog * chrome. `LegalRecordPrintView` already renders its own "Print / Save as PDF" and * "Download JSON" actions internally (Task 10) - this component only adds the full-screen * container + a Close action. * * A tampered anchor (`AnchorTamperedError`, `code === 'anchor-tampered'`) blocks the export * entirely - no download, no print view - and shows the same `TamperedBanner` text `AnchorView`'s * read-only integrity banner uses, so the two surfaces never disagree on wording. */ export function ExportLegalRecordDialog({ open, anchorId, openingToken, authority, getLiveAuthority, isOpeningCurrent, onClose, }: ExportLegalRecordDialogProps) { const { exportCareViewAnchor } = useCareViewAnchorServiceContext(); const [includePointInTime, setIncludePointInTime] = useState(false); const [asOfLocal, setAsOfLocal] = useState(() => toLocalInputValue(new Date())); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [tampered, setTampered] = useState(false); const [bundle, setBundle] = useState(null); const attemptRef = useRef(null); const nextAttemptRef = useRef(0); const liveRef = useRef(true); useEffect(() => { liveRef.current = true; return () => { liveRef.current = false; }; }, []); const nowLocal = toLocalInputValue(new Date()); const matchesLiveAuthority = () => { const current = getLiveAuthority(); return ( liveRef.current && isOpeningCurrent(openingToken) && current.viewerId === authority.viewerId && current.practiceId === authority.practiceId && current.patientId === authority.patientId && current.actingAsId === authority.actingAsId && current.sessionId === authority.sessionId && current.viewerEpoch === authority.viewerEpoch ); }; const ownsAttempt = (attempt: number) => attemptRef.current === attempt && matchesLiveAuthority(); const handleClose = () => onClose(openingToken); const handleExport = async () => { if (attemptRef.current !== null) return; // The callback may already be retired; never start a request under a dead token. if (!matchesLiveAuthority()) return; // Reserve before React state so two presses in one turn cannot start two exports. const attempt = nextAttemptRef.current + 1; nextAttemptRef.current = attempt; attemptRef.current = attempt; setSubmitting(true); setError(null); setTampered(false); try { const includeAtAdmission = includePointInTime; const asOfIso = includeAtAdmission ? new Date(asOfLocal).toISOString() : undefined; const result = await exportCareViewAnchor(anchorId, { includePointInTime: includeAtAdmission, asOf: asOfIso, }); if (!ownsAttempt(attempt)) return; // Court-grade: the JSON is the authoritative artifact - always download it, then hand // the same signed Bundle to the human-readable print view. downloadBundleJson(result); if (!ownsAttempt(attempt)) return; setBundle(result); } catch (err: unknown) { if (!ownsAttempt(attempt)) return; // Detect the tampered-anchor discriminant robustly: the error is expected to be an // `AnchorTamperedError` instance (checked first), but `code` is also checked directly // so the block is correct regardless of prototype chain / build-target transpilation. const isTampered = err instanceof AnchorTamperedError || (err as { code?: unknown })?.code === 'anchor-tampered'; if (isTampered) { setTampered(true); } else { setError(relayServerFailure(err, 'The legal record could not be exported.')); } } finally { if (ownsAttempt(attempt)) { attemptRef.current = null; setSubmitting(false); } } }; // Success: full-screen printable record. Intentionally rendered instead of (not inside) the // Radix dialog once a signed Bundle comes back. if (bundle) { return (
); } return ( { if (!next) handleClose(); }} > Export legal record
setIncludePointInTime(checked === true)} disabled={submitting} />
setAsOfLocal(e.target.value)} className="rounded-md border px-2 py-1 text-sm disabled:opacity-50" /> {tampered && } {error &&

{error}

}
); } export default ExportLegalRecordDialog;