import { useChatSendRecovery } from '../../../../hooks/useChatSendRecovery'; import { scannedPhotoSrc } from '../../../../utils/fhirPhoto'; import React, { useState, useEffect } from 'react'; import { colleagueRoleLine } from '../../../../utils/staff/colleagueRoleLine'; import { useColleagueDirectory } from '../../../../hooks/useColleagueDirectory'; import { Attachment, Communication, CommunicationRequest, Practitioner, Reference } from 'fhir/r4'; import { PractitionerWithRoles, useAuthServiceContext, useCommunicationServiceContext, useFhirServiceContext } from '../../../../services'; import { Chat, FhirCommunicationRequestDisplay } from '../../../../components'; import { formatFhirHumanName } from '../../../../utils/fhirHumanName.tsx'; import { useFhirCommunicationRequestTargetedBroadcast, useFhirPractitioners, useCommunicationManager, useNeedsReply, useInboxThreads } from '../../../../hooks'; import { CardContent } from '../../../../components/ui/card'; import { Skeleton } from '../../../../components/ui/skeleton'; import { InputGroup, InputGroupAddon, InputGroupInput } from '../../../../components/ui/input-group'; import PresenceDot from '../../../../components/PresenceDot/PresenceDot'; import { Loader2, MessageSquareText, SearchIcon, X } from 'lucide-react'; import { initialsOf } from '../../../../components/ui/org-badge'; import clsx from 'clsx'; import { EmptyState } from '../../../../components/ui/empty-state'; import { Button } from '../../../../components/ui/button'; import { SearchControl } from '../../../../components/ui/search-control'; import { InboxAvatar } from '../../MessagesPage/InboxRow'; import { DeskDirectoryRow } from './DeskInboxRow'; import { MessagesLoadFailure } from './MessagesLoadFailure'; import { BackToConversationsButton, NewConversationButton } from './ConversationListSwitch'; import { MESSAGES_SPLIT_GRID, messagesInboxPane, messagesThreadPane } from '../../../../utils/messagesLayout'; import { threadLastActivity, threadPreview } from '../../../../utils/threadSummary'; import { formatRelativeTime } from '../../../../utils/formatRelativeTime'; import { describeBlockedAction } from '../../../../utils/fetchFailureMessage'; import { WITHHELD_COLLEAGUE_NAME, WITHHELD_COLLEAGUE_REASON, } from '../../../../utils/staff/withheldColleague'; import { namedSend, type SendOperation } from "../../../../utils/messaging/sendOperation"; import type { SaveTransport } from '../../../../utils/saveOperation'; interface ColleaguesPanelProps { initialOpenRequestId?: string; initialOpenWith?: string; /** When this panel is the whole page, its list tab is the page's "Colleagues" tab. */ standalone?: boolean; /** The desk's Colleagues tab: the directory beside the conversation, no tabs of its own. */ view?: 'directory'; /** Only the conversation, for the desk inbox that lists it. */ paneOnly?: boolean; onBack?: () => void; onClose?: () => void; } export const ColleaguesPanel: React.FC = ({ initialOpenRequestId, initialOpenWith, standalone = false, view, paneOnly = false, onBack, onClose }) => { // Chat holds each named send for a full reload and settles what a reload left. const sendRecovery = useChatSendRecovery(); const desk = view !== undefined || paneOnly; // Each open takes a ticket; a thread that arrives for an older ticket is not shown. const openTicket = React.useRef(0); const { currentUser, userRole, USER_ROLES, currentOrganization } = useAuthServiceContext(); const { list: practitionersList, status: colleagueStatus, retry: retryColleagues } = useColleagueDirectory({ refreshOnMount: true }); const { saveCommunicationBundle, readCommunications, createCommunication, // The write's own result goes on screen - see the note at the send. addCommunicationToState, createCommunicationForThread, threadListingFailed, } = useCommunicationServiceContext(); const { extractReferenceId, createReferenceObject } = useFhirServiceContext(); const { staffCommunicationRequests, loading: communicationLoading, error: communicationError, errorRetryable: communicationErrorRetryable, notPermitted: messagingNotPermitted, getMessagesForRequest, getUnreadCountForRequest, openStaffDirectThread, setActiveThread, clearError: clearCommunicationError } = useCommunicationManager(); const [error, setError] = useState(); const [selectedChat, setSelectedChat] = useState(null); const [otherPractitioner, setOtherPractitioner] = useState(); // The default is what a caller who may not read the counterparty is left with, so it says so // rather than standing in for a role nobody looked up. const [conversationRoleLabel, setConversationRoleLabel] = useState(WITHHELD_COLLEAGUE_REASON); const [chatLoading, setChatLoading] = useState(false); const [selectedCommunicationRequestForDisplay, setSelectedCommunicationRequestForDisplay] = useState(null); const [showDisplayModal, setShowDisplayModal] = useState(false); const [activeTab, setActiveTab] = useState<'inbox' | 'staff'>('inbox'); const [searchTerm, setSearchTerm] = useState(''); const [showConversationMobile, setShowConversationMobile] = useState(false); // Deep link from the shell (e.g. the receptionist overview): the CommunicationRequest id or // practitioner id to open, captured once on mount from props (the shell owns clearing router state). const [pendingOpenRequestId, setPendingOpenRequestId] = useState(initialOpenRequestId); const [pendingOpenPractitionerId, setPendingOpenPractitionerId] = useState(initialOpenWith); const currentPractitioner = currentUser?.fhir as Practitioner; const currentPractitionerRef = currentPractitioner?.id ? `Practitioner/${currentPractitioner.id}` : ''; const isReceptionist = userRole === USER_ROLES.RECEPTIONIST; const { needsReply, dismiss, isDismissing, getLastCounterpartyMessageId } = useNeedsReply({ requests: staffCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, currentUserRef: currentPractitionerRef, enabled: isReceptionist || standalone, }); // `errorIsPermissionDenial` EXISTS FOR EXACTLY THIS AND THIS PANEL WAS NOT ASKING FOR IT. The // hook has carried it since a Schedules screen explained a permission limit gracefully twice and // then reported the SAME denial as a red error - it is the retryability of the RESPONSE, kept as // a fact so a screen never has to read it back out of the sentence. // A colleague who joined while this session was open must still be in the picker. const { loading: practitionersLoading, errorIsPermissionDenial: directoryIsDenied, } = useFhirPractitioners({ refreshOnMount: true }); useFhirCommunicationRequestTargetedBroadcast({ communicationRequest: selectedCommunicationRequestForDisplay || undefined, isOpen: showDisplayModal, onUpdate: (updatedCommunicationRequest) => { setSelectedCommunicationRequestForDisplay(updatedCommunicationRequest); }, onDelete: () => { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); } }); useEffect(() => { return () => { setActiveThread(null); }; }, [setActiveThread]); const getOtherParticipant = React.useCallback((communicationRequest: CommunicationRequest): Practitioner | undefined => { const currentUserRef = `Practitioner/${currentPractitioner.id}`; const senderRef = communicationRequest.sender?.reference; let otherParticipantId: string; if (senderRef === currentUserRef) { otherParticipantId = extractReferenceId(communicationRequest.recipient?.[0]) as string; } else { otherParticipantId = extractReferenceId(communicationRequest.sender) as string; } const practitionerWithRoles = practitionersList.find(p => p.practitioner.id === otherParticipantId); return practitionerWithRoles?.practitioner; }, [currentPractitioner.id, extractReferenceId, practitionersList]); const getOtherPractitionerRoleLabel = React.useCallback((practitioner: Practitioner): string => colleagueRoleLine(practitionersList.find(p => p.practitioner.id === practitioner.id)), [practitionersList]); const getCounterpartyId = React.useCallback( (request: CommunicationRequest): string | undefined => { const senderId = extractReferenceId(request.sender) as string | undefined; if (senderId === currentPractitioner.id) { return extractReferenceId(request.recipient?.[0]) as string | undefined; } return senderId; }, [currentPractitioner.id, extractReferenceId], ); const { dedupedThreads, inboxThreads } = useInboxThreads( staffCommunicationRequests, getCounterpartyId, { getMessagesForRequest, getUnreadCountForRequest }, ); // The Inbox tab counts unread messages and hides at zero; the colleague list carries no count. const inboxUnread = inboxThreads.reduce( (total, request) => total + getUnreadCountForRequest(request.id as string), 0, ); const filteredInboxRequests = React.useMemo(() => { if (!searchTerm.trim()) return inboxThreads; return inboxThreads.filter((request) => { const otherParticipant = getOtherParticipant(request); const name = otherParticipant?.name ? formatFhirHumanName(otherParticipant.name)?.toLowerCase() || '' : ''; return name.includes(searchTerm.toLowerCase()); }); }, [inboxThreads, searchTerm, getOtherParticipant]); const filteredStaffList = React.useMemo(() => { const others = practitionersList.filter( (item: PractitionerWithRoles) => item.practitioner.id !== currentUser?.fhir?.id, ); if (!searchTerm.trim()) return others; return others.filter((item: PractitionerWithRoles) => { const name = formatFhirHumanName(item.practitioner.name)?.toLowerCase() || ''; return name.includes(searchTerm.toLowerCase()); }); }, [practitionersList, currentUser?.fhir?.id, searchTerm]); const handleStaffClick = async (staff: Practitioner) => { const ticket = ++openTicket.current; setChatLoading(true); setError(undefined); try { const thread = await openStaffDirectThread( currentPractitioner, staff, ); if (ticket !== openTicket.current) return; setOtherPractitioner(staff); setConversationRoleLabel(getOtherPractitionerRoleLabel(staff)); if (thread.threadId) { setActiveThread(thread.threadId); } setSelectedChat(thread.request); setActiveTab('inbox'); setShowConversationMobile(true); } catch (openError: unknown) { if (ticket !== openTicket.current) return; console.error('Error opening thread:', openError); // Same reasoning as the note in `PatientsPanel`: a refusal must name what was refused, // and only a failure that could succeed on a retry may ask for one. setError(describeBlockedAction(openError, 'Failed to open chat')); } finally { if (ticket === openTicket.current) setChatLoading(false); } }; const handleStaffClickRef = React.useRef(handleStaffClick); handleStaffClickRef.current = handleStaffClick; const clearError = () => { clearCommunicationError(); setError(undefined); }; /** * ADDRESSABLE IS NOT THE SAME AS READABLE, AND THIS HANDLER USED TO CONFLATE THEM. * * The guard required `otherPractitioner`, which is resolved by finding the counterparty in * `practitionersList` - a staff directory read. A seat without staff:view never gets that list, * so the colleague is correctly shown as "Name withheld" AND the send silently returned, * having done nothing. * * MEASURED on journey F3 from the Billing seat: the composer cleared, the bubble rendered in * house blue, the line under it read "Sent", and the wire carried no POST at all; after a * reload the message was gone. `Chat.tsx` adds that bubble and clears the field BEFORE awaiting * this function, and marks it failed only if this throws - so a silent early return is the one * shape that makes the product state the opposite of what happened. * * The thread already carries who the other party is: `getCounterpartyId` reads it off the * request's own sender/recipient reference and touches no directory. The name is the only thing * the directory was ever needed for, and a reference without a display is still a valid * reference - the server resolves the recipient from the id. */ const handleSendMessage = async (message: string, attachment?: Attachment, references?: Reference[], send?: SendOperation) => { if ((!message.trim() && !attachment && (!references || references.length === 0)) || !selectedChat) { return; } const counterpartyId = otherPractitioner?.id ?? getCounterpartyId(selectedChat); if (!counterpartyId) { // Never silent. If the thread cannot say who it is with, the person who just pressed // send has to learn that here rather than from a message that is not there tomorrow. const noCounterparty = 'Your message was not sent - this conversation does not name who it is with.'; setError(noCounterparty); throw new Error(noCounterparty); } try { const recipientReferences: Reference[] = [ createReferenceObject( 'Practitioner', counterpartyId, otherPractitioner ? formatFhirHumanName(otherPractitioner.name) : undefined, ), ]; const transformedCommunication: Communication = createCommunicationForThread( message, selectedChat.id as string, currentUser?.fhir as Practitioner, recipientReferences, attachment, references ); // Same reasoning as the patient panel beside it. A colleague thread races the same // index; it has simply been luckier, which is not a property to rely on. addCommunicationToState(await createCommunication(namedSend(transformedCommunication, send), send)); } catch (sendError: unknown) { console.error('Error sending message:', sendError); // Chat marks the bubble failed, keeps the words and gives the server's reason only when // this throws; a caught refusal left the bubble reading "Sent" under a "not sent" banner. throw sendError; } }; const handleUpdateMessages = async (updatedMessages: Communication[], transport?: SaveTransport): Promise => { try { return await saveCommunicationBundle(updatedMessages, transport); } catch (updateError: unknown) { console.error('Failed to update message receipt status:', updateError); return []; } }; const handleCloseChat = () => { openTicket.current += 1; onClose?.(); setActiveThread(null); setSelectedChat(null); setOtherPractitioner(undefined); // The same value the state starts at. A reset that differs from the initial state is how // one of these five sites ends up saying something the other four do not. setConversationRoleLabel(WITHHELD_COLLEAGUE_REASON); setShowConversationMobile(false); }; const chat = React.useCallback(async (event: React.MouseEvent | null, communicationRequest: CommunicationRequest) => { if (event) { event.preventDefault(); event.stopPropagation(); } openTicket.current += 1; setChatLoading(true); try { if (communicationRequest.id) { setActiveThread(communicationRequest.id); } const participant = getOtherParticipant(communicationRequest); setOtherPractitioner(participant); if (participant) { setConversationRoleLabel(getOtherPractitionerRoleLabel(participant)); } setSelectedChat(communicationRequest); setShowConversationMobile(true); } catch (chatError: unknown) { console.error('Error opening chat:', chatError); } finally { setChatLoading(false); } }, [setActiveThread, getOtherParticipant, getOtherPractitionerRoleLabel]); useEffect(() => { if (!pendingOpenRequestId || practitionersLoading) return; const target = staffCommunicationRequests.find( (request) => request.id === pendingOpenRequestId, ); if (!target) return; const counterpartyId = getCounterpartyId(target); const threadRequest = dedupedThreads.find( (request) => getCounterpartyId(request) === counterpartyId, ) || target; setPendingOpenRequestId(undefined); void chat(null, threadRequest); }, [pendingOpenRequestId, practitionersLoading, staffCommunicationRequests, getCounterpartyId, dedupedThreads, chat]); useEffect(() => { if (!pendingOpenPractitionerId || practitionersLoading) return; const target = practitionersList.find( (practitioner) => practitioner.practitioner.id === pendingOpenPractitionerId, ); if (!target) return; setPendingOpenPractitionerId(undefined); void handleStaffClickRef.current(target.practitioner); }, [pendingOpenPractitionerId, practitionersLoading, practitionersList]); const currentChatMessages = selectedChat ? getMessagesForRequest(selectedChat) : []; // A listing that failed is not an empty inbox. Under the search, the failure and Retry are centred in the list pane. const listingUnreadable = threadListingFailed && communicationErrorRetryable !== false && filteredInboxRequests.length === 0; const renderInboxList = () => { if (listingUnreadable) { return (
{ clearError(); window.location.reload(); }} inPane />
); } if (filteredInboxRequests.length === 0) { return ( // (B26) - this one READ correctly ("Directory" collides with nothing), but it is // phrased the same way as its sibling for the same reason: name the action, not the tab.
); } return (
{filteredInboxRequests.map((communicationRequest: CommunicationRequest) => { const unreadCount = getUnreadCountForRequest(communicationRequest.id as string); const otherParticipant = getOtherParticipant(communicationRequest); // NOT "Staff member". That reads as a job title, so nothing on the row said // anything had been withheld - and a reader cannot tell a name they may not // see from a colleague who has none recorded. const name = otherParticipant?.name ? formatFhirHumanName(otherParticipant.name) || WITHHELD_COLLEAGUE_NAME : WITHHELD_COLLEAGUE_NAME; const roleLabel = otherParticipant ? getOtherPractitionerRoleLabel(otherParticipant) : WITHHELD_COLLEAGUE_REASON; const photo = otherParticipant?.photo?.[0]; const avatarSrc = scannedPhotoSrc(photo); // WHAT THIS CONVERSATION SAYS - the row already answers "which conversation"; this // is what answers "which one needs me FIRST". Read from the shared derivation rather // than spelled out here: two versions of "the last thing said" drift a sentence at a time. const preview = threadPreview(getMessagesForRequest(communicationRequest), currentPractitionerRef); // One reading per render pass, so every row in the list is measured against the // same instant rather than each against its own. const now = Date.now(); const lastActive = formatRelativeTime( threadLastActivity(getMessagesForRequest(communicationRequest)), now, ) || '--'; return (
void chat(null, communicationRequest)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); void chat(null, communicationRequest); }} className={clsx( 'ahr-mm-row focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', selectedChat?.id === communicationRequest.id && 'ahr-mm-row--on', )} > {/* ⚠️ `min-w-0` on both: a flex child defaults to `min-width: auto`, so without it the preview refuses to truncate and pushes the time and badge off. */}
{avatarSrc ? {name} : initialsOf(name)}
{/* A colleague-only page sets the time beside the name, as its deck does. */} {standalone ? (

{name}

{lastActive}

) : (

{name}

)}

{roleLabel}

{preview &&

{preview}

} {needsReply(communicationRequest.id as string) && ( Awaiting reply )}
{/* 🕑 The same derivation the preview beside it reads. This showed `communicationRequest.authoredOn` — when the CONVERSATION was opened — as a bare hour and minute, so the text updated on every reply while the time never moved, and a thread started at 5:16 PM three days ago read as 5:16 PM today: a time in the future against a 5:03 PM clock. ⇒ Same pairing `TeamMessagePreview` already uses. */} {!standalone &&

{lastActive}

}
{/* THE ONE COUNT BADGE. This was a `Badge` carrying `h-6 w-6 rounded px-1 font-mono`, so it drew as a monospace SQUARE while the same unread number was a circle in the patient inbox and in the header - one idea, three drawings, and a staff member who also uses the portal met both. `ahr-count-badge` is the named rule for it. */} {unreadCount > 0 && ( {unreadCount} )}
); })}
); }; const renderStaffList = () => { if (practitionersLoading) { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
); } // A REFUSED DIRECTORY IS NOT AN EMPTY ONE. // // Measured at Great River: a Billing user and a Marketing user each get `403` twice on the // staff read and were shown "No staff members found" - a sentence that describes a practice // with nobody in it. The receptionist's own directory beside it lists twenty-one people. // // The refusal is CORRECT: no permission section governs messaging a colleague, and reading // everybody's work identity is a different authority from writing to one of them. What was // wrong is that a denial rendered as data, which is the one rendering the standing rule // forbids - and it is why nobody reported this: the page looks finished. // // Checked BEFORE the empty case rather than inside it, because the two are different facts // and the denial is the more specific one. A search that matches nothing is still "no // matching staff" even when the list behind it was never readable. if (practitionersList.length === 0 && (colleagueStatus === 'loading' || colleagueStatus === 'failed') && !searchTerm) { return ( Try again : undefined} /> ); } if (directoryIsDenied && practitionersList.length === 0 && colleagueStatus === 'denied' && !searchTerm) { return (
{/* THE DECK'S OWN WORDS, from `platform-manager/messages.html`, which draws this exact refusal on this exact tab. Taken verbatim rather than written here. Its second sentence does three things in one breath: it names what is denied, it says why in terms of the ROLE rather than the reader, and it ends with the way out. "Ask an administrator if you need it" is what turns a wall into a door, and it is the half the reader actually needs. The inbox line comes AFTER it, not instead of it: the house pattern is to name the thing and then rule out the wrong conclusion the reader would draw - the same shape as the schedules screen's "Colleagues' hours are not withheld by an error - your role does not include them." Somebody whose directory is empty would otherwise assume their messages are gone too. */}

You do not have access to this practice's staff directory

Your role{currentOrganization?.name ? ` at ${currentOrganization.name}` : ''} does not include reading staff records, so the directory cannot be shown. Ask an administrator if you need it.

Conversations already started with you still appear in your Inbox.

); } if (filteredStaffList.length === 0) { return (

{searchTerm ? 'No matching colleagues' : 'No staff members found'}

{searchTerm && ( <>

No colleague matches “{searchTerm}”.

)}
); } return (
{filteredStaffList.map((item: PractitionerWithRoles) => { const photo = item.practitioner.photo?.[0]; const name = formatFhirHumanName(item.practitioner.name) || WITHHELD_COLLEAGUE_NAME; return ( } name={<>{name}} sub={getOtherPractitionerRoleLabel(item.practitioner)} onOpen={() => void handleStaffClick(item.practitioner)} flat={!standalone} messageLabel={standalone ? name : undefined} /> ); })}
); }; const renderContent = () => { // - FIRST, and before the loading and empty states, because it is neither of them. // Nothing was requested: the server says this role may not read the thread list at all. Falling // through to the ordinary empty state would assert this person has no conversations, which is a // false claim about their records. if (messagingNotPermitted) { return ( {}} /> ); } if (communicationError && !listingUnreadable) { return ( { clearError(); window.location.reload(); }} /> ); } if (communicationLoading) { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
); } return ( <>
{!standalone && activeTab === 'staff' && ( setActiveTab('inbox')} /> )} {standalone && ( )}
setSearchTerm(e.target.value)} /> {!standalone && activeTab === 'inbox' && ( setActiveTab('staff')} /> )}
{activeTab === 'inbox' ? renderInboxList() : renderStaffList()}
); }; const chatPane = selectedChat ? ( handleSendMessage(message, attachment, undefined, send)} sendRecovery={sendRecovery} onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} onClose={handleCloseChat} // Back on a phone returns to the list with this conversation still chosen. onBack={paneOnly ? onBack : () => setShowConversationMobile(false)} loading={chatLoading} needsReply={selectedChat ? needsReply(selectedChat.id as string) : false} onDismissReply={ selectedChat ? () => { const lastMsgId = getLastCounterpartyMessageId(selectedChat.id as string); if (lastMsgId) dismiss(selectedChat.id as string, lastMsgId); } : undefined } layout={desk ? 'desk' : standalone ? 'colleague' : undefined} /> ) : null; const renderDeskDirectory = () => { if (practitionersList.length === 0 && (colleagueStatus === 'loading' || colleagueStatus === 'failed') && !searchTerm) { return ( Try again : undefined} className="ahr-desk-empty" /> ); } if (directoryIsDenied && practitionersList.length === 0 && colleagueStatus === 'denied' && !searchTerm) { return ( ); } if (filteredStaffList.length === 0) { return ( ); } return (
{filteredStaffList.map((item: PractitionerWithRoles) => { const photo = item.practitioner.photo?.[0]; const name = formatFhirHumanName(item.practitioner.name) || WITHHELD_COLLEAGUE_NAME; return ( } name={<>{name}} sub={getOtherPractitionerRoleLabel(item.practitioner)} onOpen={() => void handleStaffClick(item.practitioner)} /> ); })}
); }; if (paneOnly) { return chatPane ?? (
Opening the conversation…
); } if (view === 'directory') { return (
{error && (

{error}

)} {communicationLoading || practitionersLoading ? (
{Array.from({ length: 6 }).map((_, index) => (
))}
) : ( <> {renderDeskDirectory()} )}
{chatPane ?? ( )}
); } return ( <> {/* A colleague-only page draws its two tabs once, across the list and the thread, as the deck does. */} {standalone && (
)}
{error && (

{error}

)} {renderContent()}
{/* 🚪 NOTHING SELECTED IS A STATE, NOT A BLANK — the same gap as the Patients panel and for the same reason: `Chat` owns the "Select a conversation" wording but is not mounted until something is selected, which is the one case it cannot speak for. */} {!selectedChat && (
)} {chatPane}
{selectedCommunicationRequestForDisplay && ( { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); }}/> )} ); };