import { useEffect, useRef, useState } from "react"; import { Check, X } from "lucide-react"; import { Card } from "../../../components/ui/card"; import { Button } from "../../../components/ui/button"; import type { CollaborationTeam, TeamMembership, MemberActionType, ReferralAuthorizationRequest, ShownIdentity, } from "../../../services"; import { buildReferralAuthorization, cameFromOutsideInvite, decisionKeyOf, shownIdentityOf } from "../../../services"; import { isSaveRetired, isSaveTimedOut, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope } from "../../../utils/saveOperation"; import type { SaveTransport } from "../../../utils/saveOperation"; import { useRowOwnedSaves } from "../../../utils/rowOwnedSave"; import { describeServerRefusal } from "../../../utils/serverRefusalMessage"; import { MemberIdentity, usePractitionerIdentity } from "./MemberIdentity"; import { MemberAffiliation } from "./MemberAffiliation"; import { ReferralAuthorizationDialog } from "./ReferralAuthorizationDialog"; type TeamWithMembers = CollaborationTeam & { members: TeamMembership[] }; // An outside invitation verified a registry NPI; the account that will receive access is a separate fact, so both show. export const RegistryIdentity = ({ member }: { member: TeamMembership }) => member.nppesName || member.inviteNpi ? (

NPI registry: {member.nppesName ?? "name not on file"} {/* Joined so the number never splits from its label. */} {member.inviteNpi ? ` (NPI ${member.inviteNpi})` : ""}

) : null; // The patient is deciding whether to admit somebody another person put forward, so the row says who // asked. A name the server already sent for that member wins; the lookup covers the patient's own org. const InvitedBy = ({ invitedByRef, team }: { invitedByRef?: string; team: TeamWithMembers }) => { const [kind, id] = (invitedByRef ?? "").split("/"); const supplied = invitedByRef ? team.members.find((member) => member.memberRef === invitedByRef)?.displayName : undefined; const { name } = usePractitionerIdentity(kind === "Practitioner" ? id : undefined, supplied); return (

Invited by {kind === "Practitioner" && name ? name : "a member of this team"}

); }; interface PendingApprovalsSectionProps { team: TeamWithMembers; memberAction: ( teamId: string, memberRef: string, action: MemberActionType, authorization?: ReferralAuthorizationRequest, shownIdentity?: ShownIdentity, transport?: SaveTransport, ) => Promise; onChanged: () => Promise; /** False while the list behind this section is being re-read or could not be: no decision is offered on it. */ decisionsOpen?: boolean; } // A member lands in pending-patient-approval when a PRACTITIONER invites someone on the owning // patient's behalf (see CollaborationTeamService.tsx's server-approval note) - the patient must // approve or deny before the invite becomes real. Collapses to nothing when there's nothing // pending, mirroring the "N pending" badge already on the Task-8 list card. export const PendingApprovalsSection = ({ team, memberAction, onChanged, decisionsOpen = true }: PendingApprovalsSectionProps) => { const pending = team.members.filter((member) => member.state === "pending-patient-approval"); // Every decision belongs to the member as shown: its key names the row revision and the identity on screen. const keyOf = (member: TeamMembership) => decisionKeyOf(team.teamId, member); const liveKeys = new Set(pending.map(keyOf)); const liveKeysRef = useRef(liveKeys); liveKeysRef.current = liveKeys; const [busyKey, setBusyKey] = useState(null); const [error, setError] = useState<{ message: string; reread: boolean } | null>(null); // Decisions this screen sent: their buttons stay down until the list no longer shows that row as it was. const [decided, setDecided] = useState>(() => new Set()); // The practitioner whose approval is waiting on the patient's agreement. S1c-c: the // server will not mint the referral Consent without a recorded authorization, so approving a // PRACTITIONER goes through the dialog first. A related person needs none - no referral grant is // ever minted for one - and denying needs none either, since refusing grants nothing. const [authorizingKey, setAuthorizingKey] = useState(null); const authorizing = authorizingKey ? pending.find((member) => keyOf(member) === authorizingKey) ?? null : null; const scope = useSaveScope(`${team.patientId}|${team.teamId}`, () => { setAuthorizingKey(null); setBusyKey(null); }); // Each decision is owned to its row as shown: a refusal, timeout or success for a row that changed or left is not // shown, and nothing is sent while the list is being read again. const rowSaves = useRowOwnedSaves(scope, liveKeys, decisionsOpen, (key) => { setBusyKey((current) => (current === key ? null : current)); }); // - resolve the SUBJECT of the agreement here, so the dialog is told whether it actually // has a name. The list row below may fall back to the raw reference; the consent may not. // A related person is never the subject of this dialog (no referral Consent is minted for one), so // only the practitioner id is resolved - an undefined id yields "unidentified", which is correct. // The server names this member for us when it can (, server half). Falling back to the // client lookup keeps this working against an api that predates that change - and the fallback is // the case that FAILS for an external practitioner, which is exactly why the server half exists. const authorizingIdentity = usePractitionerIdentity( authorizing?.memberClass === "practitioner" && !authorizing.displayName ? authorizing.memberRef.split("/")[1] : undefined, authorizing?.displayName, ); // An outside identity is decided on one named answer: the name shown in the dialog is the name sent with it. const outsideDecision = authorizing ? cameFromOutsideInvite(authorizing) : false; const subjectIdentified = outsideDecision ? authorizingIdentity.status === "named" && Boolean(authorizingIdentity.name) : authorizingIdentity.status !== "unidentified"; // A preview or assent whose row changed, left or was replaced is retired with it, including by an empty list. useEffect(() => { if (authorizingKey && !authorizing) setAuthorizingKey(null); }, [authorizingKey, authorizing]); // An agreement is given only on a confirmed list: one open while it is read again is closed, not kept for after. useEffect(() => { if (!decisionsOpen) setAuthorizingKey(null); }, [decisionsOpen]); const liveKeyList = [...liveKeys].join("\n"); useEffect(() => { setDecided((prev) => { const kept = [...prev].filter((key) => liveKeysRef.current.has(key)); return kept.length === prev.size ? prev : new Set(kept); }); }, [liveKeyList]); if (pending.length === 0) return null; const respond = async ( member: TeamMembership, action: "approve" | "deny", authorization?: ReferralAuthorizationRequest, shownName?: string | null, ) => { const key = keyOf(member); const operation = rowSaves.start(key); if (!operation) return; const transport: SaveTransport = operation.transport; const shown = action === "approve" && cameFromOutsideInvite(member) ? shownIdentityOf(member, shownName) : undefined; setBusyKey(key); setError(null); try { await operation.step(() => memberAction(team.teamId, member.memberRef, action, authorization, shown, transport), true); } catch (failure) { if (!operation.belongs()) return; setBusyKey(null); // Not sent: the list is being read again, and it decides what is offered next. if (isSaveRetired(failure)) return; const code = (failure as { body?: { code?: string } } | null)?.body?.code; // What was shown no longer stands: the preview goes, and the list is read again rather than confirmed again. const changed = isSaveTimedOut(failure) || code === "IDENTITY_CHANGED" || code === "IDENTITY_UNRESOLVED" || code === "MEMBERSHIP_CHANGED"; if (changed) setAuthorizingKey(null); setError({ message: isSaveTimedOut(failure) ? TIMED_OUT_MAY_HAVE_SAVED : describeServerRefusal(failure, `Could not ${action} this invite`), reread: changed, }); return; } if (!operation.belongs()) return; setDecided((prev) => new Set(prev).add(key)); setAuthorizingKey(null); setBusyKey(null); await onChanged(); }; // WHO GETS ASKED TO AGREE, AND WHO ALREADY DID. // // A related person needs no agreement because no referral Consent is ever minted for one. // // A practitioner whose organization already holds this patient's care needs none either: // Spencer ruled on 2026-09-06 that the three agreements are with the ORGANIZATION, so inviting // a same-organization colleague asks for no new one and the build asking was a regression. // Walked 2026-09-05, a colleague from the patient's own practice raised the identical consent - // same three bullets, same tick - as a stranger from another organization. Asking twice teaches // the patient that the authorization they already gave does not mean what it says. // // THE SERVER SAYS WHICH, AND ABSENT MEANS ASK. `requiresPatientAgreement` arrives only as // `false`, so the test is for that value and not for falsiness: undefined must reach the dialog. const onApprove = (member: TeamMembership) => { // An outside identity is always confirmed in the dialog, where the name shown is the name sent. if (member.memberClass === "related-person" || (member.requiresPatientAgreement === false && !cameFromOutsideInvite(member))) { void respond(member, "approve"); return; } setAuthorizingKey(keyOf(member)); }; return ( {/* THE DECK'S WORDS, AND THEY SAY WHOSE MOVE IT IS. "Pending your approval" reports the state the row is in; "Waiting for you" says who has to do something about it, which is the only reason this section is at the top of the page. The deck uses it as this section's heading on both the team page and the invite sheet, so the two cannot disagree about who is where. THE `invited` STATE LABEL IS DELIBERATELY UNTOUCHED. It reads "Invited" in the deck too - the invitation is waiting on THEM, not on the patient reading the row - so moving "Waiting for you" onto it would have told a patient to act on something that is not theirs to act on. */}

Waiting for you

{error && (

{error.message}

{error.reread && ( )}
)}
{pending.map((member) => (
{/* THE ROW WHERE THE PRACTICE MATTERS MOST. This is a practitioner somebody else put forward, whom the patient is being asked to admit to their record - and until now the only thing on the row was a name. Which practice they are from is most of what the answer turns on. */}
{/* No account yet, so no Approve; in the flexible identity column this keeps Deny reachable. */} {member.memberClass === "external-practitioner" && (

You can approve them once they have created their Akashic account.

)}
{/* THE ONLY TWO BUTTONS ON THE PAGE THAT DECIDE SOMETHING, and neither was painted: the portal's button reset zeroes a variant at id specificity, so admitting somebody to your record and refusing them were the same grey word. The deck's classes say which is which - green commits, red-ghost declines - and that is the button standard rather than a drawing's preference. */}
{!decisionsOpen || decided.has(keyOf(member)) ? (

{decisionsOpen ? "Sent. Updating the list." : "Checking this list for changes."}

) : ( <> {member.memberClass !== "external-practitioner" && ( )} )}
))}
{outsideDecision ? ( {authorizingIdentity.name ?? "Checking who this is"} ) : ( )} ) : null } subjectIdentified={subjectIdentified} busy={busyKey !== null && busyKey === authorizingKey} onAgree={() => { if (authorizing && decisionsOpen) void respond(authorizing, "approve", buildReferralAuthorization(), authorizingIdentity.name); }} onCancel={() => setAuthorizingKey(null)} />
); }; export default PendingApprovalsSection;