import React, { useState, useEffect, useCallback, useRef } from 'react'; 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 { CarePlan } from 'fhir/r4'; import { useCarePlanServiceContext, useAuthServiceContext, usePatientServiceContext } from '../services'; import { FhirCarePlan, FhirCarePlanDisplay } from '../components'; import { PrintableRegion, PrintRecord } from '../components/ui/print-record'; import { useFhirCarePlanTargetedBroadcast, useActiveEncounterReference } from '../hooks'; import { useEditBaseline } from '../hooks/useEditBaseline'; import { useSettledFormOf } from '../hooks/useSettledFormOf'; import { reloadBaseline, saveOverBaseline } from '../utils/saveOverBaseline'; import { onTheOpenedPlan } from '../utils/carePlanEdit'; import { StaleRecordNotice } from '../components/StaleRecordNotice/StaleRecordNotice'; import { describeWriteFailure } from '../utils/fetchFailureMessage'; import { isSaveRetired, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED, type SaveOperation } from '../utils/saveOperation'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; /** A save that was written but left the stopped plan's tasks running answers 502 with the plan as saved. */ const savedWithTasksLeft = (error: unknown): CarePlan | undefined => { const body = (error as { body?: { code?: string; plan?: CarePlan } } | null)?.body; return body?.code === 'PLAN_TASKS_NOT_STOPPED' ? body.plan : undefined; }; const TASKS_LEFT_MESSAGE = 'The care plan was saved, but not all of its tasks could be stopped. Update it again to finish stopping them.'; interface CarePlanModalProps { isOpen: boolean; onClose: () => void; carePlan?: CarePlan; onSuccess?: (carePlan: CarePlan) => void; onError?: (error: string) => void; title?: string; } export const CarePlanModal: React.FC = ({ isOpen, onClose, carePlan, onSuccess, onError, title }) => { const { currentUser, userRole, USER_ROLES } = useAuthServiceContext(); const { patientState, encounterState } = usePatientServiceContext(); const activeEncounter = useActiveEncounterReference(); const { transformToFhirCarePlan, transformFromFhirCarePlan, createCarePlan, updateCarePlan, fetchCarePlan } = useCarePlanServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const isNew = !carePlan; // A RELATED PERSON IS A PATIENT VIEWER. This read // `userRole === USER_ROLES.PATIENT`, so a parent acting for a child was treated as // staff here while the matching page and FhirCarePlan itself both counted them. // It now decides which of the two renders below the reader gets, so a narrow // predicate would put a proxy into the edit form rather than the record. const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Care Plan`; const methods = useForm({ defaultValues: { identifier: [], instantiatesCanonical: [], instantiatesUri: [], basedOn: [], replaces: [], partOf: [], status: '', intent: '', category: [], title: '', description: '', encounter: activeEncounter, created: '', author: null, contributor: [], careTeam: [], addresses: [], supportingInfo: [], goal: [], activity: [], note: [] } }); const { handleSubmit } = 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)); // As on the care plan page: the form saves only over the plan it was opened on; a newer save refills an untouched // form and over typing marks the opening changed, and only Reload record moves on. const fill = useCallback((plan: CarePlan): unknown => ({ ...methods.formState.defaultValues, ...transformFromFhirCarePlan(plan) }), [methods, transformFromFhirCarePlan]); // Measured against the form as it settled on the opening: the schedule controls fill in their defaults as they // mount, and an untouched form must still follow a newer save. const { formOf, settle } = useSettledFormOf(fill); const read = useCallback(() => methods.getValues() as unknown, [methods]); const write = useCallback((values: unknown) => methods.reset(values as never), [methods]); const baseline = useEditBaseline({ formOf, read, write }); useEffect(() => { settle(baseline.opening, read()); }, [baseline.opening, settle, read]); const receive = baseline.arrive; const [reloading, setReloading] = useState(false); // A create whose answer was lost may have filed the plan: it is not offered again from this opening. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // A save belongs to this opening of this plan: closing, reopening or another plan retires it, and its delayed // close with it. const scope = useSaveScope(isOpen ? carePlan?.id ?? 'new' : undefined, () => { baseline.close(); // The form too: a plan typed for the last person or practice is not saved for the next. methods.reset(); setSubmitting(false); setCreateUnconfirmed(false); }); const closeTimer = useRef>(); useEffect(() => () => clearTimeout(closeTimer.current), []); const closeAfterSuccess = (operation: SaveOperation) => { clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => { if (!operation.belongs()) return; setSuccess(undefined); onClose(); }, 1500); }; useFhirCarePlanTargetedBroadcast({ carePlan, isOpen, onUpdate: receive, onDelete: onClose }); useEffect(() => { if (carePlan) { receive(carePlan); } }, [carePlan, receive]); const onSubmit = async (data: any) => { if (isPatient) return; if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } if (isNew && createUnconfirmed) return; setSubmitting(true); setSuccess(undefined); setError(undefined); const operation = scope.start(); if (isNew) { try { const result = await operation.step(() => createCarePlan(transformToFhirCarePlan(data, patientState, encounterState, currentUser?.fhir), operation.transport), true); setSuccess('Care plan created successfully.'); onSuccess?.(result); closeAfterSuccess(operation); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Error creating care plan:', err); // Sent with no answer of ours: it may be on record, so it is not sent again from here. const errorMsg = outcomeUnknown(err) ? TIMED_OUT_MAY_HAVE_SAVED : describeWriteFailure(err, 'Failed to create care plan'); if (outcomeUnknown(err)) setCreateUnconfirmed(true); setError(errorMsg); onError?.(errorMsg); } finally { if (operation.belongs()) setSubmitting(false); } return; } // Written only over the stored plan the form was opened on; a plan changed since refuses it, and the opening is // done until Reload record. const id = carePlan?.id as string; try { const outcome = await saveOverBaseline({ baseline, operation, // The form's fields onto the plan it opened: what it does not show (author, identifiers) is kept. write: (basedOn, transport) => updateCarePlan(onTheOpenedPlan(baseline.opening, { id, ...transformToFhirCarePlan(data) }), basedOn, transport), }); if ('retired' in outcome) return; if ('refused' in outcome) { // A stale opening shows the standard notice with Reload record instead. if (!outcome.stale) setError(outcome.refused); onError?.(outcome.refused); return; } setSuccess('Care plan updated successfully.'); onSuccess?.(outcome.saved); closeAfterSuccess(operation); } catch (err) { if (!operation.belongs()) return; let errorMsg: string; const committed = savedWithTasksLeft(err); if (committed) { // Saved: the form opens on the plan as saved, and Update again (keeping it stopped) finishes the stop. baseline.open(committed); errorMsg = TASKS_LEFT_MESSAGE; } else { console.error('Error updating care plan:', err); errorMsg = describeWriteFailure(err, 'Failed to update care plan'); } setError(errorMsg); onError?.(errorMsg); } finally { if (operation.belongs()) setSubmitting(false); } }; // Reload record: what is saved now, read fresh, becomes the opening; the draft is discarded (the notice says so). const reload = async () => { const id = baseline.opening?.id; if (!id) return; const operation = scope.start(); setReloading(true); setError(undefined); setSuccess(undefined); try { await reloadBaseline({ baseline, operation, readNow: (transport) => fetchCarePlan(id, true, transport) }); } catch (reloadError) { if (isSaveRetired(reloadError)) return; setError('The care plan could not be read. Try Reload record again.'); } finally { if (operation.belongs()) setReloading(false); } }; if (!isOpen) return null; return ( {/* A READER GETS PRINT WHERE A WRITER GETS SAVE. This was a disabled Create/Update button, which tells a patient what they may not do - a denial rendered as a dead control, the same failure the record itself had. Print answers the question they actually have: how do I get this to somebody who is not on Akashic. */} {isPatient ? ( ) : ( )} ]} >
{success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )} {!isNew && !isPatient && void reload()} reloading={reloading} />}
{/* THE READER GETS THE RECORD, NOT THE FORM SWITCHED OFF. `FhirCarePlanDisplay` already renders this resource for the history viewer, the reference preview and the patient snapshot, so a patient was the one audience shown the edit form instead of the view written for reading it. The old shape wrapped the form in `inert`, which is worse than it looks: the fields stay in the accessibility tree, so a screen reader still reads them as inputs, and a text range over the subtree returns an EMPTY STRING - a patient could not select or copy their own record. */} {isPatient && carePlan ? ( undefined}/> ) : ( )}
); };