import { useCallback, useEffect, useRef, useState } from "react"; import { AppSpinner } from "../../../components"; import { useReminderClearance } from "../../../components/Chat/useReminderClearance"; import { Card } from "../../../components/ui/card"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, } from "../../../components/ui/alert-dialog"; import { useAuthServiceContext, useCollaborationTeamServiceContext, STATUS_LABELS } from "../../../services"; import type { CollaborationTeam, TeamMembership } from "../../../services"; import { isTeamDormant, teamDisplayStatus } from "../../../utils/collaborationTeamDormancy"; import { useCollabTeamSummaries } from "../../../hooks/useCollabTeamSummaries"; import { TeamMessagePreview } from "../../../components/TeamMessagePreview/TeamMessagePreview"; import { StartCaseConferenceButton } from "../../../components/video/StartCaseConferenceButton"; import { useVideoServiceContext } from "../../../services/VideoService"; import { useCapabilityServiceContext } from "../../../services/CapabilityService"; import { isRefusedRead } from "../../../utils/fetchFailureMessage"; import { useRereadOnJoinRecovery } from "../../../hooks/useRereadOnJoinRecovery"; import { useTeamParticipationRecheck } from "../../../hooks/useTeamParticipationRecheck"; import { useOptionalCommunicationServiceContext } from "../../../services/CommunicationService"; import { InvitePractitionerButton } from "./InvitePractitionerButton"; import { AttestPendingApprovals } from "./AttestPendingApprovals"; import { PendingExternalInvites } from "../CareTeamsPage/PendingExternalInvites"; import { activeClinicianCount, clinicianCountLabel, joinedLabel } from "./teamCardFacts"; import { InboxAvatar } from "../MessagesPage/InboxRow"; import { SANDBOX_TEAMS_UNAVAILABLE, useCollaborationAvailability } from "../../../utils/collaborationAvailability"; import "./StaffCareTeamsPage.css"; import { TeamAppointmentsDisclosure } from "../../../components/CollaborationTeam/TeamAppointmentsSection"; type TeamWithMembers = CollaborationTeam & { members: TeamMembership[] }; const ROLE_DENIAL = "Your role does not include collaboration teams."; // Practitioner-facing surface for the C1a collaboration-team epic (Task 10). Once Task 1 (server) // ships, listTeams() returns the FULL roster for teams the caller is an active member of, so // team.members can hold multiple practitioner rows. getMyMembership() must match the caller's own // reference to resolve their membership row for bucketing (invited/pending/active), not some other // practitioner's row. export const StaffCareTeamsPage = () => { const frameRef = useRef(null); const reminderRoomRef = useRef(null); const reminderRoom = useReminderClearance(reminderRoomRef, frameRef, { endsPage: true }); const { currentUser, wpOrganization, joinRecovery } = useAuthServiceContext(); const { listTeams, memberAction, attestMemberAuthorization, resendExternalInvite, withdrawExternalInvite } = useCollaborationTeamServiceContext(); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyTeamId, setBusyTeamId] = useState(null); const [actionError, setActionError] = useState(null); const [leaveTarget, setLeaveTarget] = useState(null); const [leaving, setLeaving] = useState(false); // A role without collaboration teams (a receptionist who types the address) is told so rather than shown // a load failure: the door is shut on purpose. Asked of the capabilities first, so the read is not even // made, and a refused read answers the same way. const { mayCall } = useCapabilityServiceContext(); const notPermitted = mayCall("GET", "/collaboration-team") === false; // In the sandbox the server answers every team read with an empty list, so the page says the feature // is unavailable there instead of reading it, and offers nothing to act on. const availability = useCollaborationAvailability(); const unavailable = availability === "unavailable"; // Until the tenant is known, no teams is not yet "no teams": it may be the sandbox's empty answer. const awaitingTenant = availability === "checking" && !teams.length; // Only the newest read answers: an older one finishing late never restores what a newer one replaced. const readSeq = useRef(0); const load = useCallback(async () => { const mine = ++readSeq.current; setLoading(true); setError(null); if (unavailable) { setTeams([]); setLoading(false); return; } if (notPermitted) { setError(ROLE_DENIAL); setLoading(false); return; } try { const result = await listTeams(); if (mine !== readSeq.current) return; setTeams(result); } catch (failure) { if (mine !== readSeq.current) return; setError(isRefusedRead(failure) ? ROLE_DENIAL : "Failed to load your collaboration teams"); } finally { if (mine === readSeq.current) setLoading(false); } }, [listTeams, notPermitted, unavailable]); useEffect(() => { load(); }, [load]); // A new member opened before collaboration-team admitted them is refused here; once Check again confirms // their access, the list is read again. useRereadOnJoinRecovery(joinRecovery, load); // A call on one of these teams is when its roster moves (an approval, an acceptance, someone // joining), and this page loads once. Reload when the viewer's call ends, so the card shows who is // on the team now rather than who was on it when the page opened. const { call } = useVideoServiceContext(); const wasInCall = useRef(false); useEffect(() => { const inCall = call.phase !== "idle"; if (wasInCall.current && !inCall) load(); wasInCall.current = inCall; }, [call.phase, load]); const currentPractitionerRef = currentUser?.fhir?.id ? `Practitioner/${currentUser.fhir.id}` : undefined; const getMyMembership = (team: TeamWithMembers): TeamMembership | undefined => team.members.find((member) => member.memberRef === currentPractitionerRef) ?? team.members.find((member) => member.memberClass === "practitioner") ?? team.members[0]; // A removal tells this page nothing, so it reads again while seen; only the team's own thread can say the viewer // is no longer on it. const readThread = useOptionalCommunicationServiceContext()?.fetchCommunicationByThread; const participationNotes = useTeamParticipationRecheck({ enabled: Boolean(readThread) && !loading && !error && !unavailable && !notPermitted, teams, setTeams, membershipState: (team) => getMyMembership(team)?.state, listTeams, readThread: (threadId, request) => (readThread ? readThread(threadId, request) : Promise.reject(new Error("unavailable"))), }); const handleAccept = async (team: TeamWithMembers, myRef: string) => { setBusyTeamId(team.teamId); setActionError(null); try { await memberAction(team.teamId, myRef, "accept"); await load(); } catch { setActionError("Failed to accept the invite. Please try again."); } finally { setBusyTeamId(null); } }; const handleDecline = async (team: TeamWithMembers, myRef: string) => { setBusyTeamId(team.teamId); setActionError(null); try { await memberAction(team.teamId, myRef, "decline"); await load(); } catch { setActionError("Failed to decline the invite. Please try again."); } finally { setBusyTeamId(null); } }; const openLeaveConfirm = (team: TeamWithMembers) => { setActionError(null); setLeaveTarget(team); }; const handleLeave = async () => { if (!leaveTarget) return; const myMembership = getMyMembership(leaveTarget); if (!myMembership) return; setLeaving(true); setActionError(null); try { await memberAction(leaveTarget.teamId, myMembership.memberRef, "leave"); setLeaveTarget(null); await load(); } catch { setActionError("Failed to leave this team. Please try again."); } finally { setLeaving(false); } }; // A dissolved team keeps each membership as it was, so an invite or a proposal on one is dead: there is // nothing to accept and no patient left to approve. Never joined, it is not listed at all. const invitedTeams = teams.filter( (team) => team.status !== "dissolved" && getMyMembership(team)?.state === "invited", ); const pendingApprovalTeams = teams.filter( (team) => team.status !== "dissolved" && getMyMembership(team)?.state === "pending-patient-approval", ); const activeTeams = teams.filter((team) => getMyMembership(team)?.state === "active"); const now = Date.now(); // THE TEAM'S OWN STATUS DECIDES THE GROUP, NOT ONLY THE CALLER'S MEMBERSHIP. Dissolving a team leaves every // membership active, so grouping on membership alone listed a dissolved team under Active. const closedTeams = activeTeams.filter((team) => team.status === "dissolved"); const openTeams = activeTeams.filter((team) => team.status !== "dissolved"); const liveActiveTeams = openTeams.filter((team) => !isTeamDormant(team, now)); const dormantActiveTeams = openTeams.filter((team) => isTeamDormant(team, now)); // PRACTITIONER - public + aside; the hook combines unread/preview across both threads. const summaries = useCollabTeamSummaries(activeTeams, { includeAside: true }); // The running practice is named only when it is this one; another practice's name is not on hand. const practiceNameFor = (team: TeamWithMembers): string | undefined => team.createdByOrganizationId && team.createdByOrganizationId === wpOrganization?.current?.id ? wpOrganization?.current?.name : undefined; // "Great River Health · " ahead of the count; nothing when the practice cannot be named. const rosterPrefix = (team: TeamWithMembers): string | null => { const name = practiceNameFor(team)?.trim(); return name ? `${name} · ` : null; }; const joinedTag = (team: TeamWithMembers) => { const label = joinedLabel(team.members.find((member) => member.memberRef === currentPractitionerRef)); return label ? {label} : null; }; const renderActiveTeamCard = (team: TeamWithMembers) => { const note = participationNotes.get(team.teamId); const participating = note !== "not-a-participant"; return ( // S1c-c-2: on a team this practitioner is ACTIVE on, another member may still be // waiting on the patient's authorization. If that patient is in the office, this is where it // gets recorded - otherwise the referral stalls behind a portal visit the patient may never make.
{/* `flex-row` is doing real work here and must not be tidied away as redundant with `flex`. The Card primitive's base is `flex flex-col`; `flex` sets display and `flex-col` sets direction, so they never conflict and tailwind-merge keeps both. Passing `flex items-center` alone therefore left this card a COLUMN that centred its two children: the text block rendered 184px wide floating 462px from the left edge, and the actions sat beneath it, 317px short of the right. The `min-w-0 flex-1` below and the `shrink-0` on the action group are a row's vocabulary and had nothing to act on. */}

{team.name} {participating ? ( {STATUS_LABELS[teamDisplayStatus(team, now)]} ) : ( Not a participant )}

{note === "unconfirmed" && (

Couldn't confirm you are still a participant just now.

)} {/* WHOSE CARE THIS TEAM IS FOR. A collaboration team exists FOR A PATIENT, and a clinician sitting in two of them had nothing on screen telling the rows apart. THE MESSAGES PANEL WAS FIXED FIRST AND THIS ONE WAS MISSED — found in a browser on 2026-08-23, with the same team rendering "Care of Patient One" one tab away and nothing here. The card says "no collaboration SURFACE names the patient", and there are two. Rendered only when the server sent it: it arrives solely for teams the caller is an ACTIVE member of, so an absent name is a real state rather than a loading one. */} {team.patientDisplay && (

Care of {team.patientDisplay}

)} {/* Not truncated: at 390 that cut the count to "2 clini…". The count keeps its words together and drops to its own line when the practice name fills the row. */}

{rosterPrefix(team)} {clinicianCountLabel(activeClinicianCount(team.members))}

{joinedTag(team)} {/* Card 079 T13: a case conference is the team's practitioners conferring ABOUT the patient, with the patient excluded (card 098 does that server-side). Behind the same guard as Leave, and only inside an ACTIVE-membership card, so this mirrors the server's own gate rather than training staff to click something that 403s. */} {/* The action row is `items-stretch`, not `items-center`, so the three actions share one height. Measured on the mount at 1400x950: Start case conference and Invite a practitioner both came out 34px because each carries a 16px lucide glyph, while Leave — the only one with no icon — collapsed to its text height of 31px (8px padding + 13px line-height + 8px padding + 2px border). Three buttons on one row at two heights, 3px apart, their top and bottom edges disagreeing either side of the centre line. Not fixed with a height: the staff mount's 24px pointer floor (WCAG 2.5.8, section 3A-TOUCH) is already satisfied by all three, so this is alignment and not a target size, and pinning a number here would go stale the next time the glyph size moves. Stretch derives the height from the tallest sibling instead. */} {team.status === "active" && participating && (
{/* Leaving a patient's team is an exit act, and the ruling puts those on blue-ghost. The shared ghost variant drew it as bare text with no border or fill at all. */}
)}
{team.status === "active" && participating && ( )} {/* Invitations this team emailed to someone outside: an active member may resend or take one back. */} {team.status === "active" && participating && ( void load()} /> )} {/* The visits this clinician has been asked to join with this team, from their own invitations only: being on the team, or staff, never shows another member's visit or makes them its host. Read when opened. */} {participating && }
); }; return ( <>

Collaboration Teams

Collaboration teams you've been invited onto across your patients.

{unavailable ? (

{SANDBOX_TEAMS_UNAVAILABLE}

) : loading || awaitingTenant ? (
) : error === ROLE_DENIAL ? (

{error}

) : error ? (

{error}

) : teams.length === 0 ? (

You're not on any collaboration teams.

) : (
{actionError &&

{actionError}

} {invitedTeams.length > 0 && (

Pending invites

{invitedTeams.map((team) => { const myMembership = getMyMembership(team); if (!myMembership) return null; const busy = busyTeamId === team.teamId; return (

{team.name}

You've been invited to join this collaboration team.

{/* Plain
); })}
)} {pendingApprovalTeams.length > 0 && (

Awaiting patient approval

{pendingApprovalTeams.map((team) => (

{team.name}

A team member proposed you. The invitation will be sent after the patient approves.

Awaiting patient approval
))}
)} {liveActiveTeams.length > 0 && (

Active

{liveActiveTeams.map(renderActiveTeamCard)}
)} {dormantActiveTeams.length > 0 && (

Dormant

{dormantActiveTeams.map(renderActiveTeamCard)}
)} {/* No live actions here: the card offers them only while the team itself is active. */} {closedTeams.length > 0 && (

Closed

{closedTeams.map(renderActiveTeamCard)}
)}
)} { if (!leaving && !open) setLeaveTarget(null); }} > Leave “{leaveTarget?.name}”? You'll leave this collaboration team. The patient or another member can invite you back later. Stay on the team {/* Outside the panel so an absent reminder adds no flex gap. */}