import { useRef, useState } from "react"; import { Patient, Reference, Address, PatientContact } from "fhir/r4"; import { CheckCircle2 } from "lucide-react"; import ProfilePhoto from "../ProfilePhoto/ProfilePhoto"; import { formatFhirHumanName } from "../../utils/fhirHumanName"; import { interpreterNeededLabel } from "../../utils/interpreterNeeded"; import { PROFILE_HISTORY, profileAddressHistory } from "../../utils/fieldHistory/profileHistoryFields"; import { FHIRAddressDisplay } from "../../utils/fhirAddress"; import { FHIRContactPointDisplay, getFirstContactPointBySystem, } from "../../utils/fhirContactPoint"; import { FhirCodeableConceptDisplay } from "../../utils/fhirCodeableConcept"; import { formatDate, calculateAge, patientAgeLabel } from "../../utils/dateUtils"; import { formatPhoneNumber } from "../../utils/phoneNumber"; import { usePatientServiceContext } from "../../services"; import { getCognitoUsername, getLoginEmail, patientHasLogin } from "../../utils/cognitoIdentity"; import { EditableField, type EditableFieldOption } from "../FieldHistory/EditableField"; import { Field } from "../FieldHistory/Field"; import { getImportSourceId, getMedicalRecordNumber } from "../../utils/patientRecordIdentifiers"; import PatientRecordOrigin from './PatientRecordOrigin.tsx'; import { FieldHistoryProvider, FieldHistoryScope, useFieldHistory, useFieldHistoryScope, } from "../FieldHistory/FieldHistoryProvider"; import { ShowFieldHistoryToggle } from "../FieldHistory/ShowFieldHistoryToggle"; import { RecordEditedMarker } from "../FieldHistory/RecordEditedMarker"; import { AddressEditor, EmergencyContactEditor, type EmergencyContactDraft, } from "../../utils/patientFieldEditors"; import { getCommPreference, getSmsVerification, isSmsDeliveryConsented, } from "../../config/communicationPreferences"; import { extractOrganizationIds } from "../../utils/patientOrganizations"; import { useAuthServiceContext } from "../../services/AuthService.tsx"; import { StaffSmsVerificationDialog } from "./StaffSmsVerificationDialog"; import RecoverLoginEmailDialog from "../../pages/DashboardPage/PatientsPage/PatientPage/RecoverLoginEmailDialog.tsx"; import StaffUnlockDialog from "../../pages/DashboardPage/PatientsPage/PatientPage/StaffUnlockDialog.tsx"; import GivePatientALoginDialog from "../../pages/DashboardPage/PatientsPage/PatientPage/GivePatientALoginDialog.tsx"; import { useHasActivationReference } from '../../hooks/useActivationContinuation'; import { ABSENCE_LABEL, absenceLabelFor, absenceOf, hasValueFor, recordAbsence, type AbsenceReason, type PatientAbsenceField, } from "../../utils/patientFieldAbsence"; import { findContainedPhysician } from "../FhirPatientDisplay/primaryPhysician"; import { saveOverCurrent } from "../../utils/saveOverCurrent"; import { SaveRetiredError, useSaveScope, type SaveOperation } from "../../utils/saveOperation"; import { olderThan } from "../../utils/versionOrder"; import type { ReloadOwner } from "../FieldHistory/EditableField"; import { CONSENT_INSTRUMENT_DEFINITIONS } from "../../config/consentInstruments"; import "./PatientProfileCard.css"; const DASH = "—"; // What each field's edit depends on: the whole of what its setter changes, compared with the edit's opening // snapshot before it is saved (with the field's recorded absence beside it). const OWNED_BY_FIELD: Record unknown> = { phone: (patient) => patient.telecom?.find((point) => point.system === "phone"), email: (patient) => patient.telecom?.find((point) => point.system === "email"), address: (patient) => patient.address?.[0], birthDate: (patient) => patient.birthDate, maritalStatus: (patient) => patient.maritalStatus, gender: (patient) => patient.gender, language: (patient) => patient.communication, emergencyContact: (patient) => patient.contact?.[0], }; /** * - the three answers a receptionist can record, in the order they are reached for. * * Derived from `ABSENCE_LABEL` rather than retyped, so the wording lives in ONE place. Two spellings of * "Declined to say" is how a chart and a quick view end up disagreeing about the same patient. */ const ABSENCE_OPTIONS = ( ["not-applicable", "asked-declined", "asked-unknown"] as const satisfies readonly AbsenceReason[] ).map((value) => ({ value, label: ABSENCE_LABEL[value] })); interface PatientProfileCardProps { patient: Patient; className?: string; // When false, the photo/name/subline identity header is omitted (the workspace modal renders its own // sticky header). Defaults to true so the standalone quick-view still shows it. showHeader?: boolean; // When true, most fields become click-to-edit (guardrail-1 full fetch->merge->PUT on save). The workspace // turns this on; the read-only quick-view leaves it off. editable?: boolean; onPatientUpdated?: (patient: Patient) => void; } /** * Shared read-only, condensed patient profile card. * * The receptionist's glance-and-go default (rendered inside the patient * workspace / quick-view modal) and, later, the practitioner's profile section. * Deliberately NOT the heavy `FhirPatientDisplay` - this is a new, focused * read-only layout that reuses the display utils. Edit-in-place is the * shared `FieldHistory/EditableField` primitive; the card wraps itself in a * `FieldHistoryProvider`/`FieldHistoryScope` so each field's label can drill * into its change history. */ const referenceText = ( reference?: Reference | Reference[] | null, ): string => { const first = Array.isArray(reference) ? reference[0] : reference; return first?.display ?? first?.reference ?? ""; }; const getPreferredLanguage = (patient: Patient) => { const communications = patient.communication ?? []; if (communications.length === 0) { return undefined; } const preferred = communications.find((communication) => communication.preferred) ?? communications[0]; return preferred?.language; }; export { interpreterNeededLabel }; // The row names the patient's own doctor. The rest of generalPractitioner is the server's list of // practices with access, so reading its first entry showed an access grant as if it were a clinician. export const generalPractitionerLabel = (patient: Patient): string => findContainedPhysician(patient)?.name?.[0]?.text?.trim() ?? ""; // Left out while unanswered, except with history on after an answer was removed: the removal is // part of the record, and hiding the row would hide its history with it. const InterpreterNeededField = ({ value }: { value: string | undefined }) => { const scope = useFieldHistoryScope(); const { isActive, getFieldHistory } = useFieldHistory(); const removedAnswer = !value && isActive && scope?.id ? getFieldHistory(scope.resourceType, scope.id, PROFILE_HISTORY.interpreterAnswer, "Interpreter needed") : null; if (!value && !removedAnswer) return null; return ; }; const capitalize = (value?: string): string | undefined => value ? value.charAt(0).toUpperCase() + value.slice(1) : undefined; /** * "49 years old - Male - (715) 441-2919" - the identity subline shown under the patient name. * * THE SECOND JOINER FOR THIS LINE, and it did not get the rule the first one did. `patientRowSummary` * backs the patients LIST and was extracted and corrected on; this backs the profile card, the * chart identity, the patient page header and the RECEPTIONIST'S patient workspace dialog. Nobody looked * at it, because the reported defect was on the list. * * WHAT IT GOT WRONG, and it is the failure `patientRowSummary`'s own comment warns against in terms: * *"A PART, never a fallback for the whole line. The front desk needs to see that there is no way to * reach somebody; treating this as a whole-line fallback would render just '15 years old' for a child * with no contact details and hide precisely the fact that matters."* * A patient with no phone simply lost the segment, so the line read "52 years old - Male" and the front * desk had nothing telling them this person cannot be reached. **Absence rendered as silence.** * * IT STILL SHOWS THE PHONE AND ONLY THE PHONE, deliberately, and that is where it differs from the * list. `PatientPage` prints the email on its own line directly beneath this one - its comment says so: * *"It carries the phone, so the line below now shows the EMAIL only... the number is not printed * twice."* Adding the email here would print it twice on the surface that needed it least. * But the email still DECIDES, because a patient reachable by email is not a patient nobody can reach: * saying "No contact info" over their address would be a new false statement in place of the old silent * one. */ export const getPatientSubline = (patient: Patient): string => { const age = patient.birthDate ? calculateAge(patient.birthDate) : null; const gender = capitalize(patient.gender); const phone = getFirstContactPointBySystem(patient.telecom, "phone"); const email = getFirstContactPointBySystem(patient.telecom, "email"); const contact = phone?.value ? formatPhoneNumber(phone.value) // A PART, never a fallback for the whole line - the same rule `patientRowSummary` follows, so the // two joiners cannot disagree about a patient nobody can reach. Absent only when an email exists // and is about to be printed below. : (email?.value ? undefined : "No contact info"); return [patientAgeLabel(age) ?? undefined, gender, contact] .filter(Boolean) .join(" • "); }; const SEX_LETTER: Record = { male: "M", female: "F" }; /** The chart band's compact line, "47 · M · 30 Nov 1978 · (319) 555-0102", with the subline's contact rule. */ export const getPatientBandLine = (patient: Patient): string => { const age = patient.birthDate ? calculateAge(patient.birthDate) : null; const sex = patient.gender ? SEX_LETTER[patient.gender] ?? capitalize(patient.gender) : undefined; const born = patient.birthDate && /^\d{4}-\d{2}-\d{2}$/.test(patient.birthDate) ? new Date(`${patient.birthDate}T12:00:00`).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : undefined; const phone = getFirstContactPointBySystem(patient.telecom, "phone"); const email = getFirstContactPointBySystem(patient.telecom, "email"); const contact = phone?.value ? formatPhoneNumber(phone.value) : (email?.value ? undefined : "No contact info"); return [age === null ? undefined : String(age), sex, born, contact].filter(Boolean).join(" · "); }; export const MARITAL_STATUS_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"; export const MARITAL_STATUS_OPTIONS: EditableFieldOption[] = [ { value: "S", label: "Never married" }, { value: "M", label: "Married" }, { value: "D", label: "Divorced" }, { value: "W", label: "Widowed" }, { value: "L", label: "Legally separated" }, { value: "T", label: "Domestic partner" }, { value: "U", label: "Unmarried" }, ]; /** * - the four coded values of FHIR's **administrative** gender, and the field is labelled "Sex" * everywhere because that is what it holds. * * WHY THE DISTINCTION IS CLINICAL RATHER THAN COSMETIC, and why the label mattered enough to change: * this value is read by MACHINES - lab reference ranges, screening prompts (cervical, prostate, AAA), * some dosing, insurance claims. **Gender identity is read by PEOPLE** and nothing calculates from it. * A single field labelled "Sex / Gender" invites a patient to set it to their gender identity, and the * system then quietly stops prompting for screenings they still medically need - or starts prompting for * ones they do not. **Nothing errors.** That is why the spec calls this field *administrative* gender. * * GENDER IDENTITY IS DEFERRED, NOT MISSING (Spencer, 2026-08-13 - the SMALL version). Its future home * is an **optional field on the patient's OWN profile**, never the staff intake form: a patient states * their own identity, and the front desk should not be asked to type it during registration. * Do NOT add the US Core gender-identity extension here to "complete" this. That was ruled out * explicitly - relabel now, add identity later IF a customer asks. Building it now is the practitioner * rabbit hole the beta scope defers. * And do NOT make these options friendlier: they are the coded administrative values a machine reads. * `patientSexIsCodedAdministrative.test.ts` pins that. */ export const GENDER_OPTIONS: EditableFieldOption[] = [ { value: "male", label: "Male" }, { value: "female", label: "Female" }, { value: "other", label: "Other" }, { value: "unknown", label: "Unknown" }, ]; // BCP-47. Fixed common set (the terminology server's BCP-47 code system isn't loaded) - same approach as // marital/gender to keep editing reliable without the $expand dependency. const LANGUAGE_SYSTEM = "urn:ietf:bcp:47"; const LANGUAGE_OPTIONS: EditableFieldOption[] = [ { value: "en", label: "English" }, { value: "es", label: "Spanish" }, { value: "zh", label: "Chinese" }, { value: "vi", label: "Vietnamese" }, { value: "ar", label: "Arabic" }, { value: "fr", label: "French" }, { value: "de", label: "German" }, { value: "tl", label: "Tagalog" }, { value: "ko", label: "Korean" }, { value: "ru", label: "Russian" }, ]; // Set/replace/remove a single telecom point (phone or email) on a full Patient draft. const setTelecomValue = ( draft: Patient, system: "phone" | "email", value: string, ): void => { const telecom = [...(draft.telecom ?? [])]; const index = telecom.findIndex((point) => point.system === system); const trimmed = value.trim(); if (trimmed) { if (index >= 0) telecom[index] = { ...telecom[index], value: trimmed }; else telecom.push({ system, value: trimmed }); } else if (index >= 0) { telecom.splice(index, 1); } draft.telecom = telecom.length ? telecom : undefined; }; // A cleared field is left undefined on the draft and JSON drops it, which the server's demographic // merge reads as "unchanged". Send null for each top-level field the edit removed, and nothing else. export const withExplicitClears = (before: Patient, after: Patient): Patient => { const body: Record = { ...after }; for (const key of Object.keys(before)) { if (before[key as keyof Patient] !== undefined && after[key as keyof Patient] === undefined) body[key] = null; } return body as unknown as Patient; }; // Replace address[0] on a full Patient draft, preserving any additional address entries and address[0]'s // non-edited props (use/type/period). Clears it when street, city and ZIP are blank: the State picker // has no empty choice, so a state left alone is what clearing looks like, and is not an address. export const setAddressValue = (draft: Patient, address: Address): void => { const rest = (draft.address ?? []).slice(1); const line = (address.line ?? []) .map((entry) => entry?.trim()) .filter((entry): entry is string => Boolean(entry)); const cleaned: Address = { ...address, line: line.length ? line : undefined, city: address.city?.trim() || undefined, state: address.state?.trim() || undefined, postalCode: address.postalCode?.trim() || undefined, }; const hasAny = Boolean(cleaned.line?.length || cleaned.city || cleaned.postalCode); const next = hasAny ? [cleaned, ...rest] : rest; draft.address = next.length ? next : undefined; }; // Map the flat emergency-contact draft back onto Patient.contact[0], preserving other contacts and the // contact's non-edited props. Clears the contact when every field is blank. const setEmergencyContact = ( draft: Patient, ec: EmergencyContactDraft, ): void => { const given = ec.given.trim(); const family = ec.family.trim(); const relationship = ec.relationship.trim(); const phone = ec.phone.trim(); const email = ec.email.trim(); const rest = (draft.contact ?? []).slice(1); if (!given && !family && !relationship && !phone && !email) { draft.contact = rest.length ? rest : undefined; return; } const contact: PatientContact = { ...(draft.contact?.[0] ?? {}) }; contact.name = given || family ? { ...(contact.name ?? {}), given: given ? [given] : undefined, family: family || undefined, text: [given, family].filter(Boolean).join(" ") || undefined, } : undefined; const telecom = [...(contact.telecom ?? [])]; const phoneIndex = telecom.findIndex((point) => point.system === "phone"); if (phone) { if (phoneIndex >= 0) telecom[phoneIndex] = { ...telecom[phoneIndex], value: phone }; else telecom.push({ system: "phone", value: phone }); } else if (phoneIndex >= 0) { telecom.splice(phoneIndex, 1); } const emailIndex = telecom.findIndex((point) => point.system === "email"); if (email) { if (emailIndex >= 0) telecom[emailIndex] = { ...telecom[emailIndex], value: email }; else telecom.push({ system: "email", value: email }); } else if (emailIndex >= 0) { telecom.splice(emailIndex, 1); } contact.telecom = telecom.length ? telecom : undefined; // Never drop an existing relationship coding (e.g. the v2-0131 "C"/Emergency Contact // coding) on a text-only edit -- only rewrite the text, preserving whatever coding is there. const existingCoding = contact.relationship?.[0]?.coding; contact.relationship = relationship ? [{ ...(existingCoding ? { coding: existingCoding } : {}), text: relationship }] : existingCoding ? [{ coding: existingCoding }] : undefined; draft.contact = [contact, ...rest]; }; // Staff context: every editable field carries this caption while its editor is open. const EDIT_HINT = "Patient will receive notice of change"; const PatientProfileCard = ({ patient, className, showHeader = true, editable = false, onPatientUpdated, }: PatientProfileCardProps) => { const { fetchPatient, updatePatient } = usePatientServiceContext(); // The acting staff member's organization - the anchor for "can this caller actually touch this // patient?" (see staffMayVerifySms below). `userRole` is addition: the login-email // recovery route is receptionists-only, which org affiliation alone cannot express. const { currentOrganization, userRole, USER_ROLES } = useAuthServiceContext(); // S2b, Task 8 - staff-attested SMS number verification (the in-office path). Unlike every // other field on this card, verifying does NOT go through `savePatientField`/`updatePatient`: the // server writes `verified`/`verifiedAt` directly (a system token straight to HealthLake, mirroring // the patient's own self-scoped verify route), so this card never PUTs the Patient for it - only // reflects a write the server already made - and WHAT IT PROVED IS READ BACK, not assumed. The server sends the // code to the number stored when it is asked, which may not be the one this card showed, and its answer names no // number; so after a proof the record is read again and that is what is shown (`readBack`). const [smsDialogOpen, setSmsDialogOpen] = useState(false); const [readBack, setReadBack] = useState(null); // - the receptionist login-email recovery dialog, which until now had // no mount point anywhere in the product. const [recoverLoginOpen, setRecoverLoginOpen] = useState(false); // - the staff-attested unlock, the other half of "this patient cannot get in". const [staffUnlockOpen, setStaffUnlockOpen] = useState(false); // - the practice-mediated claim, for a patient registered with no email. const [giveLoginOpen, setGiveLoginOpen] = useState(false); const name = formatFhirHumanName(patient.name, { emptyText: "Patient" }); /** * THE NAME THE TWO CREDENTIAL DIALOGS PUT IN THEIR TITLES - deliberately NOT the `name` above. * * That one falls back to the literal string "Patient" so the card always has a heading. Rendered in a * possessive it becomes *"Change Patient's login email"*, which reads as a real name and is worse than * the generic sentence it would replace. Empty here means UNKNOWN, and the dialogs fall back to their * original copy rather than naming a placeholder. */ const recoveryDialogSubject = formatFhirHumanName(patient.name, { emptyText: "" }) || undefined; const age = patient.birthDate ? calculateAge(patient.birthDate) : null; const gender = capitalize(patient.gender); const subline = getPatientSubline(patient); // Guardrail 1: full fetch -> merge -> PUT. Re-fetch the latest Patient, apply the ONE change to a full copy, // then PUT the whole resource - never a partial (the server has no field allowlist and would strip the // generalPractitioner cross-org links). The updated resource bubbles up so the modal refreshes. /** * - the capture control for one field, wired to the same save path as its value. * * The OPTIONS are the receptionist's three answers, and `undefined` clears. `recordAbsence` refuses * to write over a field that holds a value, so this cannot be used to erase data by accident. * * Passed per field rather than switched on for the card, because these are the only fields a patient * can meaningfully decline - a medical record number is not one of them. */ const absenceCaptureFor = (field: PatientAbsenceField) => ({ reason: absenceOf(patient, field), options: ABSENCE_OPTIONS, onRecord: (reason: string | undefined, opening?: unknown) => savePatientField(field, opening, (draft) => { recordAbsence(draft, field, reason as AbsenceReason | undefined); }), }); // SAVED OVER THE RECORD AS IT IS NOW, AND ONLY IF WHAT IT EDITS IS AS IT WAS WHEN THE EDIT OPENED. A save // used to re-read the record and write the editor's choice over it, so a value another person saved meanwhile // was silently replaced. Now the field (and its recorded absence) is compared with the opening snapshot; a // change refuses, nothing is written, and the field offers Reload record. See saveOverCurrent. const shownPatient = useRef(patient); shownPatient.current = patient; // Each save belongs to the patient on this card and the person signed in; another of either retires it - and closes // the text-proof dialog, which was opened for that patient by that person. const saveScope = useSaveScope(patient.id, () => { smsOpeningNow.current = undefined; setSmsDialogOpen(false); }); const savePatientField = async ( field: PatientAbsenceField, opening: unknown, mutate: (draft: Patient) => void, ): Promise => { const id = patient.id; if (!id) return; // An opening made on another patient's record is never saved onto this one: refused before anything is read. const opened = opening as Patient | undefined; if (opened?.id && opened.id !== id) throw new SaveRetiredError(); const baseline = opened ?? patient; const owned = (record: Patient) => JSON.stringify([OWNED_BY_FIELD[field](record) ?? null, absenceOf(record, field) ?? null]); const stillThisPatient = () => shownPatient.current?.id === id; const operation = saveScope.start(); try { const updated = await saveOverCurrent({ operation, read: () => fetchPatient(id, false, undefined, true, operation.transport), known: () => (stillThisPatient() ? shownPatient.current : undefined), stillHolds: (current) => owned(current) === owned(baseline), apply: (current) => { const before: Patient = JSON.parse(JSON.stringify(current)); mutate(current); return withExplicitClears(before, current); }, write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation.transport), }); // Never an older version over a newer one the card already shows. if (stillThisPatient() && !olderThan(updated, shownPatient.current)) onPatientUpdated?.(updated); } finally { operation.retire(); } }; // Reload record, after a refused save: the patient as saved now, read fresh within its own bounded operation, and // shown in place of what the card held - unless the card already shows a newer version. // `owner`: the field opening that asked. Cancelled or reopened, the read is retired with it - not sent if still being // prepared, and shown nowhere if it answers late. const reloadPatient = async (owner?: ReloadOwner): Promise => { const id = patient.id; if (!id) return; const operation = saveScope.start(); owner?.signal.addEventListener("abort", () => operation.retire()); if (owner?.signal.aborted) operation.retire(); const current = await operation.step(() => fetchPatient(id, false, undefined, true, operation.transport)); if (!current) throw new Error("This record could not be read."); if (owner && !owner.belongs()) throw new SaveRetiredError(); if (shownPatient.current?.id === id && !olderThan(current, shownPatient.current)) onPatientUpdated?.(current); }; // EACH OPENING OF THE TEXT-PROOF DIALOG IS ITS OWN. Every opening takes an operation of this card's scope and its own // dialog instance (keyed by the opening), and that instance is handed callbacks bound to that opening - never to // whichever opening is current when a late answer arrives. Closed, replaced, or retired by a patient or viewer change, // an opening's held Send code or Confirm code finishes into an instance nobody sees: it starts no read, hands nothing // on, and resets, reports into, or closes no later dialog. const smsOpeningNow = useRef(); const smsOpenings = useRef(0); const [smsOpening, setSmsOpening] = useState<{ key: number; owner: SaveOperation }>(); const openSmsDialog = () => { smsOpeningNow.current?.retire(); const owner = saveScope.start(); smsOpeningNow.current = owner; smsOpenings.current += 1; setSmsOpening({ key: smsOpenings.current, owner }); setSmsDialogOpen(true); }; // Only the opening on screen closes the dialog; an older one's late close is not this one's. const closeSmsDialog = (owner: SaveOperation) => { if (smsOpeningNow.current !== owner) return; owner.retire(); smsOpeningNow.current = undefined; setSmsDialogOpen(false); }; // After an in-office proof: the record as the server now holds it - which number is stored, and whether it is // verified - is shown and handed on, never older than what this card already shows. Touches nothing else: a proof // proves control of the handset, not consent to be texted. // `asked`: the opening the proof was made in - it must still be open when the proof arrives. The read-back that // follows is the proof's own: the dialog closing after it does not cancel it; another patient, viewer or unmount does. const readBackAfterProof = async (asked: SaveOperation): Promise => { const id = patient.id; if (!id || !asked.belongs()) return; const operation = saveScope.start(); try { const current = await operation.step(() => fetchPatient(id, false, undefined, true, operation.transport)); if (!operation.belongs() || !current || shownPatient.current?.id !== id || olderThan(current, shownPatient.current)) return; setReadBack(current); onPatientUpdated?.(current); } catch { // The dialog said the number was verified; the card keeps what it last read until it is opened again. } }; const phoneValue = getFirstContactPointBySystem(patient.telecom, "phone")?.value ?? ""; const emailValue = getFirstContactPointBySystem(patient.telecom, "email")?.value ?? ""; // Sign-in email, server-stamped at account creation - distinct from the telecom "Email" above, which is a // contact point staff can edit. Read-only; changing the login is a separate patient-only flow. For an // affiliation-only caller the server strips meta.tag, so this is undefined and the field is omitted // entirely - it appears only where the full resource reaches this card, i.e. a patient this org has a care relationship with. const loginUsername = getCognitoUsername(patient); // A username that is the account UUID is a login with no address to show: "Has a login", recovery kept. const loginEmail = getLoginEmail(patient); /** * - "HAS A LOGIN" IS NOT "WE KNOW THE ADDRESS", and this card treated them as one. * * A patient activated through `Patient/activate.ts` carries only the PATIENT_LOGIN_ACTIVATED marker, * never the username tag - so `loginEmail` is undefined for someone who plainly HAS a login. The chart * then said "No login", kept offering to create one, and the server refused the second attempt with a * message about a child. */ const hasLogin = patientHasLogin(patient); const hasActivationReference = useHasActivationReference(patient.id ?? ''); const maritalCode = patient.maritalStatus?.coding?.[0]?.code ?? ""; const dateOfBirth = patient.birthDate ? age != null ? `${formatDate(patient.birthDate)} (${age})` : formatDate(patient.birthDate) : DASH; const preferredLanguage = getPreferredLanguage(patient); const interpreterNeeded = interpreterNeededLabel(patient); const generalPractitioner = generalPractitionerLabel(patient); const managingOrganization = referenceText(patient.managingOrganization); const emergencyContactSource = patient.contact?.[0]; const emergencyContactName = formatFhirHumanName( emergencyContactSource?.name, { emptyText: "" }, ); const emergencyContactRelationship = emergencyContactSource?.relationship?.[0]?.text ?? emergencyContactSource?.relationship?.[0]?.coding?.[0]?.display ?? ""; const emergencyContactPhone = getFirstContactPointBySystem( emergencyContactSource?.telecom, "phone", )?.value; const emergencyContactEmail = getFirstContactPointBySystem( emergencyContactSource?.telecom, "email", )?.value; const emergencyContactDraft: EmergencyContactDraft = { given: emergencyContactSource?.name?.given?.[0] ?? "", family: emergencyContactSource?.name?.family ?? "", relationship: emergencyContactRelationship, phone: emergencyContactPhone ?? "", email: emergencyContactEmail ?? "", }; const emergencyContactDisplay = [ emergencyContactName ? emergencyContactRelationship ? `${emergencyContactName} (${emergencyContactRelationship})` : emergencyContactName : "", emergencyContactPhone ? formatPhoneNumber(emergencyContactPhone) : "", ] .filter(Boolean) .join(" • "); const medicalRecordNumber = getMedicalRecordNumber(patient); const importSourceId = getImportSourceId(patient); // The verification state of the patient's currently-selected (S2a) SMS number, from the newest record known // here: the one handed in, or the one read back after a proof. Undefined when no number has been selected at all - // there is nothing to verify yet, and this card is not where a number gets chosen (that's the patient's own // Settings page, S2a). const persistedSmsVerification = getSmsVerification( readBack && readBack.id === patient.id && !olderThan(readBack, patient) ? readBack : patient, ); const smsVerification = persistedSmsVerification; // THE STAFF SMS AFFORDANCE MUST NOT BE OFFERED WHERE IT CANNOT SUCCEED, because the misclick is // PERMANENT. Both staff SMS routes run authorizeStaffForPatient, which requires org affiliation AND // validatePatientAccessForPatientId('Patient', 'POST') - a strictly stronger claim that a // merely-affiliated org is deliberately granted nothing for (validators.ts, // resolveAffiliationGrantKind's "DELIBERATELY EXCLUDED" note). That is the ordinary state of a // walk-in a receptionist just created. Gating only on `editable` therefore offered a button whose // 403 ALSO wrote a "Denied staff SMS verification attempt" AuditEvent anchored on the patient - a row // that surfaces forever in their "who accessed my data" feed. A dead-end control that // leaves a permanent mark on someone else's record is strictly worse than a missing one. // // The signal the client can actually see is the same fact validators.ts's care-relationship branch // grants on: this organization on `Patient.generalPractitioner`. Fails CLOSED - no current org, or no // link, means no button. // // KNOWN, ACCEPTED FALSE NEGATIVE: validators.ts also grants via a patient-approved // collaboration-team referral Consent (branch C1b), which is invisible from here - an invited // outside practitioner who WOULD be authorized sees no button. Hiding a working control for a rare // cross-org case is the cheaper error than offering a broken one on the common path; there is no // client-visible signal for C1b to gate on, and inventing one would be a guess. const actingOrgId = currentOrganization?.id; const staffMayVerifySms = !!actingOrgId && extractOrganizationIds(patient).includes(actingOrgId); //: the login-email recovery affordance // `RecoverLoginEmailDialog` was built, reviewed and mounted by NOTHING - unwired-slices // failure. It mounts below, beside the read-only Login Email field it changes. // // A STRICTLY NARROWER GATE THAN `staffMayVerifySms` ABOVE, AND REUSING THAT ONE WOULD BE WRONG. // `POST /R4/credential-recovery/attest` is `allowedRoles: [RECEPTIONISTS]` - not admins, not // practitioners. This is the one place in the product where a staff member changes another person's // sign-in credential, and the route deliberately does not spend that authority on "staff" generally. // // The org clause STAYS on top of the role clause rather than being replaced by it: the route also // carries `requireOrganizationId`, and the handler re-checks affiliation independently of both. // // Same reasoning as the SMS block above for why this hides rather than shows-and-fails: a refused // staff action writes a denial AuditEvent anchored on the PATIENT, permanently visible in their // "who accessed my data" feed. A button that can only ever leave a mark on someone else's // record is worse than a missing one. const staffMayRecoverLogin = userRole === USER_ROLES.RECEPTIONIST && staffMayVerifySms; // Whether the patient has ALREADY consented to being texted. Verification alone never starts texts // (the server's eligibleChannels needs both consents too), so the dialog says so rather than letting // staff imply otherwise. Read-only here - R5 keeps switching it on the patient's own act. const smsDeliveryConsented = isSmsDeliveryConsented(patient); // Off or never set both mean the server sends no reminder at all, on any channel. A demographics-only // read (marked SUBSETTED by the server) omits the preference, so there it is unknown rather than off. const appointmentRemindersOn = getCommPreference(patient, "appointmentReminders")?.enabled === true; const preferencesWithheld = (patient.meta?.tag ?? []).some( (tag) => tag.system === "http://terminology.hl7.org/CodeSystem/v3-ObservationValue" && tag.code === "SUBSETTED", ); return (
{showHeader && (
{name}
{subline && (
{subline}
)}
)}

Contact

savePatientField("phone", opening, (draft) => setTelecomValue(draft, "phone", value)) } absence={absenceCaptureFor("phone")} value={ } /> savePatientField("email", opening, (draft) => setTelecomValue(draft, "email", value)) } absence={absenceCaptureFor("email")} value={ } /> {!hasLogin && ( /** * - A PATIENT REGISTERED WITHOUT AN EMAIL HAS NO LOGIN, AND THIS IS WHERE A * RECEPTIONIST FINDS OUT AND FIXES IT. * * It sits in the slot the Login Email field occupies when there IS one, deliberately: * a receptionist asking *"can this patient get into the portal?"* looks in one place and * gets an answer either way. * * **Not an unsent invite, not a perpetual "pending".** A pending state nobody can * resolve is worse than an honest absence - so it says there is no login, and offers the * one action that changes that. */
No login} /> {editable && patient.id && (
)}
)} {hasLogin && editable && patient.id && hasActivationReference && ( )} {hasLogin && !loginUsername && ( /** * - THE PATIENT HAS A LOGIN AND WE DO NOT KNOW THE ADDRESS. * * This is the child-activation path: `Patient/activate.ts` stamps only the * PATIENT_LOGIN_ACTIVATED marker, so the record genuinely carries no address to show. * * It does NOT offer to create one, and that is the fix. The server refuses a second * login on exactly this test, so the button could only ever produce an error - and the * error it produced spoke about a child, to a receptionist looking at an adult. * * It says the account exists and stops there. Spencer ruled the desk may know that * much; the ADDRESS is a separate disclosure and is not invented to fill the field. */
Has a login} />
)} {loginUsername && (
Has a login} /> {/* Card 145 — deliberately HERE, beside the field it changes, rather than as a top-level action. The receptionist workspace already stacks card 187's RecordAuthorizationDialog directly above this card in the same Profile tab, so a second sibling dialog up there is how one front desk grows two answers to "capture something from this patient in person". */} {editable && patient.id && staffMayRecoverLogin && (
{/* Card 137 — the SECOND door out of a locked account, deliberately beside the first: a receptionist facing "I can't get into my account" needs both answers in one place, because which one applies depends on a fact they do not have. ⚠️ Named differently on purpose — these do very different things and are irreversible in different ways; a test pins the labels as distinct. */}
)} {/* SAY WHY THEY ARE NOT THERE, WHERE THEY WOULD HAVE BEEN. "I never got the invite" is the commonest credential problem a front desk has, and the two controls above are the answer to it. They are hidden until this practice has a recorded authorization - correctly, because a refused staff action writes a denial AuditEvent anchored on the PATIENT and permanently visible in their own access feed. But nothing said so, and an engineer with the source open read the absence as a bug. A receptionist with somebody at the desk has no chance at all. IT SAYS WHAT TO DO, NOT WHAT IS WRONG, and the controls stay HIDDEN rather than shown-and-failing. A disabled button with a tooltip is the same shape wearing a hat: it puts the thing they cannot do in front of them and hides the reason behind a hover. ONLY WHERE IT IS TRUE. A receptionist, because they are the only role these controls are for and the only one being told to press something they can actually use - the attest route takes receptionists and practitioners, but recovery is receptionist-only. And only while the authorization is missing, so an authorized patient gains no text. IT SPEAKS ONLY TO LOGIN RECOVERY. The staff SMS control below has a SECOND condition - it also needs a recorded mobile - so for a freshly registered patient its absence may have nothing to do with the authorization, and saying otherwise would send the desk after the wrong thing. */} {editable && patient.id && userRole === USER_ROLES.RECEPTIONIST && !staffMayRecoverLogin && (

{/* The gate reads the patient's practices, which this desk only sees once the HIPAA Authorization is recorded; Consent to Treat alone leaves the controls hidden. */} Record this patient's {CONSENT_INSTRUMENT_DEFINITIONS['hipaa-authorization'].name}, above, to enable login recovery for them.

)}
)} {patient.id && preferencesWithheld && (
Appointment reminders

Reminder preference unavailable with this access.

)} {patient.id && !preferencesWithheld && !appointmentRemindersOn && (
Appointment reminders

Off. This patient hasn’t turned on appointment reminders, so none are sent to them.

)} {editable && patient.id && staffMayVerifySms && smsVerification && (
Text (SMS) reminders
{smsVerification.verified ? (

Verified {smsVerification.verifiedAt && ` on ${new Date(smsVerification.verifiedAt).toLocaleDateString()}`}

) : (

This number hasn’t been verified. Until it is, reminders will be sent by email instead of text.

)}
)}
label="Address" path={profileAddressHistory(0)} variant="stacked" editable={editable} editValue={patient.address?.[0] ?? {}} editHint={EDIT_HINT} snapshot={patient} onReload={reloadPatient} onSave={(value, opening) => savePatientField("address", opening, (draft) => setAddressValue(draft, value)) } renderEditor={AddressEditor} absence={absenceCaptureFor("address")} value={ // `address?.length` is NOT the same question as "has an address" once absences are // recordable - a recorded absence IS an entry, carrying an extension and no lines. So // the absence is asked first, and the length check only decides between a real address // and the dash. absenceLabelFor(patient, "address") ?? (hasValueFor(patient, "address") ? ( ) : ( DASH )) } />

Personal

savePatientField("birthDate", opening, (draft) => { draft.birthDate = value || undefined; }) } absence={absenceCaptureFor("birthDate")} value={absenceLabelFor(patient, "birthDate") ?? dateOfBirth} /> savePatientField("maritalStatus", opening, (draft) => { const option = MARITAL_STATUS_OPTIONS.find( (o) => o.value === value, ); draft.maritalStatus = value ? { coding: [ { system: MARITAL_STATUS_SYSTEM, code: value, display: option?.label, }, ], text: option?.label, } : undefined; }) } absence={absenceCaptureFor("maritalStatus")} value={ // Same trap as the address: a recorded absence makes `maritalStatus` truthy while it // carries no coding, so the absence is asked before the presence check. absenceLabelFor(patient, "maritalStatus") ?? (patient.maritalStatus?.coding?.length ? ( ) : ( DASH )) } /> savePatientField("gender", opening, (draft) => { draft.gender = (value || undefined) as Patient["gender"]; }) } absence={absenceCaptureFor("gender")} value={gender ?? absenceLabelFor(patient, "gender") ?? DASH} /> savePatientField("language", opening, (draft) => { if (!value) { draft.communication = undefined; return; } const option = LANGUAGE_OPTIONS.find((o) => o.value === value); draft.communication = [ { language: { coding: [ { system: LANGUAGE_SYSTEM, code: value, display: option?.label, }, ], text: option?.label, }, preferred: true, }, ]; }) } absence={absenceCaptureFor("language")} value={ absenceLabelFor(patient, "language") ?? (preferredLanguage?.coding?.length ? ( ) : ( DASH )) } />

Care & identity

label="Emergency contact" variant="stacked" editable={editable} editValue={emergencyContactDraft} editHint={EDIT_HINT} snapshot={patient} onReload={reloadPatient} onSave={(value, opening) => savePatientField("emergencyContact", opening, (draft) => setEmergencyContact(draft, value)) } renderEditor={EmergencyContactEditor} absence={absenceCaptureFor("emergencyContact")} value={ emergencyContactDisplay || absenceLabelFor(patient, "emergencyContact") || DASH } /> {importSourceId && ( )}
{/* Card 330 done-when ④ — how this record came to exist, in plain words. ⭐ It belongs ON the chart: "where did this come from?" is asked while looking at the record, usually because something about it is surprising — a duplicate, a name nobody recognises, a patient who says they never registered. An answer that lives in an audit screen is an answer nobody reaches. */} {patient.id && }
{editable && patient.id && staffMayVerifySms && ( { if (smsOpening) closeSmsDialog(smsOpening.owner); }} onVerified={() => { if (smsOpening) void readBackAfterProof(smsOpening.owner); }} /> )} {/* Card 145 — gated on the SAME expression as its button, deliberately. Gating the mount only on `editable` would leave the dialog reachable by anything that could set its open state while the affordance was hidden, which is how a hidden control stops being a gate. */} {editable && patient.id && staffMayRecoverLogin && ( { if (patient.id) { fetchPatient(patient.id, false, undefined, true) .then((latest) => onPatientUpdated?.(latest)) .catch((err) => console.error("Failed to refresh patient after login email change:", err), ); } }} /> )} {/* Card 137 — gated on the SAME expression as its button and as its sibling dialog, for the same reason: a hidden control that is still mountable is not a gate. */} {editable && patient.id && staffMayRecoverLogin && ( {}} /> )} {patient.id && ( {})` is a fetch-then-PUT. Two things break here: - The login email lives on `meta.tag`, server-stamped by the activate handler - it is not a field this card owns or sends. Re-PUTting the resource cannot make the tag appear, and round-trips the whole record to learn one thing it never asked the server for. - Worse, the PUT carries whatever the read returned. HealthLake is ~30s stale after a write, so the read immediately after activation can hand back the PRE-activation resource - and this then writes that stale copy back over the record. THE CORRECT PATTERN IS 25 LINES ABOVE, on `RecoverLoginEmailDialog`, and it says so in its own comment: "the login email is server-stamped onto meta.tag, not a field this card PUTs - so the only honest refresh is a re-read." Both dialogs change the same tag by the same mechanism; they now refresh the same way. The retained activation's Check remains reachable after account tags appear. */ onActivated={(isCurrent) => { if (!patient.id) return; fetchPatient(patient.id, false, undefined, true) .then((latest) => { if (isCurrent()) onPatientUpdated?.(latest); }) .catch((err) => { if (isCurrent()) console.error("Failed to refresh patient after giving them a login:", err); }); }} /> )}
); }; export default PatientProfileCard;