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" ? (
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.
Guardianship ended
) : joined === false ? (
// A new-account link is active from creation; only their first sign-in says they joined.
Invited, not joined yet
) : !tier ? (
Active
) : !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 (
);
};
// 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.
{/* 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 (
);
};
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) &&
);
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 && (
) : 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 && (
)}
{/* 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 permissionsWhat this person may do on your behalf.
>
) : (
<>
{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. */}
{/* 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 accessWho can access me
)}