import { scannedPhotoSrc } from '../../../utils/fhirPhoto'; import { useActAs } from "../../../hooks/useActAs"; import { useCallback, useEffect, useRef, useState } from "react"; import { FAMILY_SAVED, familySaveFailure, familySaveProblem, type FamilySaveProblem } from "./familySaveFailure"; import { RelatedPerson, Attachment, HumanName } from "fhir/r4"; import { useForm, FormProvider } from "react-hook-form"; import { FhirRelatedPersonDisplay, FhirRelatedPerson, AppSpinner } from "../../../components"; import { useFhirRelatedPersonBroadcast, useFhirRelatedPersons, useFhirRelatedPersonTargetedBroadcast, useRelatedPersonIdentity, } from "../../../hooks"; import { EmptyState } from "../../../components/ui/empty-state"; import { useMyProxies, type MyProxy } from "../../../hooks/useMyProxies.tsx"; import { announceAddedChild, type AcceptedLinkSnapshot } from "../../../hooks/acceptedLinkRefresh"; import { useRereadWhileSeen } from "../../../hooks/useRereadWhileSeen"; import { describeProxyCannotManageRelatedPersons, mayManageRelatedPersonsAsProxy, proxyStanding } from "../../../utils/proxyFormAuthority"; import { GUARDIAN_KEEPS_GRANT_MESSAGE, guardianKeepsGrant } from "../../../utils/guardianKeepsGrant"; import { AddDependentDialog, ActivateChildDialog, InviteCoGuardianDialog, type GuardianStanding } from "./DependentActions"; import { viewerEpoch } from "../../../utils/writeThroughHold"; import { RecordGuardianDialog } from "./RecordGuardianDialog"; import { TakeoverContactPrompt } from "./TakeoverContactPrompt"; import { useAuthServiceContext, usePatientServiceContext, useRelatedPersonServiceContext, useFhirServiceContext, } from "../../../services"; import type { ActingAsScope, ActingAsTarget } from "../../../services/AuthService.tsx"; import { useRelatedPersonAuthorityService } from "../../../services/RelatedPersonAuthorityService.tsx"; import { TIER_LABELS, endedFor, isCurrentGrant, tierShownFor as tierShownForGrants, type AuthorityGrant, type AuthorityTier } from "../../../services/relatedPersonAuthority"; import { shouldShowPendingProofingBadge, type CareAccessState } from "../../../components/FhirRelatedPersonDisplay/careAccess.helpers"; import CareAccess from "../../../components/FhirRelatedPersonDisplay/CareAccess.tsx"; import { isMinorPatient } from "../../../utils/adolescentFeatureGate"; import { InboundAccessActions } from "./InboundAccessActions"; import { PersonActivity } from "./PersonActivity"; import { NobodyToAdd } from "./NobodyToAdd"; import "./RelatedPersonsPage.css"; import { writtenAuthorityState, type InviteGrants, } from "../../../services/relatedPersonInvite"; import { useInviteAuthority } from "../../../hooks/useInviteAuthority"; import { useRereadWhileInvited } from "../../../hooks/useRereadWhileInvited"; import { useLifetime } from "../../../hooks/useLifetime"; import { onViewerChange } from "../../../utils/writeThroughHold"; import { invitationMailNotice, type InvitationMail } from "../../../services/invitationMail"; import ConfirmActAsDialog from "../../../components/ActAs/ConfirmActAsDialog.tsx"; import { ArrowLeftRight, Circle, HandHeart, MessageSquare, ScanEye, Minus, Plus, Receipt, SquareArrowRight, UserRoundPen, UserRoundPlus, Users } from "lucide-react"; import { AddRow, PersonAction, PersonAvatar, PersonHead, useWideFamilyLayout, type PersonTone } from "./FamilyPerson"; import { Skeleton } from "../../../components/ui/skeleton"; import { Button } from "../../../components/ui/button"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "../../../components/ui/sheet"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { formatFhirCodeableConcept } from "../../../utils/fhirCodeableConcept.tsx"; import { inviteAnswerOf, declinedAtOf } from "../../../config/relatedPersonInviteStatus"; import { formatDate } from "../../../utils/dateUtils"; import { PROXY_RELATIONSHIP_FALLBACK, proxyRelationshipLabel, } from "./proxyRelationshipLabel"; import { toast } from "sonner"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { patientCacheGuard } from '../../../utils/cacheOwner'; // Creating a related person saves an invitation; nothing is granted until they accept it. const INVITATION_SENT_DETAIL = "Nothing is granted until they accept."; // The two directions this page shows. Named rather than a bare string union at the use site so the // tab value and the render conditions cannot drift into disagreeing about the spelling. type RelatedDirection = "outbound" | "inbound"; // One list row. RelatedPerson is now a thin link (Slice A, Phase 1) - no name/gender/birthDate on // the resource itself - so this resolves the display name (and, indirectly, contact) via the // scoped identity projection instead. Pulled out into its own component (rather than inline in the // list.map()) so it can call the resolver hook unconditionally, once per row (rules of hooks). export const RelatedPersonRow = ({ relatedPerson, onSelect, tier, ended = false, selected = false, subjectIsMinor = false, }: { relatedPerson: RelatedPerson; onSelect: (relatedPerson: RelatedPerson) => void; /** The person this page is about is under 18, so a full proxy over them needs no verification. */ subjectIsMinor?: boolean; /** The grant's tier, when the reader may see grants. */ tier?: AuthorityTier; /** The server says this guardianship has ended: it grants nothing now, so it is never shown as Active. */ ended?: boolean; /** The person open beside the list. */ selected?: boolean; }) => { const { name: projectedName, loading, proofingStatus, joined } = useRelatedPersonIdentity(relatedPerson.id); const inviteAnswer = inviteAnswerOf(relatedPerson); const declinedAt = declinedAtOf(relatedPerson); // The tier, an open invitation and a missing verification are pills, as the board draws them (.tierpill, // .tierpill.wait); a declined invitation and a grant with no tier stay coloured text. // Pending and declined stay distinct: both are `active: false`, and only one of them is an answer. const accessMark = inviteAnswer === "declined" ? ( ) : inviteAnswer === "pending" ? ( Invited ) : ended ? ( // Before the first-sign-in check: a guardian who never joined ages out too, and signing in cannot undo it. ) : joined === false ? ( // A new-account link is active from creation; only their first sign-in says they joined. Invited, not joined yet ) : !tier ? ( ) : !loading && shouldShowPendingProofingBadge(tier, proofingStatus, subjectIsMinor) ? ( Not verified ) : ( {TIER_LABELS[tier]} ); const photo = relatedPerson?.photo?.[0]; const photoSrc = scannedPhotoSrc(photo) ?? ""; const relationshipLabel = relatedPerson?.relationship?.[0]?.text ? relatedPerson.relationship[0].text.charAt(0).toUpperCase() + relatedPerson.relationship[0].text.slice(1) : ""; // Graceful fallback chain, same as FhirRelatedPersonDisplay: projected name, then relationship // label, then a plain "Name unavailable" once we're sure the projection resolved to nothing // (avoid flashing that text while still loading) - never blank or NaN. const displayName = formatFhirHumanName(projectedName, { emptyText: "" }) || relationshipLabel || (loading ? "" : "Name unavailable"); const languages = (relatedPerson?.communication ?? []).map((comm) => comm.language?.text || comm.language?.coding?.[0]?.display || comm.language?.coding?.[0]?.code || "Unknown"); const relationshipLine = [relationshipLabel, ...languages].filter(Boolean).join(" · "); return (
onSelect(relatedPerson)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); onSelect(relatedPerson); }} > {displayName} {relationshipLine && {relationshipLine}} {accessMark}
); }; // The avatar takes the colour of what the person may do: sage for a full proxy, amber while they wait. const personTone = (relatedPerson: RelatedPerson, tier: AuthorityTier | undefined, notVerified: boolean): PersonTone => { const answer = inviteAnswerOf(relatedPerson); if (answer === "pending" || (answer !== "declined" && notVerified)) return "wait"; if (answer !== "declined" && tier === "full-proxy") return "full"; return undefined; }; // Graceful fallback chain, shared by the row and the sheet so the two can never disagree about who a // person is: an undefined/empty subjectName must never render a blank row. Fall back to the // relationship label, then a generic label, same shape as RelatedPersonRow's displayName above. /** * THE RELATIONSHIP LINE IS INVERTED; THE SUBJECT FALLBACK IS NOT. * * `relationshipLine` states the relationship from the READER's side - this list names SUBJECTS, and * `RelatedPerson.relationship` stores what the reader is to them, so a passthrough would print * "parent" under a child's name. See `proxyRelationshipLabel.ts` for why that is not a fix to the * shared formatter. * * `subjectLabel` deliberately still uses the raw formatter. It is a last-ditch name for somebody with * no name, and feeding the inverted sentence into it would title an unnamed person "your child" - a * relationship where a name belongs. Its behaviour is unchanged. */ const proxyLabels = (proxy: MyProxy) => { const rawRelationship = formatFhirCodeableConcept(proxy.relationship); return { relationshipLine: proxyRelationshipLabel(proxy.relationship), subjectLabel: proxy.subjectName || rawRelationship || "Unnamed dependent", }; }; // Related-person "act-as" account switcher, client Task 4 (Phase 1) - the outbound // "switch-into" list. Reuses useMyProxies (Task 2b) for the proxy list and ConfirmActAsDialog // (Task 3, which owns the actual useActAs().enter() call) for the confirm-on-entry flow; this // component owns only the loading/error/empty/list rendering + which target is currently selected // for confirmation. Exported (like RelatedPersonRow above) so it's directly testable without // mounting the whole page. // An Accept or an added child the list has not caught up with yet. Said as that, never as "no access": // the write is saved, and only the search behind this list is late. const AcceptedLinkLine = ({ link, onRetry }: { link: AcceptedLinkSnapshot; onRetry: () => void }) => { const name = link.subjectName; const added = link.kind === "added"; if (link.status === "updating") { return (

{added ? <>{name} was added. : <>You accepted {name}’s invitation.} Updating your list…

); } const saved = added ? <>{name} was added : <>Your acceptance of {name}’s invitation was saved; if (link.status === "refused") { return (

{saved}, but this list was refused to your account.

); } // The server says this can never show: nothing to wait for, so no Retry. if (link.status === "ended" || link.status === "closed") { return (

{link.status === "ended" ? <>You accepted {name}’s invitation, but it was to act as a guardian of someone who has since turned 18, so it gives no access now. : <>{name}’s invitation is no longer open, so it gives no access.}

); } return (
{saved}, but this list{" "} {link.status === "failed" ? "could not be refreshed." : "has not caught up yet."}
); }; // While switched into someone, the people this person helps and adding a child belong to their own account: none of // it is read or offered here, only the way back to that account. const OwnAccountOnly = ({ subjectName }: { subjectName?: string }) => { const { exit } = useActAs(); return (

You are in {subjectName ? subjectName + "'s" : "another person's"} account. The people you help, and adding a child, are in your own account.

); }; export const MyProxiesSection = () => { const { proxies, isLoading, error, refetch, rereadQuietly, refetchUntilHasLogin, acceptedLink, retryAcceptedLink, latestUnconfirmed, confirmProxies, } = useMyProxies(); // Nothing tells this page when somebody ends this account's access, so it reads its list again while seen. useRereadWhileSeen(true, rereadQuietly); // A link this account accepted that the list does not show yet is said so, with Retry, until it shows: also // after the wait found it once, when a later read did not (the list is still catching up). Never "no access". const waitingFor = acceptedLink && !proxies.some((proxy) => proxy.relatedPersonId === acceptedLink.relatedPersonId) ? acceptedLink : null; const acceptedLine = waitingFor && void retryAcceptedLink()} />; const [selectedTarget, setSelectedTarget] = useState(null); const [showConfirm, setShowConfirm] = useState(false); // Guardian-only actions are offered only from a read that still holds; each send asks again. While the newest // read failed, an already open form stays with its entries but is not offered anew, and cannot send. const mayActAsGuardian = (proxy: MyProxy) => proxy.callerIsGuardian === true && !latestUnconfirmed; const confirmGuardian = (proxy: MyProxy) => async (signal: AbortSignal): Promise => { const fresh = await confirmProxies(signal); if (fresh.status !== "confirmed") return "unknown"; return fresh.proxies.some((row) => row.relatedPersonId === proxy.relatedPersonId && row.callerIsGuardian === true) ? "allowed" : "denied"; }; // A guardian-only form's settled answer, kept for this viewer and person after the form itself is retired. const [guardianAnswer, setGuardianAnswer] = useState<{ owner: string; text: string } | undefined>(); const answerOwner = (proxy: MyProxy) => `${viewerEpoch()}|${proxy.relatedPersonId}`; // A row is a person, not a control panel: everything a guardian can do about ONE dependent lives behind // their row. On a phone it opens over the list; from a tablet up it sits beside it, first person open. const wide = useWideFamilyLayout(); const [openProxyPicked, setOpenProxy] = useState(null); // The phone sheet shows the person as the list has them NOW: their tier and flags from the latest read, and // closed once the list no longer has them. A snapshot taken when the row was opened is never shown. const openProxy = openProxyPicked ? proxies.find((proxy) => proxy.relatedPersonId === openProxyPicked.relatedPersonId) ?? null : null; const [selectedId, setSelectedId] = useState(null); const shownProxy = proxies.find((proxy) => proxy.relatedPersonId === selectedId) ?? proxies[0] ?? null; const handleSwitchIn = (proxy: MyProxy, scope?: ActingAsScope) => { setSelectedTarget({ relatedPersonId: proxy.relatedPersonId, subjectPatientId: proxy.subjectPatientId, subjectName: proxy.subjectName, ...(scope ? { scope } : {}), }); // Close the sheet BEFORE the confirm opens. Switching in leaves this screen entirely, so leaving the // sheet mounted underneath would stack two focus traps for a panel the guardian is done with. setOpenProxy(null); setShowConfirm(true); }; const openRow = (proxy: MyProxy) => { setSelectedId(proxy.relatedPersonId); if (!wide) setOpenProxy(proxy); }; if (isLoading) { return (
{[...Array(2)].map((_, i) => ( ))}
); } if (error) { return (
{error}
); } if (proxies.length === 0) { if (acceptedLine) return
{acceptedLine}
; return ( // The shared empty state, with Add a child in it: the empty state is exactly where a parent adds // their first child. Reached only after `isLoading` returns above. } data-testid="my-proxies-empty" /> ); } // Everything a guardian can do about ONE person. The gates are unchanged and each is still compared // against an explicit value, so an absent flag HIDES the action rather than offering it. const proxyDetail = (proxy: MyProxy) => { const { relationshipLine, subjectLabel } = proxyLabels(proxy); // Whether this person can sign in is said here, rather than left to be inferred from an action // disappearing. Compared against explicit values: an absent flag says nothing. const login = proxy.subjectHasLogin === true ? "they sign in themselves" : proxy.subjectHasLogin === false ? "no login of their own" : ""; return ( <> } /> {proxy.subjectHasLogin === true && ( Can sign in themselves )} {guardianAnswer?.owner === answerOwner(proxy) && (

{guardianAnswer.text}

)}
{proxy.tierUnresolved === true && (

Your access to {subjectLabel}’s account is still being set up. Check back in a minute.

)} {proxy.careTier === "assist-only" && proxy.tierUnresolved !== true && (

{/* Assist-only reads; it books nothing and fills in no forms (related-persons/_index.md). */} You can see {subjectLabel}’s record, but not book or fill in forms for them. {proxy.tierReducedForProofing ? ( <> {" "}They gave you more than this, and it starts once you finish verifying your identity. ) : null}

)} {proxy.careTier === "assist-only" && proxy.allowBillingView === true && ( handleSwitchIn(proxy, "billing")} /> )} {proxy.careTier === "assist-only" && proxy.allowMessaging === true && ( handleSwitchIn(proxy, "messaging")} /> )} {/* Every tier reads (relatedPersonAuthorityMatrix allows GET at assist-only); the pages inside * hide the writes this tier lacks. */} {proxy.careTier === "assist-only" && ( handleSwitchIn(proxy)} /> )} {proxy.careTier === "assist-only" ? null : ( handleSwitchIn(proxy)} /> )} {/* An existing login offers only a retained activation's Check; a fresh action requires a confirmed absence. */} {proxy.callerIsGuardian === true && ( setGuardianAnswer({ owner: answerOwner(proxy), text })} childPatientId={proxy.subjectPatientId} childName={subjectLabel} onActivated={() => refetchUntilHasLogin(proxy.subjectPatientId)} renderTrigger={(open, checking) => mayActAsGuardian(proxy) && ( { setGuardianAnswer(undefined); open(); }} /> )} /> )} {/* Co-guardianship is a minor-child concept, offered only to a confirmed guardian of that child. */} {proxy.subjectIsMinor === true && proxy.callerIsGuardian === true && ( setGuardianAnswer({ owner: answerOwner(proxy), text })} childPatientId={proxy.subjectPatientId} childName={subjectLabel} onInvited={refetch} renderTrigger={(open) => mayActAsGuardian(proxy) && ( { setGuardianAnswer(undefined); open(); }} /> )} /> )} {/* From 16 until 18, the server asks for the teen's own contact details so the account can * transfer cleanly; `refetch` clears the prompt once it stops asking. */} {proxy.subjectNeedsTakeoverContact === true && ( )}
); }; return (
{acceptedLine}
{proxies.map((proxy) => { const { relationshipLine, subjectLabel } = proxyLabels(proxy); const selected = wide && shownProxy?.relatedPersonId === proxy.relatedPersonId; // No action of its own on the row: a name that can shrink and a mark cannot overflow at 390. return (
openRow(proxy)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openRow(proxy); }} className="ahr-list-card ahr-family-row" > {subjectLabel} {relationshipLine && {relationshipLine}}
); })}
} />
{wide && shownProxy && (
{proxyDetail(shownProxy)}
)} { if (!next) setOpenProxy(null); }} > {openProxy ? proxyLabels(openProxy).subjectLabel : ""} What you can do for them {openProxy && proxyDetail(openProxy)}
); }; const RelatedPersonsPage = ({ isRoot = false }: { isRoot?: boolean }) => { const { userRole, USER_ROLES, actingAs } = useAuthServiceContext(); const isPatient = userRole === USER_ROLES.PATIENT; // Adding someone here grants a third person access the helper may not hold themselves; the matrix allows // it at full access only. const { proxies: myProxies, listAnswered: myListAnswered } = useMyProxies(); const actingTier = actingAs ? proxyStanding(myProxies, actingAs.relatedPersonId, myListAnswered) : undefined; const mayManage = isPatient && (!actingAs || mayManageRelatedPersonsAsProxy(actingTier)); const mayManagePerson = (person?: RelatedPerson | null) => mayManage && !guardianKeepsGrant(person); /** * Which direction the patient is looking at. Outbound first because it is the one with an action - * a parent lands here to switch into a child's account, and the inbound list is a thing they check * rather than a thing they do. * * Tabs only where there are two of them: the outbound half is gated on `isPatient`, so a clinician * reading this page through the chart has one direction and keeps the plain stacked layout. */ const showDirectionTabs = isRoot && isPatient; const [direction, setDirection] = useState("outbound"); const { patientState } = usePatientServiceContext(); const subjectIsMinor = isMinorPatient(patientState); const { createReferenceObject, transformToCodeableConcept } = useFhirServiceContext(); const { cachedForPatientId: relatedPersonsOwner, inviteRelatedPerson, updateRelatedPerson, fetchRelatedPerson, deleteRelatedPerson, transformToFhirRelatedPerson, transformFromFhirRelatedPerson, relatedPersonsList: rawRelatedPersonsList, fetchRelatedPersonsWithPolling, } = useRelatedPersonServiceContext(); // The cache outlives a change of patient: read only the patient on screen. const relatedPersonsGuard = patientCacheGuard(relatedPersonsOwner, patientState?.id); const relatedPersonsList = relatedPersonsGuard.list(rawRelatedPersonsList); const { putAuthority, getAuthorities } = useRelatedPersonAuthorityService(); const { loading, error, refused } = useFhirRelatedPersons(); const [selectedRelatedPerson, setSelectedRelatedPerson] = useState(null); const [showDisplaySheet, setShowDisplaySheet] = useState(false); // The person sheet shows their details, or what they may do. const [sheetView, setSheetView] = useState<"details" | "permissions" | "activity">("details"); // Beside the list, their activity opens over the page. const [activityOpen, setActivityOpen] = useState(false); // Remove duplicates from the list based on ID const uniqueRelatedPersonsList = relatedPersonsList.reduce( (acc: RelatedPerson[], current) => { const x = acc.find((item) => item.id === current.id); if (!x) { return acc.concat([current]); } else { return acc; } }, [], ); // From a tablet up the first person is open beside the list; on a phone only the one tapped. const wide = useWideFamilyLayout(); const shownPerson = showDirectionTabs && wide ? uniqueRelatedPersonsList.find((person) => person.id === selectedRelatedPerson?.id) ?? uniqueRelatedPersonsList[0] ?? null : selectedRelatedPerson; const { name: selectedName, proofingStatus: shownProofing, loading: shownIdentityLoading, failed: shownIdentityFailed, } = useRelatedPersonIdentity(shownPerson?.id); // Each row's tier comes from the patient's own grants; unread grants leave the rows without one. // The grants list is the owner's alone: a switched-in helper never reads it, and an answer that // lands after switching in is dropped. const ownerReadsGrants = mayManage && !actingAs; const [grants, setGrants] = useState(null); // Whether the list says what it knows of every link (a server that reports `complete`), so an absent link is unknown. const [grantsReportStates, setGrantsReportStates] = useState(false); const grantsRead = useRef(0); // `owned`: a read made by a reread with a deadline publishes only while that reread still admits it. const loadGrants = useCallback(async (owned?: { signal?: AbortSignal; admit: () => boolean }) => { const read = ++grantsRead.current; if (!ownerReadsGrants) { setGrants(null); return; } const publishes = () => read === grantsRead.current && (!owned || owned.admit()); try { const { grants: next, complete } = await getAuthorities(owned?.signal); if (publishes()) { setGrants(next ?? []); setGrantsReportStates(complete !== undefined); } } catch (err) { console.error("Failed to load care access grants:", err); if (publishes()) setGrants(null); } }, [getAuthorities, ownerReadsGrants]); useEffect(() => { void loadGrants(); }, [loadGrants, relatedPersonsList.length]); // An open invitation is answered in the other person's session. While one is shown, the owner's list and grants // are read again on return and on a bounded schedule, so the card moves on without a reload. For the patient's // own list only: the reread is scoped to the patient on screen, and its newest read wins. const waitingOnAnInvitation = ownerReadsGrants && !!patientState && relatedPersonsList.some((person) => inviteAnswerOf(person) === "pending"); const ownerScopeRef = useRef({ owner: ownerReadsGrants, patientId: patientState?.id }); ownerScopeRef.current = { owner: ownerReadsGrants, patientId: patientState?.id }; const familyLifetime = useLifetime(); useRereadWhileInvited(waitingOnAnInvitation, async (stillCurrent, signal) => { if (!patientState) return; // The owner, patient and viewer this reread is for. The list read publishes only as the newest for this // patient (the service's cache owner); the owner-only grants read is not even started for anyone else. const alive = familyLifetime(); const patientId = patientState.id; const sameOwner = () => stillCurrent() && alive() && ownerScopeRef.current.owner && ownerScopeRef.current.patientId === patientId; await fetchRelatedPersonsWithPolling(patientState, true, { signal, admit: sameOwner }); if (!sameOwner()) return; await loadGrants({ signal, admit: sameOwner }); }); // Only a grant in force is shown as what they may do; one still being decided or unreadable is not. const grantFor = (relatedPersonId?: string) => grants?.find((grant) => grant.relatedPersonId === relatedPersonId && isCurrentGrant(grant)); // The tier a row says: see `tierShownFor`. What this session's invite wrote never stands over the server's answer. const tierShownFor = (relatedPersonId?: string): AuthorityTier | undefined => tierShownForGrants(grants, grantsReportStates, relatedPersonId, relatedPersonId ? written[relatedPersonId]?.tier : undefined); // What this session's invites wrote. The list grows before the grant is written, so the first // grants read can miss it; the card and row show this until a read has it. const [written, setWritten] = useState>({}); const holdWritten = (relatedPersonId: string | undefined, tier: AuthorityTier, chosen?: InviteGrants) => { if (!relatedPersonId) return; setWritten((current) => ({ ...current, [relatedPersonId]: writtenAuthorityState(tier, chosen) })); void loadGrants(); }; const [showAddEditModal, setShowAddEditModal] = useState(false); const [editingRelatedPerson, setEditingRelatedPerson] = useState(null); const [submitting, setSubmitting] = useState(false); // Why the last save from this opening of the dialog was not made, said inside it. const [saveProblem, setSaveProblem] = useState(null); const [deleting, setDeleting] = useState(false); const [photo, setPhoto] = useState(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [loadingRelatedPerson, setLoadingRelatedPerson] = useState(false); // A person whose grant the guardian keeps cannot be edited or removed here; an editor or confirmation already // open when their record says so closes. const editingKept = Boolean(editingRelatedPerson?.id) && guardianKeepsGrant( relatedPersonsList.find((person) => person.id === editingRelatedPerson?.id) ?? editingRelatedPerson, ); useEffect(() => { if (!editingKept) return; setShowAddEditModal(false); setShowDeleteModal(false); setEditingRelatedPerson(null); }, [editingKept]); // An ended guardianship has no access left to end, so its details can still be edited but it offers no removal; a // confirmation already open when the grants say it ended closes. const editingEnded = Boolean(editingRelatedPerson?.id) && endedFor(grants, editingRelatedPerson?.id); useEffect(() => { if (editingEnded) setShowDeleteModal(false); }, [editingEnded]); // The link, then its access. Once the link exists, a retry re-sends only the access the server did not confirm // (a full resubmit would mint a duplicate link), and an access decision that ended here creates nothing again. // Each opening of the dialog is its own. Shared with RelatedPersonModal.tsx / EditRelatedPersonPage.tsx. const invite = useInviteAuthority(putAuthority); // A new opening of the dialog (or its close): no retry, decision or busy state of an earlier one carries over. const resetInviteForm = () => { invite.reset(); setSubmitting(false); setSaveProblem(null); }; // What the server said about each new link's email, kept by link so a retry of its access reports the same answer. // Only the opening and viewer that sent the create keep its answer: a retired one never overwrites a newer one's. const mailByLink = useRef(new Map()); useEffect(() => onViewerChange(() => mailByLink.current.clear()), []); const createRelatedPersonFor = useCallback((stillCurrent: () => boolean) => async (payload: Record) => { const { relatedPerson, mail } = await inviteRelatedPerson(payload); if (relatedPerson?.id && stillCurrent()) mailByLink.current.set(relatedPerson.id, mail); return relatedPerson; }, [inviteRelatedPerson]); // Another viewer, practice or acted-as link: the dialog is usable again, whatever an earlier viewer left running, // and says nothing about that viewer's save. useEffect(() => onViewerChange(() => { setSubmitting(false); setSaveProblem(null); // What this viewer's invites wrote is theirs to be shown, never another person's. setWritten({}); }), []); const isNew = !editingRelatedPerson; const methods = useForm({ defaultValues: { email: "", active: true, gender: "", birthDate: "", tier: "assist-only", allowMessaging: false, allowBillingView: false, allowBillingPay: false, name: [ { family: "", given: [""], prefix: ["none"], suffix: ["none"], use: "official", }, ], relationship: [], communication: [], identifier: [], address: [], telecom: [], }, }); const { handleSubmit, reset, setValue } = methods; // Improved form initialization function const initForm = (relatedPerson: RelatedPerson) => { const { photo: relatedPersonPhoto, formData } = transformFromFhirRelatedPerson(relatedPerson); // Ensure arrays have at least empty values to prevent validation errors const cleanedFormData = { ...formData, name: formData.name?.map((name: HumanName | undefined) => ({ ...name, prefix: [(name?.prefix?.filter((p: string) => p && p.trim() !== "")?.[0]) || "none"], suffix: [(name?.suffix?.filter((s: string) => s && s.trim() !== "")?.[0]) || "none"], given: name?.given?.filter((g: string) => g && g.trim() !== "") || [""], family: name?.family || "", use: name?.use || "official", })) || [ { family: "", given: [""], prefix: ["none"], suffix: ["none"], use: "official", }, ], }; // Reset the form with cleaned data reset(cleanedFormData); // Explicitly set individual fields that might be missing if (formData.birthDate) { setValue("birthDate", formData.birthDate); } if (formData.gender) { setValue("gender", formData.gender); } // Handle prefix and suffix specifically if (formData.name && formData.name[0]) { setValue("name.0.prefix.0", formData.name[0].prefix?.[0] || "none"); setValue("name.0.suffix.0", formData.name[0].suffix?.[0] || "none"); } setPhoto(relatedPersonPhoto); }; useFhirRelatedPersonBroadcast(); useFhirRelatedPersonTargetedBroadcast({ relatedPerson: selectedRelatedPerson || undefined, isOpen: showDisplaySheet, onUpdate: (updatedRelatedPerson) => { setSelectedRelatedPerson(updatedRelatedPerson); if (editingRelatedPerson?.id === updatedRelatedPerson.id) { setEditingRelatedPerson(updatedRelatedPerson); initForm(updatedRelatedPerson); } }, onDelete: () => { setShowDisplaySheet(false); setSelectedRelatedPerson(null); setEditingRelatedPerson(null); }, }); const viewRelatedPerson = (relatedPerson: RelatedPerson) => { setSelectedRelatedPerson(relatedPerson); setSheetView("details"); setShowDisplaySheet(true); }; // Improved edit click handler const handleEditClick = async ( event: React.MouseEvent, relatedPerson: RelatedPerson, ) => { event.stopPropagation(); if (guardianKeepsGrant(relatedPerson)) return; // First set the editing person and open modal setEditingRelatedPerson(relatedPerson); resetInviteForm(); setShowAddEditModal(true); setLoadingRelatedPerson(true); try { // Fetch the latest data const fetchedRelatedPerson = await fetchRelatedPerson( relatedPerson.id as string, ); // Initialize form with fetched data initForm(fetchedRelatedPerson); // Update the editing person with fetched data setEditingRelatedPerson(fetchedRelatedPerson); } catch (err) { console.error("Error loading related person:", err); toast.error("Failed to load related person details.", { position: "top-right", }); } finally { setLoadingRelatedPerson(false); } }; const handleAddClick = () => { setEditingRelatedPerson(null); resetInviteForm(); reset({ email: "", active: true, gender: "", birthDate: "", tier: "assist-only", allowMessaging: false, allowBillingView: false, allowBillingPay: false, name: [ { family: "", given: [""], prefix: ["none"], suffix: ["none"], use: "official", }, ], relationship: [], communication: [], identifier: [], address: [], telecom: [], }); setPhoto(undefined); setShowAddEditModal(true); }; const onSubmit = async (data: any) => { if (editingKept) return; if (!patientState) { toast.error("Patient information is missing. Please try again.", { position: "top-right", }); return; } // Everything this submit does later belongs to this opening of the dialog only. const current = invite.begin(); setSubmitting(true); setSaveProblem(null); try { // Extract all the fields from the form data const { email } = data; // Get name information const nameData = data.name && data.name[0] ? data.name[0] : {}; const firstname = nameData.given && nameData.given[0] ? nameData.given[0] : ""; const lastname = nameData.family || ""; const relationship = data.relationship || []; const tier: AuthorityTier = (data.tier as AuthorityTier) || "assist-only"; // The Separately grants chosen on the patient's invitation, written with the tier. const grants: InviteGrants | undefined = isPatient ? { allowMessaging: data.allowMessaging === true, allowBillingView: data.allowBillingView === true, allowBillingPay: data.allowBillingPay === true } : undefined; if (isNew) { // Validate required fields for creation if (!email || !email.trim()) { toast.error("Email is required.", { position: "top-right", }); setSubmitting(false); return; } if (!firstname || !firstname.trim()) { toast.error("First name is required.", { position: "top-right", }); setSubmitting(false); return; } if (!lastname || !lastname.trim()) { toast.error("Last name is required.", { position: "top-right", }); setSubmitting(false); return; } if (!relationship || relationship.length === 0) { toast.error("Relationship is required.", { position: "top-right", }); setSubmitting(false); return; } // Create the thin-link RelatedPerson (minimum capture - no demographics), then apply the // chosen F1 tier via the separate authority endpoint. const retrying = !!invite.pending; const step = await invite.submit( { email: email.trim(), firstname: firstname.trim(), lastname: lastname.trim(), tier, grants, relationship: transformToCodeableConcept(relationship) as any, patient: createReferenceObject( "Patient", patientState.id as string, formatFhirHumanName(patientState.name), ), ...(photo && { photo }), }, createRelatedPersonFor(current), current, ); // Closed, reopened or another viewer meanwhile: nothing here is about the dialog now on screen. if (step.kind === "gone") return; if (step.kind === "retry") { // The link exists; its access was not confirmed, so the same access is offered again. toast.error(step.message, { position: "top-right" }); setSaveProblem({ kind: "failed", message: step.message }); return; } if (step.kind === "review") { // The link exists; this access decision is over. Nothing more is sent from here, and the person's // card shows what is in force. Nothing this form chose is shown as written. toast.error(step.message, { position: "top-right" }); resetInviteForm(); setShowAddEditModal(false); void loadGrants(); return; } holdWritten(step.relatedPerson.id, tier, grants); const mail = step.relatedPerson.id ? mailByLink.current.get(step.relatedPerson.id) : undefined; const notice = invitationMailNotice(mail, retrying ? "them" : firstname.trim()); toast[notice.tone](`${notice.text} ${INVITATION_SENT_DETAIL}`, { position: "top-right" }); setShowAddEditModal(false); } else { // For update, we still need to transform to FHIR format const normalizedData = { ...data, name: data.name?.map((n: HumanName) => ({ ...n, prefix: (n.prefix || []).filter((p: string) => p && p !== "none"), suffix: (n.suffix || []).filter((s: string) => s && s !== "none"), })), }; await updateRelatedPerson({ id: editingRelatedPerson?.id, ...transformToFhirRelatedPerson(normalizedData, patientState, photo), }); if (!current()) return; toast.success(FAMILY_SAVED, { position: "top-right", }); setShowAddEditModal(false); // After successful update, refresh the list data without page reload setTimeout(() => { window.dispatchEvent(new CustomEvent("relatedPersonUpdated")); }, 100); } } catch (error: unknown) { if (!current()) return; console.error( `Error ${isNew ? "creating" : "updating"} related person:`, error, ); console.error("Failed to update contact:", error); toast.error(familySaveFailure(error, isNew), { position: "top-right" }); // The dialog stays open with what was entered, and says why here. setSaveProblem(familySaveProblem(error, isNew)); } finally { if (current()) setSubmitting(false); } }; const handleDeleteRelatedPerson = async () => { if (!editingRelatedPerson || editingKept || editingEnded) return; setDeleting(true); try { await deleteRelatedPerson(editingRelatedPerson); setShowDeleteModal(false); setShowAddEditModal(false); setEditingRelatedPerson(null); // After successful delete, refresh the list data without page reload toast.success("Related person Deleted successfully.", { position: "top-right", }); setTimeout(() => { window.dispatchEvent(new CustomEvent("relatedPersonDeleted")); }, 100); } catch (error) { console.error("Failed to delete related person:", error); toast.error("Failed to delete related person. Please try again.", { position: "top-right", }); } finally { setDeleting(false); } }; const handlePhotoChange = (newPhoto: Attachment | undefined) => { setPhoto(newPhoto); }; const renderContent = () => { // Who they are to me, what they may do, and whether their identity is verified. const personSub = (person: RelatedPerson, withTier: boolean) => { const relationship = person.relationship?.[0]?.text ? person.relationship[0].text.charAt(0).toUpperCase() + person.relationship[0].text.slice(1) : ""; const grant = grantFor(person.id); return [ relationship, withTier && grant ? TIER_LABELS[grant.tier] : "", person.id === shownPerson?.id && !shownIdentityLoading && shownProofing === "verified" ? "identity verified" : "", ].filter(Boolean).join(" · "); }; // Only ever the shown person's own answer: the hook says loading until it has read THIS id. const shownDisplayName = shownIdentityLoading || shownIdentityFailed ? "" : formatFhirHumanName(selectedName, { emptyText: "" }); // The phone sheet says the tier in the line; beside the list the choices below say it. const personHead = (person: RelatedPerson, withTier = !wide) => { const pending = person.id !== shownPerson?.id || shownIdentityLoading; const name = pending ? "Loading their name…" : shownIdentityFailed ? "Their name could not be loaded" : shownDisplayName || person.relationship?.[0]?.text || "Name unavailable"; const grant = grantFor(person.id); const photo = person.photo?.[0]; return ( } aside={mayManagePerson(person) ? ( ) : undefined} /> ); }; // Without the right to manage, the person's details are all there is to show. const readOnlyDetails = (person: RelatedPerson) => ( <> {guardianKeepsGrant(person) &&

{GUARDIAN_KEEPS_GRANT_MESSAGE}

} ); const activityView = (person: RelatedPerson) => (

Their activity on your record

{shownDisplayName || "This person"}

{person.id && }
); const closePerson = () => { setShowDisplaySheet(false); setSelectedRelatedPerson(null); }; const inboundList = (
{uniqueRelatedPersonsList.map((relatedPerson: RelatedPerson) => ( ))}
{showDirectionTabs && mayManage && ( )}
{showDirectionTabs && wide && shownPerson && (
{personHead(shownPerson)} {mayManagePerson(shownPerson) ? ( void loadGrants()} secondaryAction={ } /> ) : readOnlyDetails(shownPerson)} {mayManage && (
setActivityOpen(true)} />
)} Their activity on your record What they opened or changed, and when. {activityOpen && activityView(shownPerson)}
)}
); return ( <> {/* OUTBOUND - "Accounts I can access": the other patients this person holds a proxy grant over, who they can switch INTO. Gated on `isPatient`, the current account's own role, so it never renders for staff reading a patient's related persons; useMyProxies mirrors the same gate. */} {isPatient && (!showDirectionTabs || direction === "outbound") && (
{!showDirectionTabs && (

Accounts I can access

Related-person accounts you have permission to switch into.

)} {actingAs ? : }
)} {/* INBOUND - "people who can access my account". The pending-invitation surface lives in the app chrome (RelatedPersonInvitationNotice, on every screen); do not re-add it here, or one invitation gets two surfaces and two answers. */} {(!showDirectionTabs || direction === "inbound") && (<> {!showDirectionTabs && (

{isPatient ? "People who can access my account" : "People who can access this patient's account"}

)} {/* Staff on a minor's chart record an adult's in-person guardian declaration; the server decides who may. */} {!isPatient && subjectIsMinor && patientState?.id && (
{ void fetchRelatedPersonsWithPolling(patientState, true); }} />
)} {isPatient && !mayManage && (

{describeProxyCannotManageRelatedPersons(actingAs?.subjectName)}

)} {loading ? (
{[...Array(3)].map((_, i) => ( ))}
) : error && refused ? ( // Refused, so neither empty nor worth retrying: access to this list is the server's to decide.

{isPatient ? "You don't have access to see who can access this account." : "You don't have access to see who can access this patient's account."}

) : error ? (

Could not load relative persons

Try again later.

) : !loading && uniqueRelatedPersonsList.length === 0 ? ( /* The shared empty state, branched on audience: this list has the patient's own mount and the chart a clinician opens. Guarded by `!loading`, so it cannot speak for an unanswered list. */ ) : undefined} data-testid="related-persons-empty" /> ) : inboundList})} {/* A person on a phone: who they are, then what can be done about them, over the list. */} {showDirectionTabs && ( {selectedRelatedPerson && mayManage && sheetView === "activity" ? ( <> Their activity on your record What they opened or changed, and when. {activityView(selectedRelatedPerson)} ) : selectedRelatedPerson && mayManagePerson(selectedRelatedPerson) && sheetView === "permissions" ? ( <> Update permissions What this person may do on your behalf. { void loadGrants(); setSheetView("details"); }} /> ) : selectedRelatedPerson ? ( <> {shownDisplayName || "This person"} What you can do about their access. {personHead(selectedRelatedPerson)}
{mayManage ? ( setSheetView("permissions")} onShowActivity={() => setSheetView("activity")} onRevoked={closePerson} /> ) : readOnlyDetails(selectedRelatedPerson)}
) : null}
)} {/* Sheet component for displaying Related Person details */} {!showDirectionTabs && ( {mayManagePerson(selectedRelatedPerson) && selectedRelatedPerson && sheetView === "permissions" ? ( <> {/* Radix needs a title; the section below names the person visibly. */} Update permissions What this person may do on your behalf.
{ void loadGrants(); setSheetView("details"); }} />
) : ( <> {isPatient ? "Their details" : "Related Person Details"} Who this person is and what they may do. {/* The details are the body, not the header's description: a description is drawn small and muted. */}
{selectedRelatedPerson && ( )} {mayManage && selectedRelatedPerson && (
setSheetView("permissions")} onRevoked={() => { setShowDisplaySheet(false); setSelectedRelatedPerson(null); }} />
)}
)}
)} {/* Add/Edit Modal */} { setShowAddEditModal(open); if (!open) resetInviteForm(); }} > {editingRelatedPerson ? (isPatient ? "Edit their details" : "Edit Related Person") : (isPatient ? "Invite someone" : "New Related Person")} {isPatient && isNew && ( They are asked to accept before anything is granted. )} {loadingRelatedPerson ? (
) : (
{saveProblem && (

{saveProblem.message}

)}
{editingRelatedPerson && !editingEnded && ( )} {/* The patient's invite has no Cancel: the dialog's X already closes it. */} {!(isPatient && isNew) && ( )}
)} {/* Delete Confirmation Dialog */} {isPatient ? "Remove this person" : "Delete Related Person"} {isPatient ? "They will no longer be listed on your record, and any access you gave them ends. This cannot be undone." : "Are you sure you want to delete this related person? This action cannot be undone."}
); }; if (isRoot) { return (
{/* The deck goes straight from the title to the tabs; the line under it stays by Spencer's 2026-09-05 ruling ("go ahead and add them back in for now"). */}

Family & caregivers

People connected to your care and emergency support.

{/* Two directions that must never blur, so two tabs rather than two stacked sections. Patient mount only: on the staff chart there is one direction, and a lone tab is worse than a stack. */} {showDirectionTabs && ( setDirection(value as RelatedDirection)}> Accounts I can access Who can access me )}
{renderContent()}
); } return
{renderContent()}
; }; export default RelatedPersonsPage;