import { useChatSendRecovery } from '../../../../hooks/useChatSendRecovery'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { Attachment, Communication, Practitioner } from 'fhir/r4'; import { useAuthServiceContext, useCommunicationServiceContext, useCollaborationTeamServiceContext, STATUS_LABELS, TEAM_STATUS_TONE, } from '../../../../services'; import type { CollaborationTeam, TeamMembership } from '../../../../services'; import { Chat } from '../../../../components'; import { useCollaborationTeamThreadMessages } from '../../../../hooks/useCollaborationTeamThreadMessages'; import { useCollabTeamSummaries } from '../../../../hooks/useCollabTeamSummaries'; import { buildCollabTeamThreadId, buildCollabTeamAsideThreadId } from '../../../../utils/collaborationTeamThread'; import { TeamMessagePreview } from '../../../../components/TeamMessagePreview/TeamMessagePreview'; import { CardContent } from '../../../../components/ui/card'; import { Skeleton } from '../../../../components/ui/skeleton'; import { RowStatus } from '../../../../components/ui/row-meta'; import { Users, MessageSquare, Lock } from 'lucide-react'; import { EmptyState } from '../../../../components/ui/empty-state'; import clsx from 'clsx'; import { MESSAGES_SPLIT_GRID, messagesInboxPane, messagesThreadPane } from '../../../../utils/messagesLayout'; import { onViewerChange, viewerEpoch } from '../../../../utils/writeThroughHold'; import { useOptionalWebSocketServiceContext } from '../../../../services/WebSocketService'; import { TEAM_MEMBERSHIP_UPDATED, ROSTER_REREADS, ROSTER_REREAD_MS } from '../../../../components/PatientInbox/PatientInboxProvider'; import { namedSend, type SendOperation } from "../../../../utils/messaging/sendOperation"; import type { SaveTransport } from '../../../../utils/saveOperation'; type TeamWithMembers = CollaborationTeam & { members: TeamMembership[] }; // Practitioner-only "Collaboration Teams" tab on the consolidated staff Messages shell (Slice 2b Task // 3). Mirrors the patient portal's Collaboration Teams tab (MessagesPage.tsx Slice-1 block) and the // two-pane list+Chat layout of the sibling ColleaguesPanel/PatientsPanel. Chrome-less: no own // Card/CardTitle - the StaffMessagesPage shell already supplies the "Messages" Card/title/tab bar and // renders this panel inside its CardContent. interface CollaborationTeamsPanelProps { initialTeamId?: string; initialVariant?: 'public' | 'aside' } export const CollaborationTeamsPanel: React.FC = ({ initialTeamId, initialVariant }) => { // Chat holds each named send for a full reload and settles what a reload left. const sendRecovery = useChatSendRecovery(); const { currentUser } = useAuthServiceContext(); const { listTeams } = useCollaborationTeamServiceContext(); const { createCommunication, createCommunicationForThread, saveCommunicationBundle, readCommunications, hasReadReceiptForReference, getCurrentUserReference, } = useCommunicationServiceContext(); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [teamFilter, setTeamFilter] = useState<'open' | 'closed'>('open'); const [selectedTeamId, setSelectedTeamId] = useState(null); const selectedTeam = useMemo(() => teams.find(team => team.teamId === selectedTeamId) ?? null, [teams, selectedTeamId]); const setSelectedTeam = useCallback((team: TeamWithMembers | null) => setSelectedTeamId(team?.teamId ?? null), []); const [showConversationMobile, setShowConversationMobile] = useState(false); const [asideMode, setAsideMode] = useState(false); const readSeq = React.useRef(0); const mounted = React.useRef(true); const consumedInitialRef = React.useRef(false); const teamsRef = React.useRef(teams); teamsRef.current = teams; const load = useCallback(async (background = false): Promise => { const mine = ++readSeq.current; const epoch = viewerEpoch(); const belongs = () => mounted.current && mine === readSeq.current && epoch === viewerEpoch(); if (!background) setLoading(true); setError(null); try { const listed = await listTeams(); if (!belongs()) return; if (!Array.isArray(listed)) throw new Error('Unreadable team list'); setTeams(listed); setSelectedTeamId(previous => previous && listed.some(team => team.teamId === previous) ? previous : null); return listed; } catch { if (belongs()) setError('Failed to load your collaboration teams'); } finally { if (belongs()) setLoading(false); } }, [listTeams]); useEffect(() => { mounted.current = true; void load(); return () => { mounted.current = false; readSeq.current++; }; }, [load]); useEffect(() => onViewerChange(() => { readSeq.current++; setTeams([]); setSelectedTeamId(null); setShowConversationMobile(false); consumedInitialRef.current = true; void load(); }), [load]); const sockets = useOptionalWebSocketServiceContext(); const subscribe = sockets?.subscribe; const unsubscribe = sockets?.unsubscribe; useEffect(() => { if (!subscribe || !unsubscribe) return; let stopped = false; const timers: ReturnType[] = []; const stamp = (list: TeamWithMembers[], id: string) => { const team = list.find(row => row.teamId === id); return JSON.stringify([team?.status, team?.name, team?.members.map(member => [member.memberRef, member.state, member.displayName])]); }; const onRoster = (data: { teamId?: string } | undefined) => { const epoch = viewerEpoch(); const id = data?.teamId; if (!id || !teamsRef.current.some(team => team.teamId === id)) return; const before = stamp(teamsRef.current, id); const read = async (left: number) => { if (stopped || epoch !== viewerEpoch()) return; const listed = await load(true); if (stopped || epoch !== viewerEpoch() || !listed || !left || stamp(listed, id) !== before) return; timers.push(setTimeout(() => void read(left - 1), ROSTER_REREAD_MS)); }; void read(ROSTER_REREADS); }; subscribe(TEAM_MEMBERSHIP_UPDATED, onRoster); return () => { stopped = true; timers.forEach(clearTimeout); unsubscribe(TEAM_MEMBERSHIP_UPDATED, onRoster); }; }, [subscribe, unsubscribe, load]); // The Slice 1 server read gate requires ACTIVE membership on the thread - listTeams() also returns // teams the caller is only invited/pending-approval on (those belong on StaffCareTeamsPage to // accept, not here), so this tab must only ever list teams the caller can actually chat in. const callerRef = currentUser?.fhir?.id ? `Practitioner/${currentUser.fhir.id}` : undefined; const myMembershipState = useCallback( (team: TeamWithMembers) => team.members.find((m) => m.memberRef === callerRef)?.state, [callerRef], ); const chatableTeams = useMemo(() => teams.filter((t) => myMembershipState(t) === 'active'), [teams, myMembershipState]); const openTeams = useMemo(() => chatableTeams.filter((t) => t.status === 'active' || t.status === 'dormant'), [chatableTeams]); const closedTeams = useMemo(() => chatableTeams.filter((t) => t.status === 'dissolved'), [chatableTeams]); const shownTeams = teamFilter === 'open' ? openTeams : closedTeams; // List-row last-message + unread previews (practitioner-only tab include the aside thread). // Separate from the pub/aside `useCollaborationTeamThreadMessages` pair below, which only tracks // the currently-OPEN team's live conversation. const now = Date.now(); const summaries = useCollabTeamSummaries(shownTeams, { includeAside: true }); // One-shot deep-link pre-selection (e.g. from the overview Messages card): open a specific team, flip the // Open/Closed filter so a dissolved team is visible, and toggle to the aside if the link asked for it. useEffect(() => { if (consumedInitialRef.current || !initialTeamId || loading) return; const target = chatableTeams.find((t) => t.teamId === initialTeamId); if (!target) return; consumedInitialRef.current = true; setTeamFilter(target.status === 'dissolved' ? 'closed' : 'open'); setSelectedTeam(target); setShowConversationMobile(true); setAsideMode(initialVariant === 'aside'); }, [initialTeamId, initialVariant, loading, chatableTeams]); /** * - WHO WROTE EACH MESSAGE, taken from the roster this panel already holds. * * THE MESSAGES THAT NEEDED THIS ARE THE OTHER PRACTICE'S. A `Communication.sender.display` is * denormalised by whoever sent it, so a cross-org clinician's messages arrive with none and were * rendered as *"Participant"* - on the very screen whose roster named them. Measured on Great River's * Test Team: `Practitioner/cb68ee44...` is *"External Testpractitioner"* in the roster and was * "Participant" three lines below it. * * THE SERVER'S ANSWER, PASSED ALONG - not a second resolution. `displayName` is resolved by the * collaboration-team read, which is also the one place that decides what this caller may be told * about a member; the client cannot look an external practitioner up and must not try. * Members with no `displayName` are simply absent from the map, and `senderLabel` still falls back * - the fallback is being NARROWED, never removed. */ const rosterNames = useMemo>(() => { const names: Record = {}; for (const member of selectedTeam?.members ?? []) { if (member.displayName) names[member.memberRef] = member.displayName; } return names; }, [selectedTeam]); const activeTeamId = selectedTeam?.teamId ?? null; // The team chat is read again while it is open, so a removal shows without a reload; the aside only while shown. const pub = useCollaborationTeamThreadMessages(activeTeamId, 'public', null, { followMembership: true }); const aside = useCollaborationTeamThreadMessages(activeTeamId, 'aside', null, { followMembership: asideMode }); const active = asideMode ? aside : pub; // Only the server's own refusal of this team's thread says this viewer is no longer on it; the list cannot. const notParticipant = pub.participation === 'refused' || active.participation === 'refused'; // Teams whose thread refused this viewer, each with the team thread's latest answer seen when it was refused. // Closing or leaving the thread does not make the roster readable again; only a read of that team's own thread // that answers AFTER the refusal does, and never while anything still refuses. A new viewer starts with none. const [refusedTeams, setRefusedTeams] = useState>(() => new Map()); useEffect(() => onViewerChange(() => setRefusedTeams(new Map())), []); useEffect(() => { if (!activeTeamId) return; const seenWhenRefused = refusedTeams.get(activeTeamId); if (notParticipant) { if (seenWhenRefused === undefined) { setRefusedTeams((held) => new Map(held).set(activeTeamId, pub.lastConfirmed ?? -1)); } return; } if (seenWhenRefused !== undefined && pub.lastConfirmed !== null && pub.lastConfirmed > seenWhenRefused) { setRefusedTeams((held) => { const next = new Map(held); next.delete(activeTeamId); return next; }); } }, [activeTeamId, notParticipant, pub.lastConfirmed, refusedTeams]); const isRefused = (teamId: string | null | undefined) => !!teamId && (refusedTeams.has(teamId) || (notParticipant && teamId === activeTeamId)); // Reuses Chat's existing read-receipt mechanism (identical to ColleaguesPanel/PatientsPanel): // Chat's markMessagesAsRead effect is gated on onUpdateMessages and writes the caller's read // receipts for the active thread on view via this handler. const handleUpdateMessages = async (updatedMessages: Communication[], transport?: SaveTransport): Promise => { try { return await saveCommunicationBundle(updatedMessages, transport); } catch (e) { console.error('Failed to update collab read receipts:', e); return []; } }; const selfRef = getCurrentUserReference(); const unreadCount = (messages: Communication[]): number => !selfRef ? 0 : messages.filter((m) => m.sender?.reference !== selfRef && !hasReadReceiptForReference(m, selfRef)).length; const pubUnread = unreadCount(pub.messages); const asideUnread = unreadCount(aside.messages); // Taken at render, so a Send pressed in an opening stays that opening's even when Chat calls back after preparing a // file: a callback from a replaced opening sends nothing and cannot settle the one now shown. const sendFrom = active.sendOpening; const handleTeamSend = async (text: string, attachment?: Attachment, operation?: SendOperation) => { if (!selectedTeam || (!text.trim() && !attachment)) return; // Server derives the recipient roster from team membership - no client recipients. sender is // set by createCommunicationForThread itself from the passed Practitioner; do not hand-set it. const self = currentUser?.fhir as Practitioner; const threadId = asideMode ? buildCollabTeamAsideThreadId(selectedTeam.teamId) : buildCollabTeamThreadId(selectedTeam.teamId); const built = createCommunicationForThread(text, threadId, self, [], attachment); // The send belongs to the thread it was pressed in: once another team or viewer is on screen, its answer // changes nothing here. const send = active.beginSend(sendFrom); if (!send.owns()) return; try { const sent = await createCommunication(namedSend(built, operation), operation); if (send.owns()) active.appendOptimistic(sent); } catch (err) { if (!send.owns()) return; // Refused as not a participant: the thread says so in place of the composer, so no toast asks for a retry. if (send.noteRefusal(err)) return; // Anything else goes back to the chat, which keeps the words and says whether it may have been sent. throw err; } }; const handleTeamClick = (team: TeamWithMembers) => { setSelectedTeam(team); setShowConversationMobile(true); setAsideMode(false); }; const handleCloseChat = () => { setSelectedTeam(null); setShowConversationMobile(false); setAsideMode(false); }; const renderTeamList = () => { if (loading) { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } if (error && teams.length === 0) { return

{error}

; } if (shownTeams.length === 0) { return (
); } return (
{error &&

{error}

} {shownTeams.map((team) => { const memberCount = 1 + team.members.filter((m) => m.state === 'active').length; return (
handleTeamClick(team)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handleTeamClick(team); }} aria-current={selectedTeam?.teamId === team.teamId ? 'true' : undefined} className={clsx( 'ak-row cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', selectedTeam?.teamId === team.teamId && 'ak-card--on', )} >

{team.name} {/* A state is coloured text, never a filled pill. */} {isRefused(team.teamId) ? ( Not a participant ) : ( {STATUS_LABELS[team.status]} )}

{/* πŸ‘₯ CARD 676 β€” WHOSE CARE THIS TEAM IS FOR, which no clinician-facing surface said. A collaboration team exists FOR A PATIENT; a practitioner in two of them had nothing on screen telling the rows apart, and the failure that invites is a message sent to the wrong team. βš–οΈ Rendered only when the server sent it β€” it arrives solely for teams the caller is an ACTIVE member of, which is the same line that already decides whether they see the roster at all. An absent name is therefore a real state, not a loading one, so it degrades to the member count alone rather than to a placeholder. */} {team.patientDisplay && (

Care of {team.patientDisplay}

)} {!isRefused(team.teamId) && (

{memberCount} member{memberCount === 1 ? '' : 's'}

)}
); })}
); }; return (
{/* The shared segmented control, so Open/Closed reads as one control rather than two separate buttons. */}
{renderTeamList()}
{/* πŸšͺ NOTHING SELECTED IS A STATE, NOT A BLANK β€” the third pane with this gap. `Chat` owns the wording but is not mounted until a team is picked, which is the one case it cannot speak for. */} {!selectedTeam && (
)} {selectedTeam && ( // Chat renders its own Card with h-full, which needs a definite-height flex-1 slot to // resolve against - otherwise stacking the toggle/banner above it in plain block flow // would push Chat's compose bar past this column's fixed/overflow-hidden bounds.
{/* amber-700, not amber-600, and the reason is already settled in index.css. Card 235 moved `--warning` off amber-600 #d97706 because white on it is 3.19:1, under the 4.5:1 AA floor for text; amber-700 #b45309 is 5.02:1. This call site kept the raw pre-Card-235 value, so the selected aside drew its own label under the floor the token had already been moved to clear. `--assistant-mark` keeps #d97706 deliberately, but that token is for an ICON at the 3:1 non-text floor and its own comment forbids text on it. */}
{asideMode && (
Private β€” practitioners only, not shown to the patient
)} {!notParticipant && active.participation === 'unconfirmed' && (

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

)}
m.state === 'active' && m.memberClass === 'practitioner').length : 1 + selectedTeam.members.filter((m) => m.state === 'active').length } senderNameByReference={rosterNames} readOnly={selectedTeam.status === 'dissolved' || notParticipant} readOnlyReason={notParticipant ? "You are not a participant in this collaboration team, so you can't send here." : undefined} onSendMessage={handleTeamSend} sendRecovery={sendRecovery} onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} onClose={handleCloseChat} // Back on a phone returns to the list with this conversation still chosen. onBack={() => setShowConversationMobile(false)} loading={active.loading} loadError={active.error} onRetryLoad={() => { void active.refetch(); }} />
)}
); };