import { useState, useEffect, useRef, useSyncExternalStore } from "react"; import { Outlet, useNavigate, useParams, useLocation } from "react-router-dom"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { InputControl } from '../../../../components/ui/input-control'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Heading } from '../../../../components/ui/heading'; import { Flex, FlexItem, FlexBlock } from '../../../../components/ui/flex'; import { Text } from '../../../../components/ui/text'; import { Spacer } from "../../../../components/ui/spacer"; import { Check, TriangleAlert } from 'lucide-react'; import type { Encounter, Flag, Patient } from "fhir/r4"; import { type IValueSet, useAuthServiceContext, useEncounterServiceContext, useFlagServiceContext, usePatientServiceContext, useStaffPermissionServiceContext, useSurfaceExpectedPatient, } from "../../../../services"; import { AppHeader, AppSpinner, ProfileLayoutContainer, FhirFlag } from "../../../../components"; import { FhirCodeableConceptDisplay, formatFhirCodeableConcept, } from "../../../../utils/fhirCodeableConcept.tsx"; // PresenceDot, FhirHumanNameDisplay and getPatientSubline used to be imported here as well. They are // gone because the encounter row no longer identifies the patient - and the fact that those three in // particular became unused is the check on this change: they were exactly what PatientChartIdentity // renders one line below, which is what made the two lines identical. import { useRouteAuthorization } from "../../../../hooks"; import { Card, CardContent, CardHeader, } from "../../../../components/ui/card.tsx"; import PatientChartSidebar from "./PatientChartSidebar"; import PatientChartIdentity, { PatientChartAllergies, PatientChartNewActions } from "./PatientChartIdentity"; import { ChartRecordStandingProvider } from "../../../../hooks/useRecordAccessStanding"; import { BASE_STAFF_SECTIONS, CHART_SECTIONS, MANAGEMENT_SECTION, RECORD_SECTION_GROUPS, type PatientNavSection } from "./staffChartSections"; import PatientRecordNav from "./PatientRecordNav.tsx"; import { usePatientNav } from "../../patientNav.tsx"; import { patientRecordPageTitle } from "./patientRecordSections.ts"; import { FAMILY_HISTORY_PATH, PATIENT_RECORD_DESTINATIONS } from "../../patientRecordNav.tsx"; import { EMAIL_ABSENCE_OPTIONS, type AbsenceReason, } from "../../../../utils/patientFieldAbsence"; import "./PatientPage.css"; import { chartWaitsForRoutePatient } from "./chartWaitsForRoutePatient"; import { RecordPracticeBar, RecordPracticeProvider, isRecordReadingPath } from "../../../../contexts/RecordPracticeContext"; import { isOrglessPatientsHost } from "../../../../utils/hostedMount"; import { onViewerChange, viewerEpoch } from "../../../../utils/writeThroughHold"; interface NewPatientFormData { email: string; firstname: string; lastname: string; role: string; emailAbsenceReason: AbsenceReason | ""; } interface FlagFormData { status: Flag["status"]; category?: IValueSet[] | null; code: IValueSet | null; subject?: Flag["subject"] | null; encounter?: Flag["encounter"] | null; author?: Flag["author"] | null; period?: Flag["period"]; } export { RECORD_SECTION_GROUPS }; // The band's visit chip: "In progress · started 9:20 AM", or the visit's own status. const visitChip = (encounter: Encounter): { label: string } => { const started = encounter.period?.start ? new Date(encounter.period.start) : undefined; const at = started && !Number.isNaN(started.getTime()) ? started.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) : undefined; if (encounter.status === "in-progress") return { label: at ? `In progress · started ${at}` : "In progress" }; const status = encounter.status.replace(/-/g, " "); return { label: status.charAt(0).toUpperCase() + status.slice(1) }; }; const getErrorMessage = (error: unknown): string => { if (error instanceof Error && error.message.trim()) { return error.message; } if (typeof error === "string" && error.trim()) { return error; } if (typeof error === "object" && error !== null && "message" in error) { const message = (error as { message?: unknown }).message; if (typeof message === "string" && message.trim()) { return message; } } return "The request could not be completed."; }; const PatientPage = () => { const { id, encounterStateId } = useParams<{ id: string; encounterStateId: string; }>(); const navigate = useNavigate(); const location = useLocation(); const { currentUser, userRole, USER_ROLES, wpOrganization } = useAuthServiceContext(); const { activeWorkspace } = useStaffPermissionServiceContext(); const { patientState, setPatientState, encounterState, setEncounterState, fetchPatient, } = usePatientServiceContext(); // - declares AND releases on unmount. The raw context function never released, so this // chart left its expectation behind for whatever mounted next - including surfaces that declare // nothing of their own and therefore inherited it. const declareExpectedPatient = useSurfaceExpectedPatient(); const { fetchFlagsWithPolling, transformToFhirFlag, createFlag, updateFlag, flagsList, setFlagsList, } = useFlagServiceContext(); const { fetchEncounter } = useEncounterServiceContext(); const [loading, setLoading] = useState(false); // The NEW-patient form no longer writes, so there is nothing to be in-flight for. Kept as a plain // constant because the header button and several fields still read it. const submitting = false; const [success, setSuccess] = useState(); const [error, setError] = useState(); const [flagging, setFlagging] = useState(false); const [showFlagModal, setShowFlagModal] = useState(false); const [flag, setFlag] = useState(undefined); useRouteAuthorization({ allowedRoles: [ USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST, USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON, ], }); const isPatient = userRole === USER_ROLES.PATIENT || userRole === USER_ROLES.RELATED_PERSON; // - the chart's own index route renders the record nav OPEN as its landing surface, so the // collapsed "Browse your record" row would be a second copy of the same list on that one page. const isRecordIndex = location.pathname.replace(/\/+$/, "") === "/dashboard/patient"; // The stream and the visit or result opened from it: one page in the deck, drawn on the paper with // no shell card and no browse row, since the stream carries its own at the foot of the list. const isRecordStream = isPatient && (isRecordIndex || /^\/dashboard\/patient\/(visit|result)\//.test(location.pathname)); // The neutral portal's record reads across the patient's practices, so it carries one practice context: All // practices or one of theirs, named on every record page. A new patient, helper or viewer starts at All. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); const governsRecordPractice = isPatient && isOrglessPatientsHost(); const showsRecordPractice = governsRecordPractice && isRecordReadingPath(location.pathname); // A record-update notification opens on the practice it came from, once the patient is known. const arrivalPractice = patientState?.id ? (location.state as { recordPractice?: string } | null)?.recordPractice : undefined; // Spencer, 2026-08-25 - Tasks is its own destination, not a leaf of the record, so the shell // announces it by its own name. The ROUTE still nests it (`PatientTasks` renders inside this // shell's Outlet and takes its patient from it), so this is a path test rather than a route move. // carries what moving the route would take. const isTasks = location.pathname.startsWith("/dashboard/patient/tasks"); // Medications is its own destination in the approved nav, not a leaf of the record - same move // Tasks made above. It goes further than Tasks did: the deck gives it a breadcrumb and its own // two columns with no shell heading and no "Browse your record" row, so both are suppressed // rather than renamed. The ROUTE still nests here, which is what keeps the patient context. const isMedications = location.pathname.startsWith("/dashboard/patient/medications"); // Care plans and goals is the same shape of move: its own destination, its own two columns. // // 2026-09-10 - THE WHOLE SUBTREE, not just its index. This was an exact match on // `/care-provision`, so Care plans was treated as its own destination and Goals - a sibling entry in // the patient's own navigation, one click away - fell through to the generic record-leaf treatment. // What that looked like on screen: the page announced itself as *My Visits & Results* in the shell // title, then printed 'Goals' as the leaf heading, and then Goals rendered its own 'Goals' under // that. One screen, the wrong name at the top and the right name twice beneath it. // // THE LEAF HEADING IS NOT THE THING TO REMOVE. It exists because ten of the fifteen record // sections render no heading at all, and without it a reader navigating by heading hears only the // shell's title and cannot tell Conditions from Test results. Goals is in the other five - it has a // heading AND a lede of its own - so the fix is to stop treating it as a leaf, not to weaken the // rule that serves the ten. const isCarePlans = location.pathname.startsWith("/dashboard/patient/care-provision"); // Insurance, Spencer 2026-08-29: *"the insurance page in the mockups are correct, point everything // at that insurance page."* Its own destination, so it announces itself by its own name - the same // move Tasks made, and for the same reason. It keeps the shell and the browse row // because the ROUTE still nests here; only the name it introduces itself by is wrong when inherited. const isInsurance = location.pathname.startsWith("/dashboard/patient/insurance"); // The insurance deck draws the page on the paper: its own title row with Add insurance beside it, no // shell card and no browse row. CoverageListPage titles itself on this path. const isOwnInsurance = isPatient && isInsurance; // Family health history sits under My Profile in the deck: its own title, no record browse row. const isFamilyHistory = location.pathname.startsWith(FAMILY_HISTORY_PATH); // / B20 - the record page's own title, from the nav's authored label. `undefined` on the // index route (which IS the nav) and anywhere the nav offers no destination, so no heading renders // rather than a guessed one. const patientNavItems = usePatientNav(); const recordPageTitle = isRecordIndex ? undefined : patientRecordPageTitle(patientNavItems, location.pathname, PATIENT_RECORD_DESTINATIONS); const isNew = id === "new"; // Nothing of a staff chart is drawn until the patient in context is the one the URL names. const waitingForRoutePatient = chartWaitsForRoutePatient({ isPatient, isNew, routeId: id, patientId: patientState?.id }); const staffPatientBasePath = id ? encounterStateId ? `/dashboard/encounters/${encounterStateId}/patients/${id}` : `/dashboard/patients/${id}` : undefined; const isPractitionerWorkspace = activeWorkspace === USER_ROLES.PRACTITIONER; // Practitioner: full chart + management. Receptionist/other staff: lean + Management (reach Encounters). const staffPatientSections: PatientNavSection[] = isPractitionerWorkspace ? [...BASE_STAFF_SECTIONS, ...CHART_SECTIONS, MANAGEMENT_SECTION] : [...BASE_STAFF_SECTIONS, MANAGEMENT_SECTION]; const spacerMargin = "24px"; const methods = useForm({ defaultValues: { email: "", firstname: "", lastname: "", role: "Patients", emailAbsenceReason: "", }, }); const { control, handleSubmit, setValue } = methods; // Watched rather than read on submit, because the Email field's own required-ness depends on it and a // person needs to see that change as they choose, not discover it when the save is refused. const emailAbsenceReason = methods.watch("emailAbsenceReason"); const emailDeclined = Boolean(emailAbsenceReason); useEffect(() => { return () => { if (!isPatient) { setPatientState(undefined); } }; }, []); // Tracks the route id we have already STARTED a load for, so clearing patientState below // (which re-fires this effect) cannot kick off a second fetch for the same patient. const loadStartedForIdRef = useRef(undefined); // (B38) - the load used to be skipped whenever ANY patient was already in context, // rather than when the loaded patient is the one this route names. Because `patientState` is // APP-WIDE context and ~32 chart hooks key their caches off it, opening a second chart without // a full page reload left the PREVIOUS patient in place and every section of the chart - // demographics, encounters, the lot - rendered THAT patient's data under THIS patient's URL. // // The comparison against `id` is load-bearing and a source guard pins it // (__tests__/patientPageLoadsTheRoutePatient.test.ts). Do not "simplify" it back to a bare // presence check. // // Measured on the deployed mount before the fix: Jacob Miller's chart (2 encounters server-side), // then an in-app switch to Patient One (16 encounters server-side) still showed Jacob's 2, and // Patient One's PROFILE rendered "Jacob Miller". The server was proven correct with a two-path // control, so this was never a query or an authz problem. // // A hard reload always worked, which is exactly why it read as "stale state is ruled out" - a // fresh document has no previous patient to be stale. useEffect(() => { const initPatient = async () => { if (isNew) return; if (!id) return; // Patient / RelatedPerson viewing their own chart: PatientService assigns patientState from // the session (and for act-as, from the proxied subject). Never fetch over the top of that. if (isPatient) return; // - this surface is FOR the patient the URL names, and says so. From here on the // service refuses to hold anyone else: a stray `setPatientState` from another surface (the // appointment quick view, the patient list, a broadcast) clears the context instead of // silently swapping the subject that 31 clinical create paths write against. // Declared inside the staff branch on purpose - a patient viewing their OWN chart takes the // subject from the session, not from `id`, and declaring the route id there would fight it. declareExpectedPatient(id); // Already showing the patient this route names - nothing to do. if (patientState?.id === id) { loadStartedForIdRef.current = id; return; } // A load for this id is already in flight; the clear below re-fired this effect. if (loadStartedForIdRef.current === id) return; loadStartedForIdRef.current = id; // Clear BEFORE fetching, not after. Leaving the previous patient in place for the duration of // the fetch is what renders the wrong patient's chart, and every chart service clears its own // bundle on a null patientState - so this is what makes all 32 sections refetch for the new // subject rather than serving the last one's cache. if (patientState) { setPatientState(undefined); } setLoading(true); try { const fhirPatient: Patient = await fetchPatient(id); setPatientState(fhirPatient); } catch (error) { console.error("Failed to load patient:", error); loadStartedForIdRef.current = undefined; navigate("..", { relative: "path" }); } finally { setLoading(false); } }; initPatient(); }, [id, isNew, isPatient, patientState?.id, fetchPatient]); useEffect(() => { if (!patientState) { return; } if (isPatient) { return; } const loadFlags = async () => { try { await fetchFlagsWithPolling(patientState); } catch (error) { console.error("Failed to load flags:", error); setError(`Could not load flags. Try again.`); } }; loadFlags(); }, [fetchFlagsWithPolling, patientState]); useEffect(() => { if (userRole === "Patient") { return; } const initEncounter = async () => { if (!encounterStateId) { setEncounterState(undefined); return; } if (encounterState && encounterState.id === encounterStateId) { return; } setLoading(true); try { const fhirEncounter: Encounter = await fetchEncounter(encounterStateId); setEncounterState(fhirEncounter); } catch (error) { console.error("Failed to load encounter:", error); setError("Failed to load encounter context."); } finally { setLoading(false); } }; initEncounter(); }, [encounterStateId, fetchEncounter, encounterState, setEncounterState]); const handleFlagSubmit = async (formData: FlagFormData) => { setShowFlagModal(false); setFlagging(true); setError(undefined); try { let updatedFlag: Flag; if (flag) { updatedFlag = await updateFlag({ id: flag.id, ...transformToFhirFlag(formData), }); setFlagsList((prevFlags) => prevFlags.map((currentFlag) => currentFlag.id === updatedFlag.id ? updatedFlag : currentFlag, ), ); } else { if (!patientState) { throw new Error("Patient information is not loaded."); } const practitioner = currentUser?.fhir; if (practitioner?.resourceType !== "Practitioner") { throw new Error("Practitioner information is not available."); } updatedFlag = await createFlag( transformToFhirFlag( formData, "Patient", patientState, "Practitioner", practitioner, ), ); setFlagsList((prevFlags) => [...prevFlags, updatedFlag]); } } catch (error) { console.error("Failed to save flag:", error); setError(`Could not save the flag. ${getErrorMessage(error)}`); } finally { setFlagging(false); } }; /** * THIS FORM NO LONGER CREATES A PATIENT. IT HANDS OVER TO THE ONE SURFACE THAT CAN. * * It collected an email and two names, and every duplicate rule needs an SSN, a phone, a birth date or * an address - so the scan queued on create could never evaluate a single rule and returned clean for a * patient nobody had checked. A silent negative is worse than an absent check, because from the outside * "scanned, no duplicates" and "never checkable" are the same sentence. * * IT IS THE SURFACE THAT WAS WRONG, NOT THE NO-EMAIL CASE. A patient WITH an email created here was * equally unscannable, so fixing only the case that visibly refused would have left the case that * quietly succeeded. * * AND THE ANSWER IS NOT TO GROW THIS FORM. Adding demographics here makes a second full registration * surface with its own copy of identifiability, matching and the no-login consequences to keep in step * forever - and the register dialog's create-then-enrich choreography is exactly the part that drifts. * One creation surface, one net. What was typed travels with them. */ const onSubmit = async (data: NewPatientFormData) => { navigate('/dashboard/receptionist', { state: { registerPrefill: { firstName: data.firstname || undefined, lastName: data.lastname || undefined, email: data.email || undefined, emailAbsenceReason: data.emailAbsenceReason || undefined, }, }, }); }; const exitEncounter = () => { setEncounterState(undefined); navigate( `/dashboard/patients/${id}/management/encounters/${encounterStateId}`, { replace: true }, ); }; if (isNew && !isPatient) { return (
{/* IT DOES NOT SAY SAVE, BECAUSE IT DOES NOT SAVE. This form hands over to the one surface that can register a patient; a button labelled Save on a screen that creates nothing is the kind of lie that gets found out at the worst moment - somebody presses it, sees no error, and believes the patient exists. */} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} User Information {/* email */} ( { field.onChange(value ?? ""); if (emailDeclined) setValue("emailAbsenceReason", ""); }} /> )} /> {/* THE WAY PAST THIS FIELD for a patient who has no email to give. The server has always accepted this - it refuses a blank email by NAMING the three codes it would take instead - and this form offered none of them, so the only way to register a patient without one was to invent an address. That is worse than a blank: unreachable and looking reachable at the same time. The options come from the shared list the front-desk register dialog uses, so the two surfaces ask this question in one vocabulary. */}
No email? {EMAIL_ABSENCE_OPTIONS.map((option) => ( ))}
{emailDeclined && ( // NAME THE DOOR THAT WORKS, because this one cannot finish the job. // // The server takes a no-email patient only with a date of birth AND either a phone or // an address - `identifiableWithoutEmail` in registerPatientRequirements. This form // collects none of the three, so choosing a reason here always ends in a refusal. // Measured: it used to end in the server's own sentence about a date of birth, on a // form with no date of birth on it. // // Growing the three fields here was the alternative and it is the wrong one: it makes a // second full registration surface to keep in step with the front desk's forever, which // is the duplication this card was raised about. Point at the one that already handles // identifiability, matching and the no-login consequences.

This patient will have no portal login. They can be treated normally, and an account can be added later from their record.

A patient with no email also needs a date of birth and either a phone number or an address, so their record can be matched to them later. Those are asked for at the front desk, and what you have typed here comes with you.

{/* IT CARRIES WHAT THEY ALREADY TYPED. A handoff that makes somebody retype two names they just typed is how a handoff gets called a bug. In ROUTER STATE, not a query parameter: this is a patient's name, and the address bar and browser history outlive the receptionist on a shared front-desk machine. The front desk consumes it once and replaces the entry. */}
)} {/* firstname */} ( )} /> {/* lastname */} ( )} />
); } return (
{!isPatient && ( {staffPatientBasePath && ( )} {staffPatientBasePath && ( )} {/* Red, as every practitioner chart frame in the deck draws Flag. */} )} {/* Patient header. Medications takes its title from here too: its deck draws "Medications" over both columns, the same view title as every other patient page. */} {isPatient && !isCarePlans && !isInsurance && ( <> {/* Patient Record */} {/* * "View as of…" and "Care-view anchors" are deliberately NOT offered here. They are * clinical/evidentiary tooling — point-in-time reconstruction and proving what a clinician * saw when they decided something — and belong to the staff header above, not to a * patient browsing their own records. * The patient-self ROUTES were removed too (App.tsx renderPatientRoutes, `selfView`), along * with EncounterPage's "View care-view anchor" action, so the portal has no reachable * entry point and no link pointing at a route that no longer exists. */} {/* "My visits & results" — Spencer, 2026-08-25, and the deck's own `view-title`. The shell said "My Records", which agreed with nothing: the nav item, the breadcrumb and the drawing all say "My visits & results", so a patient arrived at a page announcing itself as something other than the thing they had just tapped. The subtitle is gone with it. The deck has a title and no line under it, and "View and manage your personal records" was describing the shell rather than the page — every child route inherited it, so Tasks introduced itself that way too. 21px/720 with the drawing's tracking, matching every other view title. */}
{/* ONE RECORD, ONE NAME — and Tasks is not part of it. Spencer, 2026-08-25, settling both halves at once: *"Tasks are their own page and my visits and results is its own page… Tasks say 'My Tasks' in the heading and My visits & results say 'My Visits & Results'."* And, asked whether the two names were ever two things: *"Are 'My Visits & Results' the same thing as 'My Records'? I think yes."* **"My Records" is retired.** It agreed with nothing — not the nav item, not the breadcrumb, not the drawing — so a patient arrived at a page announcing itself as something other than the thing they had just tapped. The subtitle went with it. It described the SHELL rather than the page, so every child route inherited it and Tasks introduced itself as "your personal records" too. 21px/720 with the drawing's tracking, matching every other view title. Two sessions renamed this one heading from two different cards on the same afternoon and neither could see the other — post to the patient-portal pair log before touching it again. */}

{isTasks ? "My Tasks" : isMedications ? "Medications" : isInsurance ? "Insurance" : isFamilyHistory ? "Family health history" : <>My Visits & Results}

)} {error && ( <> setError(undefined)}> {error} )}
{/* Medications draws its own two cards, so the shell's card around them would be a third border nobody asked for. The deck puts its columns straight on the page. The patient's Tasks and Family health history decks do the same. */} {loading || waitingForRoutePatient ? ( ) : ( <> {!isPatient && (flagsList ?? []) .filter((flag: Flag) => flag.status === "active") .map((flag: Flag) => ( <> {/* Card 248 — ` [${flag.status}]` was removed rather than relabelled. The list above is already filtered to `status === "active"`, so the suffix rendered the literal word "active" on every row it could ever appear on: a constant, not information. Routing a constant through the vocabulary would have produced a tidier constant. */} {` (${formatFhirCodeableConcept(flag.category)})`} ))} {!isPatient && patientState && staffPatientBasePath ? ( // One card: the section rail, then the patient band over the section it opened.
} actions={( <> navigate("/dashboard/staff-messages", { state: { tab: "patients", openWith: id } })} onOrder={() => navigate(`${staffPatientBasePath}/care-provision/diagnostic-orders`)} /> {encounterStateId && ( )} )} />
) : ( {showsRecordPractice && } {/* Card 276 — the patient's own way around their record. `PatientChartSidebar` above renders for STAFF only (`!isPatient`), so until this existed a patient who opened their own chart could not go anywhere from it: all 36 record destinations were hamburger-only, which is what D6 forbids for exactly this cohort. Collapsed here because the page below is what the patient came for; on the chart's index route the same component renders open as the landing surface (see `renderPatientRoutes` in App.tsx). */} {isPatient && !isRecordIndex && !isRecordStream && !isTasks && !isMedications && !isCarePlans && !isFamilyHistory && !isInsurance && } {/* CARD 240 / B20 — THE PAGE SAYS WHAT IT IS. Ten of the fifteen record sections render no heading of their own, so eleven pages announced only "My Records" to a screen reader: someone navigating by heading heard the shell's title and could not tell Conditions from Medicines from Test results. The breadcrumb does NOT cover this — it is navigation, not a title, and it is not in the heading order at all. `h3`, under the shell's `h2 "My Records"`, so the outline nests rather than skipping — which is finding B2's rule applied one level down. The text is the NAV's own authored label, resolved from the same data the record nav renders, so the heading and the link a patient followed cannot disagree. */} {/* ⛔ NOT ON A DESTINATION THAT TITLES ITSELF. The two guards are the same two the browse row above carries, and for the same reason: Medications and the care provision pages are their own destinations rather than leaves of the record, and each renders its own heading. Without this, Goals printed 'Goals' here and then 'Goals' again as its own title, one line apart. */} {isPatient && !isMedications && !isCarePlans && !isFamilyHistory && !isInsurance && !isRecordStream && recordPageTitle && (

{recordPageTitle}

)}
)} {showFlagModal && userRole !== "Patient" && patientState && ( setShowFlagModal(false)} onSubmit={handleFlagSubmit} fhirFlag={flag} subjectType="Patient" subject={patientState} /> )} )}
); }; export default PatientPage;