import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Item, ItemGroup } from "../ui/item-group"; import { FormProvider, useForm } from 'react-hook-form'; import { Icon } from "../ui/icon"; import { Divider } from '../ui/divider'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { ExternalLink, RefreshCw, FileText as page, Calendar as calendar } from 'lucide-react'; import { Heading } from '../ui/heading'; import { Flex, FlexItem, FlexBlock } from '../ui/flex'; import './FhirHistory.css'; import { Text } from '../ui/text'; import { Spacer } from '../ui/spacer'; import { Disabled } from '../ui/disabled'; import { Modal } from '../ui/wp-modal-shim'; import { SHEET_BODY_TYPES } from '../../utils/sheetBodyDisplays'; import { Sheet, SheetContent } from '../ui/sheet'; import { Bundle, Resource, ImmunizationEvaluation, FamilyMemberHistory, AllergyIntolerance, AdverseEvent, Procedure, ClinicalImpression, DetectedIssue, MedicationStatement, MedicationRequest, MedicationAdministration, ImmunizationRecommendation, Immunization, MedicationDispense, Device, DeviceUseStatement, CareTeam, DeviceRequest, CarePlan, NutritionOrder, Goal, RiskAssessment, VisionPrescription, Encounter, EpisodeOfCare, Task, DocumentReference, Questionnaire, QuestionnaireResponse, RelatedPerson, Patient, Practitioner, Organization, Location, HealthcareService, PractitionerRole, Schedule, Condition, Observation } from 'fhir/r4'; // `page`/`calendar` still feed WordPress's here - that is the separate icon sweep, not this one. import { environment } from '../../environments/environment.ts'; import { isResourceDeleted } from '../../utils/isResourceDeleted.ts'; import { resolveHistoryBaseUrl } from '../../utils/fieldHistory/useFieldHistoryFetch.ts'; import { describeFetchFailure } from '../../utils/fetchFailureMessage'; import { useAuthServiceContext, useHttpService, useQuestionnaireServiceContext } from '../../services'; import { AppSpinner, FhirDetectedIssueDisplay, FhirDeviceDisplay, FhirDeviceUseStatementDisplay, FhirFamilyMemberHistoryDisplay, FhirImmunizationEvaluationDisplay, FhirMedicationAdministrationDisplay, FhirAllergyIntoleranceDisplay, FhirProcedureDisplay, FhirAdverseEventDisplay, FhirClinicalImpressionDisplay, FhirMedicationStatementDisplay, FhirMedicationRequestDisplay, FhirMedicationDispenseDisplay, FhirImmunizationDisplay, FhirImmunizationRecommendationDisplay, FhirDeviceRequestDisplay, FhirCarePlanDisplay, FhirCareTeamDisplay, FhirGoalDisplay, FhirNutritionOrderDisplay, FhirRiskAssessmentDisplay, FhirVisionPrescriptionDisplay, FhirEncounterDisplay, FhirEpisodeOfCareDisplay, FhirTaskDisplay, FhirDocumentReferenceDisplay, FhirQuestionnaireResponseDisplay, FhirRelatedPersonDisplay, FhirPatientDisplay, FhirPractitionerDisplay, FhirOrganizationDisplay, FhirLocationDisplay, FhirHealthcareServiceDisplay, FhirPractitionerRoleDisplay, FhirScheduleDisplay, FhirConditionDisplay, FhirObservationDisplay } from '../index.ts'; import QuestionnairePreview from '../../pages/DashboardPage/QuestionnairesPage/QuestionnairePage/QuestionnairePreview/QuestionnairePreview.tsx'; import { useRequestRecordCorrection } from '../../hooks'; import { isConditionRecordedByCareTeam, isProcedureRecordedByCareTeam, resolveConditionAuthorReference, resolveProcedureAuthorReference, } from '../../utils/messaging/requestCorrection'; import { formatFhirCodeableConcept } from '../../utils/fhirCodeableConcept.tsx'; import { ANSWERS_WITHHELD_MESSAGE, answersWithheld } from '../../utils/answersWithheld'; import { Badge } from '../ui/badge'; interface FhirHistoryProps { resourceType: string; resourceId: string; renderHistoryItem?: (item: T, index: number) => React.ReactNode; questionnaireResponse?: QuestionnaireResponse; } export const FhirHistory = ({ resourceType, resourceId, renderHistoryItem, questionnaireResponse }: FhirHistoryProps) => { const { get } = useHttpService(); const { fetchQuestionnaire, transformFromFhirQuestionnaire } = useQuestionnaireServiceContext(); const { userRole, USER_ROLES, currentOrganization, currentUser, actingAsSessionRef } = useAuthServiceContext(); const isPatientRequestingCorrection = Boolean(userRole) && [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const handleRequestProcedureCorrection = async (note: string) => { if (!selectedItem || resourceType !== 'Procedure') return { ok: false }; const procedure = selectedItem as unknown as Procedure; const label = formatFhirCodeableConcept(procedure.code) || 'this procedure'; return requestCorrection(resolveProcedureAuthorReference(procedure), label, note); }; const handleRequestConditionCorrection = async (note: string) => { if (!selectedItem || resourceType !== 'Condition') return { ok: false }; const condition = selectedItem as unknown as Condition; const label = formatFhirCodeableConcept(condition.code) || 'this condition'; return requestCorrection(resolveConditionAuthorReference(condition), label, note); }; const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [history, setHistory] = useState<{ bundle: Bundle | undefined; owner: string }>(); const [selectedItem, setSelectedItem] = useState(); const shownCorrectionAuthor = !selectedItem ? undefined : resourceType === 'Procedure' ? resolveProcedureAuthorReference(selectedItem as unknown as Procedure) : resourceType === 'Condition' ? resolveConditionAuthorReference(selectedItem as unknown as Condition) : undefined; const { requestCorrection, error: correctionError, recovery: correctionRecovery } = useRequestRecordCorrection(shownCorrectionAuthor); const [showModal, setShowModal] = useState(false); const [responseLoading, setResponseLoading] = useState(false); // - what View Answers says when it cannot open. const [responseError, setResponseError] = useState(null); const [showResponse, setShowResponse] = useState(false); // The version whose answers the preview holds; it is shown only while it still may be. const [openedVersion, setOpenedVersion] = useState(); // Whose screen the open details or preview were opened on. const [openFor, setOpenFor] = useState(); // What history rows and openings belong to: this record, its subject, the viewer, their practice and session. // Read when a read or opening starts, after its load, and at render, never only when it began. const practiceId = currentOrganization?.id; const scope = `${resourceType}/${resourceId}/${questionnaireResponse?.subject?.reference ?? ''}/${practiceId ?? ''}` + `/${currentUser?.fhir?.id ?? ''}/${actingAsSessionRef?.current ?? ''}`; const latest = useRef({ scope, questionnaireResponse }); latest.current = { scope, questionnaireResponse }; const historyBundle = history?.owner === scope ? history.bundle : undefined; // Openings and history reads are numbered; only the newest may publish. const opening = useRef(0); const historyRead = useRef(0); // A version's answers stay closed when they are withheld now or were withheld on that version. The server's // marker decides; this only keeps the screen from showing what it already said is withheld. const answersClosed = (item: Resource | undefined, against: QuestionnaireResponse | undefined = questionnaireResponse) => resourceType === 'QuestionnaireResponse' && Boolean(item) && ((against ? answersWithheld(against) : false) || answersWithheld(item as QuestionnaireResponse)); const ofThisRecord = (item: Resource | undefined) => Boolean(item) && (item?.id ?? resourceId) === resourceId; const nowWithheld = resourceType === 'QuestionnaireResponse' && Boolean(questionnaireResponse && answersWithheld(questionnaireResponse)); // A different record, subject, viewer, practice or session retires whatever was reading, opening or open for the // last one, and settles its loading here rather than through a late answer. useEffect(() => { setSelectedItem(undefined); setShowModal(false); setShowResponse(false); setOpenedVersion(undefined); setOpenFor(undefined); setResponseError(null); setResponseLoading(false); setLoading(false); setError(undefined); return () => { opening.current += 1; historyRead.current += 1; }; }, [scope]); // Answers withheld now close what is open and retire what is loading. useEffect(() => { if (!nowWithheld) return; opening.current += 1; setResponseLoading(false); setShowResponse(false); setOpenedVersion(undefined); setSelectedItem(undefined); setShowModal(false); }, [nowWithheld]); const responseMethods = useForm({ defaultValues: {} }); const { reset: responseReset, watch: responseWatch } = responseMethods; const fetchHistory = useCallback(async () => { if (!resourceId || resourceId === 'new') return; const read = ++historyRead.current; const startedFor = latest.current.scope; // The auth service moves the session on before the screen renders the new owner, so the live ref is read too. const session = actingAsSessionRef?.current; const admitted = () => read === historyRead.current && latest.current.scope === startedFor && actingAsSessionRef?.current === session; setLoading(true); setError(undefined); const apiUrl = resolveHistoryBaseUrl(resourceType); try { const params = new URLSearchParams(); // The server merges all `_history` pages now; the old `count` param was dropped by // the proxy and never limited anything, so it's gone. if (resourceType === 'Organization') { params.append('wpInstallationId', environment.wpData?.installationId); } const query = params.toString(); const bundle = await get>(`${apiUrl}/${resourceType}/${resourceId}/_history${query ? `?${query}` : ''}`); if (!admitted()) return; setHistory({ bundle, owner: startedFor }); } catch (err: any) { if (!admitted()) return; console.error(`Error fetching ${resourceType} history:`, err); // A hard-deleted resource returns an opaque 500 from the `_history` LIST endpoint //. Distinguish that from a transient failure so we don't tell the user to // "try again" on a record that's gone for good. const deleted = await isResourceDeleted(get, apiUrl as string, resourceType, resourceId); if (!admitted()) return; setError( deleted ? `This ${resourceType.toLowerCase()} has been deleted, so its version history is no longer available.` : `Failed to load ${resourceType.toLowerCase()} history. Please try again.` ); } finally { if (admitted()) setLoading(false); } }, [get, resourceType, resourceId, practiceId, actingAsSessionRef]); useEffect(() => { fetchHistory(); }, [fetchHistory]); // The newest choice wins: opening a row's details retires any answers opening or open. const handleItemClick = (item: T) => { if (!ofThisRecord(item) || answersClosed(item)) return; opening.current += 1; setResponseLoading(false); setShowResponse(false); setOpenedVersion(undefined); setOpenFor(scope); setSelectedItem(item); setShowModal(true); }; const closeModal = () => { if (!showModal) return; opening.current += 1; setResponseLoading(false); setShowModal(false); setSelectedItem(undefined); }; // The answers of the version on that row, never the current version's. const viewResponse = async (event: any, version: QuestionnaireResponse) => { event.stopPropagation(); event.preventDefault(); if (!version.questionnaire || !ofThisRecord(version) || answersClosed(version)) return; const mine = ++opening.current; const startedFor = scope; setShowModal(false); setSelectedItem(undefined); const session = actingAsSessionRef?.current; // Still the newest opening, for the same record and viewer, and the answers may still be shown. const owned = () => mine === opening.current && latest.current.scope === startedFor && actingAsSessionRef?.current === session && !answersClosed(version, latest.current.questionnaireResponse); // - the third copy of the dead catch. This one at least DISABLES the button when there // is no questionnaire recorded, so the only silent outcome left here was a failed load. setResponseError(null); setResponseLoading(true); try { const questionnaire: Questionnaire = await fetchQuestionnaire(version.questionnaire); if (!owned()) return; const transformedQuestionnaire = transformFromFhirQuestionnaire(questionnaire, version); responseReset(transformedQuestionnaire); setOpenedVersion(version); setOpenFor(startedFor); setShowResponse(true); } catch (error) { if (!owned()) return; console.error('Failed to load questionnaire:', error); setResponseError(describeFetchFailure(error, 'We could not open these answers')); } finally { if (mine === opening.current && actingAsSessionRef?.current === session) setResponseLoading(false); } }; const closeResponse = () => { opening.current += 1; setResponseLoading(false); setShowResponse(false); setOpenedVersion(undefined); }; const defaultRenderHistoryItem = (item: T, index: number) => { const lastUpdated = item.meta?.lastUpdated ? new Date(item.meta.lastUpdated).toLocaleString() : ''; const version = resourceType === 'QuestionnaireResponse' && questionnaireResponse ? item as unknown as QuestionnaireResponse : undefined; // Withheld now, or on that version, keeps its answers closed. const withheld = Boolean(version && answersClosed(version)); return ( handleItemClick(item)}> {/* The version and the date wrap together beside the page icon: on a narrow row the date moves below the version rather than squeezing it until its badge runs under the calendar icon. */} Version {item.meta?.versionId} {/* That version's own status: a draft later sent keeps its draft row marked, and its sent rows are not. Any status other than in-progress, or none, says nothing either way. */} {version?.status === 'in-progress' && ( Saved draft )} {version && withheld && ( {ANSWERS_WITHHELD_MESSAGE} )} {version && !withheld && ( )} {lastUpdated && ( {lastUpdated} )} ); }; if (loading) { return ; } if (error) { return ( {error} ); } const mayRequestConditionCorrection = isPatientRequestingCorrection && resourceType === 'Condition' && Boolean(selectedItem) && isConditionRecordedByCareTeam(selectedItem as unknown as Condition); const mayRequestProcedureCorrection = isPatientRequestingCorrection && resourceType === 'Procedure' && Boolean(selectedItem) && isProcedureRecordedByCareTeam(selectedItem as unknown as Procedure); return (
History can take 1–2 minutes to show your latest changes. History {/* Card 523 — what View Answers says when it cannot open. */} {responseError && ( setResponseError(null)} className="mb-4"> {responseError} )} {history && history.owner !== scope ? ( Press Reload to show this history for the account now signed in. ) : historyBundle?.entry && historyBundle?.entry.length < 2 ? ( No history available for this {resourceType}. ) : ( <> Showing {historyBundle?.entry?.length} version(s) {resourceType === 'ImmunizationEvaluation' && ' (click to view details)'} {(historyBundle?.entry ?? []).map((entry, index) => { if (!entry.resource) return null; return renderHistoryItem ? renderHistoryItem(entry.resource as T, index) : defaultRenderHistoryItem(entry.resource as T, index); })} )} {/* NOTHING BELOW IS MOUNTED UNTIL A VERSION HAS BEEN CHOSEN, and that guard is the whole fix. `selectedItem` is undefined until a row in the list above is clicked, and every display below was being handed it unconditionally. `isOpen` does not save them: several close over their prop while building the element, and only return null for a closed modal further down their own body — so they dereference undefined on MOUNT, before anything is open. Pressing History on a practitioner's own profile crashed the whole screen to the error boundary on `Cannot read properties of undefined (reading 'meta')`, every time. THE `as` CASTS BELOW ARE WHY THE COMPILER SAID NOTHING. Each asserts a concrete resource type from the generic `T | undefined`, which is a fair thing to want and also silences exactly the case that was wrong. Inside this guard the union is narrowed for real, so each cast now does only the generic-to-concrete job it was written for. ONE GUARD RATHER THAN ONE PER COMPONENT: there are twenty-odd of these and they are all the same bet on the same state. Patching the one that happened to crash would leave the other nineteen waiting on a different click. */} {selectedItem && openFor === scope && ofThisRecord(selectedItem) && !answersClosed(selectedItem) && (<> {/* EVERY DOWNCAST BELOW GOES VIA `Resource`, AND THE REASON IS WORTH KEEPING. Narrowing `selectedItem` to `T` is what made them errors, all twenty-nine of them: `T as Patient` was only ever accepted because the union still carried `undefined`, which overlaps everything. So the compiler was not silent by accident - THE POSSIBILITY OF THE BUG WAS THE THING MAKING THESE CASTS LEGAL, and closing it is what made them speak up. `T extends Resource`, so widening to `Resource` is an assignment rather than an assertion, and narrowing from the FHIR base type to a concrete resource is a conversion TypeScript accepts on its own terms. Going through `unknown`, which is what it suggests, would turn every one of these into a cast that asserts nothing - the wider version of the hole this guard just closed. */} {/* Patient */} {/* Practitioner */} {/* PractitionerRole */} {/* RelatedPerson - mounted only when the item IS one. Every display here is handed selectedItem cast to its own type, which is inert for the others because none of them fetches on mount; this one asks /RelatedPerson/{id}/identity for whatever id it holds, so a care plan's version sent the care plan's id and took a deny. */} {resourceType === 'RelatedPerson' && ( )} {/* Organization */} {/* Location */} {/* HealthcareService */} {/* Schedule */} {/* FamilyMemberHistory */} {/* AllergyIntolerance */} {/* Condition */} {/* Procedure */} {/* AdverseEvent */} {/* ClinicalImpression */} {/* DetectedIssue */} {/* These displays draw only a sheet's contents, so the sheet is ours to open. */} { if (!open) closeModal(); }} > {/* Observation */} {/* MedicationStatement */} {/* MedicationRequest */} {/* Device */} {/* DeviceUseStatement */} {/* DeviceRequest */} {/* CarePlan */} {/* CareTeam */} {/* Goal */} {/* NutritionOrder */} {/* RiskAssessment */} {/* VisionPrescription */} {/* Task */} {/* MedicationAdministration */} {/* MedicationDispense */} {/* Immunization */} {/* ImmunizationEvaluation */} {/* ImmunizationRecommendation */} {/* Encounter */} {/* EpisodeOfCare */} {/* QuestionnaireResponse */} {/* DocumentReference */} )} {showResponse && openedVersion && openFor === scope && ofThisRecord(openedVersion) && !answersClosed(openedVersion) && (
)}
); }; export default FhirHistory;