import React, { useCallback, useState, useEffect, 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 { CarePlan } from 'fhir/r4'; import { Check, FileText } from 'lucide-react'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator, FhirCarePlan, FhirCarePlanDisplay } from '../../../../../components'; import { PrintableRegion, PrintRecord } from '../../../../../components/ui/print-record'; import { useAuthServiceContext, useCarePlanServiceContext, usePatientServiceContext } from '../../../../../services'; import { useFhirCarePlanTargetedBroadcast } from '../../../../../hooks'; import { ARCHIVE_ABANDONED, ArchiveNotice, useArchive } from '../../../../../hooks/useArchive'; 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 ArchiveConfirmModal from '../../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { carePlanAuthorship } from '../../../../../utils/carePlanAuthorship'; import { mayEditRecord } from '../../../../../utils/recordEditability'; import { useSeatStanding } from '../../../../../utils/seatStanding'; import { describeFetchFailure, describeWriteFailure, ownFailureSentence } from '../../../../../utils/fetchFailureMessage'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import { isArchived } from '../../../../../utils/archivedResource'; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED, type SaveOperation } from '../../../../../utils/saveOperation'; /** 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. Save it again to finish stopping them.'; const ARCHIVE_PARTIAL_MESSAGE = 'The care plan was archived, but not all of its tasks could be stopped. Archive it again to finish.'; // A fresh copy each time, so a form never shares its arrays with another. const emptyCarePlanForm = () => ({ identifier: [], instantiatesCanonical: [], instantiatesUri: [], basedOn: [], replaces: [], partOf: [], status: '', intent: '', category: [], title: '', description: '', encounter: null, created: '', author: null, contributor: [], careTeam: [], addresses: [], supportingInfo: [], goal: [], activity: [], note: [] }); const EditCarePlanPage: React.FC = () => { const { carePlanId } = useParams<{ carePlanId: string }>(); const navigate = useNavigate(); const { currentUser, userRole, USER_ROLES, currentOrganization, practitionerRoles } = useAuthServiceContext(); const seatStanding = useSeatStanding(); const { patientState, encounterState } = usePatientServiceContext(); const { fetchCarePlan, transformToFhirCarePlan, transformFromFhirCarePlan, createCarePlan, updateCarePlan, archiveCarePlan, carePlansList } = useCarePlanServiceContext(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [loadFailure, setLoadFailure] = useState(); const methods = useForm({ defaultValues: emptyCarePlanForm() }); const { handleSubmit } = methods; // The form is filled from the stored plan it was opened on and saves only over that version. A newer save // arriving refills an untouched form; over typing it marks the opening changed, and only Reload record moves on. // A read or answer lands only for the plan the page is on when it arrives. const shownId = useRef(carePlanId); shownId.current = carePlanId; const fill = useCallback((plan: CarePlan): unknown => ({ ...emptyCarePlanForm(), ...transformFromFhirCarePlan(plan) }), [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 [reloading, setReloading] = useState(false); // A create whose answer was lost may have filed the plan: it is not offered again from this page. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // The stored plan this page shows, archives and asks authorship of: the one it was opened on. const carePlan = baseline.opening?.id === carePlanId ? baseline.opening : undefined; // Every read, save, creation and archive belongs to this opening of this plan: another plan, coming back to it, // or another person signing in retires it, closes its dialog, and nothing it answers is used. const archiveDialog = useRef<{ close: () => void }>(); const scope = useSaveScope(carePlanId, () => { baseline.close(); // The form too: a new plan typed for the last person or practice is not saved for the next. methods.reset(); setSubmitting(false); setCreateUnconfirmed(false); setLoadFailure(undefined); archiveDialog.current?.close(); }); // A read arrives rather than opens: a newer opening is never displaced by it. const init = baseline.arrive; // Archiving writes the plan as this page opened it, within an operation of this page. Refused because someone // else changed it, out of date, or unanswered, the opening is done: the dialog closes, the page says so, and only // Reload record moves on. Archived with tasks left running, the plan as saved is opened so archiving again finishes. const archiveOperation = useRef(); const archive = useArchive(async (reason, note) => { const held = carePlan; if (!held || baseline.stale) return ARCHIVE_ABANDONED; const operation = scope.start(); archiveOperation.current = operation; try { return await operation.step(() => archiveCarePlan(held, reason, note, operation.transport), true); } catch (archiveError) { if (isSaveRetired(archiveError)) return ARCHIVE_ABANDONED; const ends = isRecordChanged(archiveError) ? (archiveError.outOfDate ? 'outOfDate' as const : 'changed' as const) : outcomeUnknown(archiveError) ? 'unconfirmed' as const : undefined; if (ends) { baseline.refuse(ends); archiveDialog.current?.close(); return ARCHIVE_ABANDONED; } const body = (archiveError as { body?: { code?: string; plan?: CarePlan } } | null)?.body; if (body?.code === 'PLAN_TASKS_NOT_STOPPED' && body.plan) { if (!operation.belongs()) return ARCHIVE_ABANDONED; baseline.open(body.plan); throw new ArchiveNotice(ARCHIVE_PARTIAL_MESSAGE); } throw archiveError; } }); archiveDialog.current = archive; // Closing the dialog (Escape, Keep it active) retires the archive it started: one still being prepared is never // sent. One already sent may have landed; the plan's next update says so. const closeArchive = () => { archiveOperation.current?.retire(); archive.close(); }; const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const isNew = carePlanId === 'new'; // WHOSE PLAN THIS IS. The list stops offering Edit on another practice's plan, and this page is what // that button led to - so the same question has to be asked again HERE, because a URL is reachable // without the row. A control removed only from the list is a client gate with a door beside it. // // A NEW plan is nobody's yet and is always writable; the rule only speaks about a stored one. const authorship = carePlanAuthorship(carePlan, { currentOrganizationId: currentOrganization?.id, }); /* * THE SECOND REASON A PLAN IS READ-ONLY, and it applies to a NEW one as well. * * Authorship above asks whose plan it is; this asks whether the reader may write a plan at all. * The server gates every CarePlan write on a paid clinician seat, so an unseated practitioner - * a medical assistant, somebody whose seat was turned off this morning - was offered a blank form, * filled it in and was refused at Save. `isNew` short-circuits authorship because a plan nobody * has written yet belongs to nobody; it does not short-circuit the seat. */ const seat = mayEditRecord({ seatStanding, resourceType: 'CarePlan', viewerIsStaff: !isPatient, practitionerRoles, }); // An archived plan is read, not edited: an ordinary save would carry it as the plan in current use. Archiving it // again stays available, since that is how a partial stop is finished. const archivedRecord = !isNew && isArchived(carePlan); const mayArchive = !isNew && !isPatient && authorship.mayEdit && seat.mayEdit; const mayWrite = !isPatient && (isNew || authorship.mayEdit) && seat.mayEdit && !archivedRecord; /** Whichever reason applies. Authorship is the more specific answer, so it is asked first. The archive notice * in the record says why an archived plan is read-only. */ const readOnlyReason = archivedRecord ? undefined : isNew ? seat.reason : (authorship.readOnlyReason ?? seat.reason); const spacerMargin = '24px'; useFhirCarePlanTargetedBroadcast({ carePlan, carePlanId, onUpdate: init, onDelete: () => navigate('..', { relative: 'path' }) }); useEffect(() => { const loadCarePlan = async () => { if (isNew || carePlan) return; const foundCarePlan = carePlansList?.find(cp => cp.id === carePlanId); if (foundCarePlan) { init(foundCarePlan); return; } const requested = carePlanId; const operation = scope.start(); setLoading(true); try { const fetchedCarePlan = await operation.step(() => fetchCarePlan(requested as string, false, operation.transport)); if (shownId.current !== requested || fetchedCarePlan?.id !== requested) return; init(fetchedCarePlan); } catch (err) { if (isSaveRetired(err) || shownId.current !== requested) return; console.error('Error loading care plan:', err); setLoadFailure(describeFetchFailure(err, 'This care plan could not be opened')); } finally { if (operation.belongs()) setLoading(false); } }; loadCarePlan(); }, [carePlan, carePlansList, carePlanId, fetchCarePlan, init]); const onSubmit = async (data: any) => { if (isPatient) return; if (!patientState) { setError('Patient information is missing. Please try again.'); return; } if (isNew && createUnconfirmed) return; setSubmitting(true); setSuccess(undefined); setError(undefined); if (isNew) { const creation = scope.start(); try { await creation.step(() => createCarePlan(transformToFhirCarePlan(data, patientState, encounterState, currentUser?.fhir), creation.transport), true); navigate('..', { relative: 'path' }); setSuccess('Care plan created successfully.'); setTimeout(() => setSuccess(undefined), 3_000); } catch (err) { if (isSaveRetired(err) || !creation.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. if (outcomeUnknown(err)) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } setError(isSaveTimedOut(err) ? ownFailureSentence(err) : describeWriteFailure(err, 'Failed to create care plan')); } finally { if (creation.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 operation = scope.start(); 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); return; } setSuccess('Care plan updated successfully.'); setTimeout(() => setSuccess(undefined), 3_000); } catch (err) { if (!operation.belongs()) return; const committed = savedWithTasksLeft(err); if (committed) { // Saved: the form opens on the plan as saved, and Save again (keeping it stopped) finishes the stop. baseline.open(committed); setError(TASKS_LEFT_MESSAGE); return; } console.error('Error updating care plan:', err); setError(describeWriteFailure(err, 'Failed to update care plan')); } 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 = carePlan?.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); } }; // NOTHING TO EDIT IS NOT A BLANK PLAN. Until a stored plan has loaded there is no form: a refused // read renders as a denial and a failed one says so, since Save on an empty form would write it // over the plan. if (!isNew && !carePlan && loadFailure) { return ( <>

{loadFailure}

); } if (loading || (!isNew && !carePlan)) { return ( ); } return ( resourceType="CarePlan" resourceId={carePlanId || ''}> {(showHistory) => (
{!isNew && ( )} {!isPatient && mayWrite && ( )} {mayArchive && ( )} {!isPatient && !mayWrite && ( // BEFORE ANYTHING IS TYPED, not after Save. An editor that looks // writable and refuses at the end has already cost the practitioner // the work they entered, and teaches them the product is unreliable // rather than that the plan belongs to somebody else. {readOnlyReason} )} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} {!isNew && (mayWrite || mayArchive) && 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. */} {!mayWrite && carePlan ? ( undefined}/> ) : ( )} {/* The record has to be able to leave the screen. Without this a patient sending it to somebody not on Akashic photographs a scrolling page or prints the browser window, navigation and all. */} {!mayWrite && carePlan && } {mayArchive && ( { if (!o) closeArchive(); }} resourceLabel="Care Plan" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { if (await archive.submit(reason, note)) { navigate('..', { relative: 'path' }); } }} /> )}
)} ); }; export default EditCarePlanPage;