import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useLifetime } from '../hooks/useLifetime'; import { SAVED_WITHHELD_MESSAGE } from '../utils/documentListScope'; import { Flex, FlexItem } from '../components/ui/flex'; import { DismissibleAlert } from '../components/ui/dismissible-alert'; import { Modal } from '../components/ui/wp-modal-shim'; import { useForm, FormProvider } from 'react-hook-form'; import { DocumentReference, Organization } from 'fhir/r4'; import { useDocumentReferenceServiceContext, useAuthServiceContext, usePatientServiceContext } from '../services'; import { FhirDocumentReference } from '../components'; import { useFhirDocumentReferenceTargetedBroadcast } from '../hooks'; import { describeWriteFailure, mayHaveBeenWritten } from '../utils/fetchFailureMessage'; const SAVE_RECEIVED_THEN_WITHDRAWN = 'Your access changed after your save was received. You can no longer change this document here.'; const SAVE_UNCONFIRMED_THEN_WITHDRAWN = 'Your access changed, and we could not confirm whether your last save went through. Reload to check before changing it again.'; import { isArchived } from '../utils/archivedResource'; import { ArchivedNotice } from '../components/ArchivedNotice/ArchivedNotice'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; import { isFiledOriginal, keepFiledLineage } from '../utils/records/filedOriginal'; import { filingOwnerOf, useDocumentFiling, type FilingTarget } from '../services/DocumentFilingService'; import { useSaveScope } from '../utils/saveOperation'; interface DocumentReferenceModalProps { isOpen: boolean; onClose: () => void; documentReference?: DocumentReference; onSuccess?: (documentReference: DocumentReference) => void; onError?: (error: string) => void; title?: string; isRoot?: boolean; /** Read at the moment of writing, so a submit still validating when access is lost writes nothing. */ mayWrite?: () => boolean; /** * Asked once Save is pressed and before anything is written: undefined to go ahead, or the sentence that * says why nothing was saved. For access that may have changed since this dialog opened. */ confirmWrite?: () => Promise; /** Set when the writer's access changed while this edit was open: it stays readable, and nothing can be sent. */ withdrawn?: string; } export const DocumentReferenceModal: React.FC = ({ isOpen, onClose, documentReference, onSuccess, onError, title, isRoot = false, mayWrite, confirmWrite, withdrawn }) => { const { userRole, USER_ROLES, wpOrganization, currentUser } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { transformToFhirDocumentReference, transformFromFhirDocumentReference, createDocumentReference, updateDocumentReference, fetchDocumentReference, filings } = useDocumentReferenceServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); // A save that answers after this dialog closed, or the viewer changed, reports to nobody; the delayed close // is cancelled with the dialog, so it can never close the one opened after it. const lifetime = useLifetime(); const closeTimerRef = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(closeTimerRef.current), []); // Hidden, this dialog stays mounted, so each opening is its own: a save or delayed close from an earlier // opening touches nothing in a later one. const openingRef = useRef(0); const saveOwner = filingOwnerOf({ currentUser, wpOrganization }); const saves = useSaveScope(`${saveOwner.viewer}|${saveOwner.practice}|${patientState?.id ?? ''}|${documentReference?.id ?? 'new'}|${isOpen}`); // Set when the server refused an Update because the document was archived meanwhile; the reread copy when // it could be read, else the document as opened, which the refusal has already said is archived. const [archivedMeanwhile, setArchivedMeanwhile] = useState(); // What this opening's last write is known to have done, kept apart from what the writer may still do: a change // of access never turns a save that was received, or one whose answer was lost, into one that was not saved. const [writeOutcome, setWriteOutcome] = useState<'unsent' | 'sending' | 'received' | 'unconfirmed'>('unsent'); useEffect(() => { openingRef.current += 1; clearTimeout(closeTimerRef.current); setSubmitting(false); setSuccess(undefined); setError(undefined); setArchivedMeanwhile(undefined); }, [isOpen]); const isNew = !documentReference; // Staff file each opening's new files through one filing: a create for the practice or the patient, or an // amendment of the edition this edit opened on. Each opening is a new target, so a new filing. const staffSide = userRole !== USER_ROLES.PATIENT && userRole !== USER_ROLES.RELATED_PERSON; const editedId = documentReference?.id; const editedVersion = documentReference?.meta?.versionId; const filingTarget = useMemo((): FilingTarget | undefined => { if (!isOpen || !staffSide) return undefined; if (!isNew) return editedId && editedVersion ? { kind: 'amendment', documentId: editedId, baseVersion: editedVersion } : undefined; if (isRoot) return { kind: 'create', purpose: 'practice-document' }; return patientState?.id ? { kind: 'create', purpose: 'document', patientId: patientState.id } : undefined; }, [isOpen, staffSide, isNew, editedId, editedVersion, isRoot, patientState?.id]); const filing = useDocumentFiling(filingTarget, filingTarget, filings, filingOwnerOf({ currentUser, wpOrganization })); const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Document Reference`; // An archived document opens read-only here as on its page: the notice, and no Update. const archived = !isNew && (isArchived(documentReference) || archivedMeanwhile !== undefined); const shown = archivedMeanwhile ?? documentReference; const methods = useForm({ defaultValues: { masterIdentifier: null, identifier: [], status: '', docStatus: '', type: null, category: [], subject: null, date: '', author: [], authenticator: null, custodian: null, relatesTo: [], description: '', securityLabel: [], content: [{}], context: { encounter: [], event: [], period: null, facilityType: null, practiceSetting: null, sourcePatientInfo: null, related: [] } } }); const { handleSubmit, reset } = methods; // A REFUSED CLICK SAYS SO WHERE IT HAPPENED. The button is in the modal header, above a form that // can be several screens tall, and react-hook-form reports each problem inline beside its field - // so somebody scrolled past the first card saw nothing change at all. This fills the error slot // this modal already has, at the top of the form, next to the button they pressed. const onInvalid = (errors: unknown) => setError(invalidSubmitMessage(errors)); const init = useCallback((documentReference: DocumentReference) => { reset(transformFromFhirDocumentReference(documentReference)); }, [transformFromFhirDocumentReference, reset]); useFhirDocumentReferenceTargetedBroadcast({ documentReference, isOpen, onUpdate: init, onDelete: onClose }); useEffect(() => { if (documentReference) { init(documentReference); } }, [documentReference, init]); const onSubmit = async (data: any) => { if (withdrawn) return; if (mayWrite && !mayWrite()) return; // An archived document is read-only; the server refuses the write too. if (!isNew && isArchived(documentReference)) { const errorMsg = 'This document is archived and cannot be changed. Nothing was saved.'; setError(errorMsg); onError?.(errorMsg); return; } if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } setSubmitting(true); setSuccess(undefined); setError(undefined); const mounted = lifetime(); const operation = saves.start(); const opening = openingRef.current; const alive = () => mounted() && opening === openingRef.current && operation.belongs(); try { if (confirmWrite) { const refusal = await confirmWrite(); if (!alive()) return; if (refusal || (mayWrite && !mayWrite())) { const message = refusal ?? 'Your access no longer lets you change this document.'; setError(message); onError?.(message); return; } } let result: DocumentReference | undefined; setWriteOutcome('sending'); if (isNew) { result = await operation.step(() => createDocumentReference(transformToFhirDocumentReference(data, patientState, wpOrganization.current as Organization), { filingId: filing.filingId(), transport: operation.transport }), true); } else { // THE PATIENT AND THE PRACTICE GO BACK IN, and leaving them out is not a // cosmetic omission: `transformToFhirDocumentReference` writes `subject` and // `custodian` only when it is handed them, so an update without them PUTs a document // belonging to nobody, at no practice. It never showed because nothing in the product // could reach an edit until now. // A filed Records original keeps its file and source binding, which the form does not carry. result = await operation.step(() => updateDocumentReference(keepFiledLineage(documentReference, { id: documentReference?.id, ...transformToFhirDocumentReference(data, patientState, wpOrganization.current as Organization) }), { filingId: filing.filingId(), transport: operation.transport }), true); } if (!alive()) return; setWriteOutcome('received'); const successMsg = result ? `Document reference ${isNew ? 'created' : 'updated'} successfully.` : SAVED_WITHHELD_MESSAGE; setSuccess(successMsg); if (result) onSuccess?.(result); clearTimeout(closeTimerRef.current); closeTimerRef.current = setTimeout(() => { if (!alive()) return; setSuccess(undefined); onClose(); }, 1500); } catch (err: any) { if (!alive()) return; setWriteOutcome((outcome) => (outcome === 'sending' ? (mayHaveBeenWritten(err) ? 'unconfirmed' : 'unsent') : outcome)); console.error(`Error ${isNew ? 'creating' : 'updating'} document reference:`, err); const errorMsg = describeWriteFailure(err, `This document could not be ${isNew ? 'created' : 'updated'}`); setError(errorMsg); onError?.(errorMsg); if (!isNew && documentReference?.id && (err as { body?: { code?: string } } | null)?.body?.code === 'ARCHIVED_DOCUMENT') { setArchivedMeanwhile(documentReference); fetchDocumentReference(documentReference.id) .then((reread) => { if (!alive() || !isArchived(reread)) return; setArchivedMeanwhile(reread); init(reread); }) .catch(() => undefined); } } finally { if (alive()) setSubmitting(false); } }; if (!isOpen) return null; return ( {/* No Cancel: the X already calls onClose. Its only extra was a stray console.log, which goes with it. */} {!archived && !withdrawn && ( )} ]} >
{archived && isArchived(shown) && } {withdrawn && writeOutcome !== 'sending' && ( {writeOutcome === 'received' ? SAVE_RECEIVED_THEN_WITHDRAWN : writeOutcome === 'unconfirmed' ? SAVE_UNCONFIRMED_THEN_WITHDRAWN : withdrawn} )} {success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )}
{/* `enableInlineActions` was passed here from a name that was never declared - a ReferenceError sitting in a file nothing could reach, which the type ratchet counted as one of its known errors and therefore said nothing about. MEASURED 2026-09-07: the first press of the Edit control that made this modal reachable took down the screen with "enableInlineActions is not defined". The prop defaults to true, which is what the create path already passes. */}
); };