import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Spacer } from '../../../../components/ui/spacer'; import { useForm, FormProvider } from 'react-hook-form'; import { DocumentReference, Organization } from 'fhir/r4'; import { Check, FileText } from 'lucide-react'; import { compareVersions, onViewerChange } from '../../../../utils/writeThroughHold'; import { useAuthServiceContext, useDocumentReferenceServiceContext, usePatientServiceContext } from '../../../../services'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator, FhirDocumentReference } from '../../../../components'; import { useFhirDocumentReferenceTargetedBroadcast } from '../../../../hooks'; import { useArchive } from '../../../../hooks/useArchive'; import { useLifetime } from '../../../../hooks/useLifetime'; import ArchiveConfirmModal from '../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { ArchivedNotice } from '../../../../components/ArchivedNotice/ArchivedNotice'; import { isArchived } from '../../../../utils/archivedResource'; import { describeFetchFailure, describeWriteFailure, isRefusedRead } from '../../../../utils/fetchFailureMessage'; import { Button } from '../../../../components/ui/button'; import { SAVED_WITHHELD_MESSAGE } from '../../../../utils/documentListScope'; import { isStaffExperience } from '../../../../utils/mountContext'; import { isFiledOriginal, keepFiledLineage } from '../../../../utils/records/filedOriginal'; import { filingOwnerOf, useDocumentFiling, type FilingTarget } from '../../../../services/DocumentFilingService'; import { useSaveScope } from '../../../../utils/saveOperation'; interface EditDocumentReferencePageProps { isRoot?: boolean; } // What a blank document form holds; a form closed for a change of viewer goes back to exactly this. const emptyDocumentForm = () => ({ 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: [] } }); // Each document, patient and practice gets fresh page state, so an answer for one cannot land on another. const EditDocumentReferencePage: React.FC = (props) => { const { documentId } = useParams<{ documentId: string }>(); const { wpOrganization } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const scope = [props.isRoot ? 'practice' : `patient:${patientState?.id ?? ''}`, wpOrganization.current?.id ?? '', documentId].join('|'); return ; }; const DocumentReferenceInScope: React.FC = ({ isRoot = false }) => { const navigate = useNavigate(); const lifetime = useLifetime(); const { documentId } = useParams<{ documentId: string }>(); const { wpOrganization, userRole, USER_ROLES, currentUser } = useAuthServiceContext(); const saveOwner = filingOwnerOf({ currentUser, wpOrganization }); const saves = useSaveScope(`${saveOwner.viewer}|${saveOwner.practice}|${documentId}`); const { patientState } = usePatientServiceContext(); const { fetchDocumentReference, currentDocumentReference, transformToFhirDocumentReference, transformFromFhirDocumentReference, createDocumentReference, updateDocumentReference, archiveDocumentReference, filings, } = useDocumentReferenceServiceContext(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); // A create the saver may not read is done: the same form must not create it a second time. const [savedWithheld, setSavedWithheld] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [documentReference, setDocumentReference] = useState(); const canonicalRef = useRef(); canonicalRef.current = documentReference; const [failure, setFailure] = useState<{ refused: boolean; message: string } | undefined>(); const [attempt, setAttempt] = useState(0); const askedRef = useRef(false); const ticketRef = useRef(0); const isNew = documentId === 'new'; // A patient or helper sends a document only in answer to a practice's request, never from a blank form. const noStandingAdd = isNew && !isRoot && !!userRole && (userRole === USER_ROLES?.PATIENT || userRole === USER_ROLES?.RELATED_PERSON); const spacerMargin = '24px'; const reviewed = documentReference?.docStatus === 'final' || documentReference?.docStatus === 'amended'; // Staff file new files through one filing per round of this page: a create for the practice (never a patient // selected earlier) or the patient, or an amendment of the edition shown. A finished save starts the next round. const [filingRound, setFilingRound] = useState(0); const staffSide = !!userRole && userRole !== USER_ROLES?.PATIENT && userRole !== USER_ROLES?.RELATED_PERSON; const editedId = documentReference?.id; const editedVersion = documentReference?.meta?.versionId; const filingTarget = useMemo((): FilingTarget | undefined => { void filingRound; if (!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; }, [filingRound, staffSide, isNew, editedId, editedVersion, isRoot, patientState?.id]); const filing = useDocumentFiling(filingTarget, filingTarget, filings, filingOwnerOf({ currentUser, wpOrganization })); // A reviewed document is part of what a clinical decision relied on; only the practice may change it. const locked = reviewed && !isStaffExperience(); // An archived document is read-only for everyone: no Save, no Archive again, no review. The server refuses the // write too; this is so nobody types into a form that cannot be saved. const archived = !isNew && isArchived(documentReference); const markReviewed = async () => { if (!documentReference || isArchived(documentReference)) return; setSubmitting(true); setError(undefined); const alive = lifetime(); try { const updated = await updateDocumentReference({ ...documentReference, docStatus: 'final' }); // An answer that arrives after this page closed, or the viewer changed, never fills the next form. if (!alive()) return; if (!updated) { setSavedWithheld(true); setDocumentReference(undefined); setSuccess(SAVED_WITHHELD_MESSAGE); return; } init(updated); setSuccess('Marked reviewed.'); } catch (err) { if (!alive()) return; setError(describeWriteFailure(err, 'Failed to mark the document reviewed')); rereadIfOvertaken(err, alive); } finally { setSubmitting(false); } }; const methods = useForm({ defaultValues: emptyDocumentForm() }); const { handleSubmit, reset } = methods; const init = useCallback((documentReference: DocumentReference) => { if (canonicalRef.current && canonicalRef.current.id === documentReference.id && compareVersions(documentReference, canonicalRef.current) === -1) return; canonicalRef.current = documentReference; reset(transformFromFhirDocumentReference(documentReference)); setDocumentReference(documentReference); }, [transformFromFhirDocumentReference]); useFhirDocumentReferenceTargetedBroadcast({ documentReference, documentReferenceId: documentId, isRoot: isRoot, // Only the document this page still shows is refreshed by a broadcast. onUpdate: (updated) => { if (documentReference && updated.id === documentReference.id) init(updated); }, onDelete: () => navigate('..', { relative: 'path' }) }); const archive = useArchive((reason, note) => archiveDocumentReference(documentReference!, reason, note)); // Leaving drops any answer in flight; a remount, as StrictMode replays, asks afresh. useEffect(() => () => { ticketRef.current++; askedRef.current = false; }, []); // A document opened for one viewer, acting-as subject or practice is closed when that changes; the next // viewer's own read decides what this page shows, and an answer still out for the last one is dropped. useEffect(() => onViewerChange(() => { ticketRef.current++; askedRef.current = false; setDocumentReference(undefined); reset(emptyDocumentForm()); setSavedWithheld(false); setSuccess(undefined); setError(undefined); setSubmitting(false); setLoading(false); setFailure(undefined); setAttempt((n) => n + 1); archive.close(); }), [reset, archive.close]); useEffect(() => { if (isNew || documentReference || failure) return; // Only a currently valid snapshot may open the form at once; a list a write or an expiry has made stale // is not read from, and the document is asked for instead. const foundDocumentReference = currentDocumentReference(documentId); if (foundDocumentReference) { init(foundDocumentReference); return; } // Asked once per page and per Try again; a list or provider change never asks again. if (askedRef.current) return; askedRef.current = true; const ticket = ++ticketRef.current; setLoading(true); fetchDocumentReference(documentId as string) .then((fetched) => { if (ticket === ticketRef.current) init(fetched); }) .catch((err) => { if (ticket !== ticketRef.current) return; console.error('Error loading document reference:', err); setFailure({ refused: isRefusedRead(err), message: describeFetchFailure(err, 'This document could not be loaded') }); }) .finally(() => { if (ticket === ticketRef.current) setLoading(false); }); }, [documentReference, failure, currentDocumentReference, documentId, fetchDocumentReference, init, isNew, attempt]); const tryAgain = () => { askedRef.current = false; setFailure(undefined); setAttempt((n) => n + 1); }; // The server refused because the document was archived or changed meanwhile: read it again, so the page shows // it as it is now (read-only if archived) instead of offering the same refused Save. const rereadIfOvertaken = (err: unknown, alive: () => boolean) => { const code = (err as { body?: { code?: string } } | null)?.body?.code; if ((code !== 'ARCHIVED_DOCUMENT' && code !== 'DOCUMENT_CHANGED') || !documentReference?.id) return; fetchDocumentReference(documentReference.id).then((current) => { if (alive()) init(current); }).catch(() => undefined); }; const onSubmit = async (data: any) => { if (savedWithheld || noStandingAdd) return; // An existing document the page no longer holds cannot be saved: there is nothing to name. if (!isNew && !documentReference) return; if (!isNew && isArchived(documentReference)) return; setSubmitting(true); setSuccess(undefined); setError(undefined); const mounted = lifetime(); const operation = saves.start(); const alive = () => mounted() && operation.belongs(); try { if (isNew) { const created = await operation.step(() => createDocumentReference(transformToFhirDocumentReference(data, isRoot ? undefined : patientState, wpOrganization.current as Organization), { filingId: filing.filingId(), transport: operation.transport }), true); if (!alive()) return; if (!created) { setSavedWithheld(true); setSuccess(SAVED_WITHHELD_MESSAGE); return; } navigate('..', { relative: 'path' }); } 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. const updated = 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; if (!updated?.id || !updated.meta?.versionId) { setSavedWithheld(true); setDocumentReference(undefined); setSuccess(SAVED_WITHHELD_MESSAGE); return; } init(updated); setFilingRound((round) => round + 1); } setSuccess(`Document reference ${isNew ? 'created' : 'updated'} successfully.`); setTimeout(() => { if (alive()) setSuccess(undefined); }, 3_000); } catch (err: any) { if (!alive()) return; console.error(`Error ${isNew ? 'creating' : 'updating'} document reference:`, err); setError(describeWriteFailure(err, `This document could not be ${isNew ? 'created' : 'updated'}`)); rereadIfOvertaken(err, alive); } finally { if (alive()) setSubmitting(false); } }; if (!isNew && failure) { return ( <>
{/* A refusal names itself and offers no retry. */}

{failure.refused ? 'This document is not available to you' : 'Unable to load this document'}

{failure.message}

{!failure.refused && ( )}
); } if (noStandingAdd) { return ( <>

Documents are sent when your care team asks

When your care team asks for a document, you can send it from that request in your tasks.

); } // No form until the document is here: an empty one offers Save and Archive on nothing. if (savedWithheld) return <>

{SAVED_WITHHELD_MESSAGE}

; if (loading || (!isNew && !documentReference)) { return ( ); } return ( resourceType="DocumentReference" resourceId={documentId || ''}> {(showHistory) => (
{!isNew && ( )} {!isNew && !reviewed && !archived && isStaffExperience() && ( )} {!locked && !archived && !savedWithheld && ( )} {!isNew && !locked && !archived && ( )} {archived && ( <> )} {locked && !archived && ( <> Your care team has reviewed this document, so it can no longer be changed or removed. )} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} { if (!o) archive.close(); }} resourceLabel="Document Reference" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { // An archive that answers after this page was replaced does not move the next one. const alive = lifetime(); if (await archive.submit(reason, note) && alive()) { navigate('..', { relative: 'path' }); } }} />
)} ); }; export default EditDocumentReferencePage;