import { useState, type ReactNode } from "react"; import { Card } from "../../../components/ui/card"; import { Button } from "../../../components/ui/button"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, } from "../../../components/ui/alert-dialog"; import { STATE_LABELS } from "../../../services"; import type { CollaborationTeam, RemovedTeamMember, TeamMembership, MemberActionType } from "../../../services"; import { formatDate } from "../../../utils/dateUtils"; import { MemberIdentity } from "./MemberIdentity"; import { MemberAffiliation } from "./MemberAffiliation"; type TeamWithMembers = CollaborationTeam & { members: TeamMembership[] }; interface TeamMembersSectionProps { team: TeamWithMembers; memberAction: (teamId: string, memberRef: string, action: MemberActionType) => Promise; onChanged: () => Promise; /** * THE CONTROL THAT ACTS ON THIS LIST, RENDERED AT THE END OF IT. * * Invite lived in the page header, which is where a control goes when nobody has asked where it * BELONGS. The deck puts it at the bottom right of the members section, and the rule underneath * that is the one the referrals rail already states: a control belongs where the thing it acts on * is. A patient thinking "who else should be here" is looking at the list of who is here. * * A SLOT RATHER THAN THE DIALOG ITSELF, because the dialog needs five services this section has * no business knowing about - and a section that reached for them would be a members list that * can also invite, which is two jobs. */ footer?: ReactNode; /** The signed-in viewer's own references, so a removal they made reads "by you". */ viewerRefs?: string[]; } // Who removed a member, in the reader's terms; nothing when the server did not record it. const removedBy = (removal: RemovedTeamMember, patientId: string, viewerRefs: string[]): string => { if (!removal.removedByRef) return ""; if (viewerRefs.includes(removal.removedByRef)) return " by you"; if (removal.removedByRef === `Patient/${patientId}`) return " by the patient"; // A team member may take back an outside invitation they or a colleague sent. if (removal.removedByRef.startsWith("Practitioner/")) return " by a team member"; return " by someone acting for the patient"; }; // Shows every non-pending member of the team (invited/active). Members awaiting the owning // patient's own approval live in PendingApprovalsSection instead, so a member row never appears in // both sections at once. The owning patient can remove any member here; the server enforces // whether that's actually valid for the member's current state. // // REMOVAL ASKS FIRST. Remove used to fire on the single press that reached it: one click and the // person was off the team, with nothing said before and nothing offered after. This is the screen // where a patient decides who may read their record, the rows sit inches apart, and a member's // name may still be resolving when the pointer arrives - so the press that lands is not reliably // the press that was meant. The confirm is the settled convention for a destructive action, and // it earns its place here twice over: it also states WHO is about to be removed, resolved through // the same identity the row uses, which is the only thing that makes two similar rows tellable // apart at the moment it matters. // // Mirrors TeamLifecycleSection: the destructive action is a plain Button inside the dialog rather // than AlertDialogAction, so a failed removal does not slam the dialog shut on its way to the // error line. export const TeamMembersSection = ({ team, memberAction, onChanged, footer, viewerRefs = [] }: TeamMembersSectionProps) => { const members = team.members.filter((member) => member.state !== "pending-patient-approval"); const removed = team.removedMembers ?? []; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // The member the patient has asked to remove and not yet confirmed. Holding the membership // itself rather than a boolean is what lets the dialog name the person it is asking about. const [pendingRemoval, setPendingRemoval] = useState(null); const handleRemove = async (member: TeamMembership) => { setBusy(true); setError(null); try { await memberAction(team.teamId, member.memberRef, "remove"); await onChanged(); setPendingRemoval(null); } catch { setError("Failed to remove this member. Please try again."); setPendingRemoval(null); } finally { setBusy(false); } }; return ( {/* THE DECK WRITES A SECTION HEADING AS A LABEL, not as a title: 11px, uppercase, letter- spaced, in the muted ink. That is what keeps the NAMES the biggest thing in the section, which is the whole point of a list somebody scans for a person. The deck's own colour here is --faint and this takes the muted step instead. --faint is 3.06:1 on --paper and our token file says in as many words that it is for large text and borders only; an 11px label is neither. Everything else is the deck's, verbatim. */}

Members

{error &&

{error}

} {members.length === 0 ? (

{removed.length > 0 ? "No current members." : "No members on this team yet."}

) : (
{members.map((member) => (
{/* WHERE THEY ARE FROM, directly under the name. A team crossing three practices was three sets of identical-looking rows, and this is the row somebody reads before pressing Remove. */} {member.role &&

{member.role}

}
{/* THE STATE IS A WORD AT THE END OF THE ROW, not a bordered token on a line of its own. It was an outline Badge under the affiliation, which gave a three-line row where the deck has two and put a boxed word directly beneath the practice - so a member's PRACTICE and their MEMBERSHIP STATE, two unrelated facts, stacked into what reads as one block of detail about them. The deck's row has two ends: who they are on the left, what is true of them on the right, in the same slot its invite sheet puts an Invite control in. Every row on this screen then has its answer in the same place. */}
{member.memberClass === "related-person" ? "Added" : STATE_LABELS[member.state]} {/* QUIET AT REST, AND THE RED LIVES ON THE CONFIRM. This was built as Deny's twin - same slot, same red ghost - because refusing somebody the record and taking it back are the same act at two moments. That is true of the PAIR and it does not survive the COUNT, which is the half nobody asked until the screen was looked at: Deny appears ONCE, on a pending row, and Remove appears on EVERY row. Eight red-bordered buttons ran down the right edge of a page whose job is to say who is on your team, and they were the loudest thing on it. THE PORTAL HAS NEVER DRAWN A REPEATED DESTRUCTIVE CONTROL, counted across the patient-portal decks: one red button per frame and never two. Family and caregivers is the closest analogue there is - a list of people the patient may remove - and it reaches removal from a frame of its own rather than from a column of buttons. AND THE SLOT'S OWN RULE SAYS IT: the deck styles this position `font-size:11px; color:var(--faint)`. It built the slot QUIET. Approve and Deny override that because their row is asking for a decision; the steady state is a word. So Remove keeps the slot and loses the paint, and the red is on the confirm's destructive half - which is where the decision is actually taken, and leaves the button standard exactly as it was: the colour is still the purpose, applied where the purpose is exercised. */}
))}
)} {removed.length > 0 && (

Removed

{removed.map((removal) => (

{`Removed ${formatDate(removal.removedAt)}${removedBy(removal, team.patientId, viewerRefs)}`}

))}
)} {footer &&
{footer}
} { if (!open && !busy) setPendingRemoval(null); }} > Remove this member from the team?
{pendingRemoval && (
{/* THE CONFIRM NAMES THE PRACTICE TOO, and this is the place it earns its keep most: the whole reason the dialog states who it is asking about is that two similar rows sit inches apart, and for two colleagues with similar names the practice is the only thing that separates them. */}
)}

They leave “{team.name}” immediately and lose its shared conversation and the record access this team granted.{" "} {pendingRemoval?.memberClass === "related-person" ? "You can add them back at any time from Invite someone." : pendingRemoval?.memberClass === "external-practitioner" // An empty practice list is the server saying they belong to no practice: they // came in by email and NPI, and that is the way back. || (pendingRemoval?.memberClass === "practitioner" && pendingRemoval.organizationDisplays?.length === 0) ? "You can invite them again from Invite someone with their email and NPI, and they will need to accept." // A named practice says nothing about which; the appointment route is any practice's. // Practices that could not be read promise no route at all. : pendingRemoval?.organizationDisplays?.length ? "You can invite them again if you have a past or upcoming appointment with them. Otherwise, another team member can invite them, and you approve it." : "Bringing them back takes a new invitation, which they will need to accept."}

Keep them on the team {/* THE CONFIRM'S OWN BUTTON HAD TO BE PAINTED TOO, and it is the same trap the new-team button was: a variant does not survive the portal's button reset, which zeroes background, border and colour at id specificity. So the destructive action stood as bare text next to a filled amber Keep - the emphasis exactly backwards from what the two buttons do. */}
); }; export default TeamMembersSection;