import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ChevronRight, UsersRound } from "lucide-react"; import clsx from "clsx"; import { AppSpinner } from "../../../components"; import { Button } from "../../../components/ui/button"; import { EmptyState } from "../../../components/ui/empty-state"; import { Input } from "../../../components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { useCollaborationTeamServiceContext, usePatientServiceContext, useAuthServiceContext, STATE_LABELS, } from "../../../services"; import { useMyProxies } from "../../../hooks/useMyProxies.tsx"; import { DORMANT_AFTER_DAYS, teamQuietReason, teamDisplayStatus, teamStatusLabel } from "../../../utils/collaborationTeamDormancy"; import { describeRoster } from "../../../utils/collaborationTeamRoster"; import { splitTeamsByOwner, type TeamWithMembers } from "../../../utils/collaborationTeamSides"; import { useCollabTeamSummaries } from "../../../hooks/useCollabTeamSummaries"; import { formatRelativeTime } from "../../../utils/formatRelativeTime"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { CareTeamDetail } from "./CareTeamDetailPage"; import { ConnectedShareAccessDialog } from "./ShareAccessDialog"; import { MemberTeamDetail } from "./MemberTeamDetail"; import { publishOpenTeam } from "./openTeamCrumb"; import { useIsPhoneWidth } from "../../../hooks/useIsPhoneWidth"; import { usePatientInbox } from "../../../components/PatientInbox/patientInboxContext"; import { ROSTER_REREADS, ROSTER_REREAD_MS, TEAM_MEMBERSHIP_UPDATED } from "../../../components/PatientInbox/PatientInboxProvider"; import { useOptionalWebSocketServiceContext } from "../../../services/WebSocketService"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { SANDBOX_TEAMS_UNAVAILABLE, useCollaborationAvailability } from "../../../utils/collaborationAvailability"; import "./CareTeamsPage.css"; type Side = "mine" | "others"; const LIST_PATH = "/dashboard/collaboration-teams"; // Collaboration teams: My teams and Other teams, each grouped Active / Dormant / Closed. // A team opens as an overlay over the list at every width; its URL is the list's plus the team id. export const CareTeamsPage = () => { const { teamId } = useParams<{ teamId?: string }>(); const navigate = useNavigate(); // A practitioner to invite, carried from a referral onto the team the patient invites them to. const invite = useSearchParams()[0].get("invite") ?? undefined; const inviteQuery = invite ? `?invite=${encodeURIComponent(invite)}` : ""; const { listTeams, createTeam, memberAction } = useCollaborationTeamServiceContext(); const { noteTeam } = usePatientInbox(); // Guarded on patientState so a false error never flashes before the patient context loads. const { patientState } = usePatientServiceContext(); const { proxies } = useMyProxies(); // Switched in, the caller is a guardian, and only a patient or practitioner may start a team. const { actingAs } = useAuthServiceContext(); const mayCreate = !actingAs; // Only the patient shares by code; a proxy tier is not authority to release the record. const mayShare = !actingAs; // In the sandbox the server lists no teams and refuses every team write, so nothing is read or offered. const availability = useCollaborationAvailability(); const unavailable = availability === "unavailable"; const [showShare, setShowShare] = useState(false); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 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; const [side, setSide] = useState("mine"); const [showCreateDialog, setShowCreateDialog] = useState(false); const [teamName, setTeamName] = useState(""); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); // Every read takes a number when it starts; only the newest answers, so an older success never restores a list a // newer read replaced or failed on. Decisions are offered only on a list the newest read answered. const readSeq = useRef(0); const [rosterCurrent, setRosterCurrent] = useState(true); const load = useCallback(async () => { const mine = ++readSeq.current; if (!patientState) return; if (unavailable) { setTeams([]); setLoading(false); return; } setLoading(true); setError(null); try { const listed = await listTeams(); if (mine !== readSeq.current) return; setTeams(listed); setRosterCurrent(true); } catch { if (mine !== readSeq.current) return; setError("Failed to load your collaboration teams"); setRosterCurrent(false); } finally { if (mine === readSeq.current) setLoading(false); } }, [patientState, listTeams, unavailable]); // After a change inside the overlay: re-read without replacing the page with a spinner. Answers with what it // listed, or nothing when a newer read replaced it or it failed. const readAgain = useCallback(async (): Promise => { const mine = ++readSeq.current; setRosterCurrent(false); try { const listed = await listTeams(); if (mine !== readSeq.current) return undefined; setTeams(listed); setError(null); setRosterCurrent(true); return listed; } catch { if (mine !== readSeq.current) return undefined; setError("Failed to load your collaboration teams"); return undefined; } }, [listTeams]); const refresh = useCallback(async () => { await readAgain(); }, [readAgain]); // A roster changed elsewhere (another tab, another member): the server's notice names only the team, so the list is // read again and an open team shows what it now holds. A list that trails the notice is read a couple more times. const sockets = useOptionalWebSocketServiceContext(); const subscribe = sockets?.subscribe; const unsubscribe = sockets?.unsubscribe; const teamsRef = useRef(teams); teamsRef.current = teams; useEffect(() => { if (!subscribe || !unsubscribe) return; let stopped = false; const timers: ReturnType[] = []; const rosterOf = (list: TeamWithMembers[] | undefined, id: string) => `${list?.find((team) => team.teamId === id)?.status ?? ""}#` + (list?.find((team) => team.teamId === id)?.members ?? []) .map((member) => `${member.memberRef}:${member.state}:${member.invitationGeneration ?? ""}`).sort().join("|"); const onRoster = (data: { teamId?: string } | undefined) => { const teamId = data?.teamId; if (!teamId || !teamsRef.current.some((team) => team.teamId === teamId)) return; const before = rosterOf(teamsRef.current, teamId); const read = async (left: number) => { const listed = await readAgain(); if (stopped || !listed || left === 0 || rosterOf(listed, teamId) !== 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, readAgain]); useEffect(() => { load(); }, [load]); const sides = useMemo( () => splitTeamsByOwner(teams, patientState?.id, proxies), [teams, patientState?.id, proxies], ); // Opening a team from the list reads the teams again, so it shows a change made since the list was read. const lastOpened = useRef(teamId); useEffect(() => { if (teamId && teamId !== lastOpened.current) void refresh(); lastOpened.current = teamId; }, [teamId, refresh]); const openOwn = teamId ? sides.mine.find((team) => team.teamId === teamId) : undefined; const openOther = teamId ? sides.others.find((other) => other.team.teamId === teamId) : undefined; // An id that is neither the patient's team nor one they belong to goes back to the list. useEffect(() => { if (!loading && !awaitingTenant && !error && teamId && !openOwn && !openOther) { navigate(LIST_PATH, { replace: true }); } }, [loading, awaitingTenant, error, teamId, openOwn, openOther, navigate]); const invitableTeams = useMemo(() => sides.mine.filter((team) => team.status !== "dissolved"), [sides]); // With one team to invite them to, it opens straight onto the invite. useEffect(() => { if (!loading && !error && !teamId && invite && invitableTeams.length === 1) { navigate(`${LIST_PATH}/${invitableTeams[0].teamId}${inviteQuery}`, { replace: true }); } }, [loading, error, teamId, invite, inviteQuery, invitableTeams, navigate]); // Opening another person's team from a link lands on the tab it belongs to. useEffect(() => { if (openOther) setSide("others"); }, [openOther]); const openCreateDialog = () => { setTeamName(""); setCreateError(null); setShowCreateDialog(true); }; const handleCreate = async (event: FormEvent) => { event.preventDefault(); const trimmed = teamName.trim(); if (!trimmed || !mayCreate) return; setCreating(true); setCreateError(null); try { // Messages can open the new team before the list read carries it. noteTeam(await createTeam(trimmed)); setShowCreateDialog(false); await load(); } catch (failure) { // The server's own reason (a sandbox refusal included), never an invitation to retry what cannot work. setCreateError(relayServerFailure(failure, "Failed to create the collaboration team. Please try again.")); } finally { setCreating(false); } }; const now = Date.now(); const openTeams = useMemo( () => [...sides.mine, ...sides.others.map((other) => other.team)].filter((team) => team.status !== "dissolved"), [sides], ); // Public thread only; the practitioners' aside is never fetched for a patient. const summaries = useCollabTeamSummaries(openTeams, { includeAside: false }); const ownerOf = (team: TeamWithMembers) => sides.others.find((other) => other.team.teamId === team.teamId)?.ownerName; // A heading says what the group means; a row says why that team is in it. const renderTeamRow = (team: TeamWithMembers, { reason }: { reason?: string } = {}) => { const pendingCount = team.members.filter((member) => member.state === "pending-patient-approval").length; const owner = ownerOf(team); // A quiet team keeps its card, sunken: it still opens, and dormancy restricts nothing. const quiet = teamDisplayStatus(team, now) !== "active"; const summary = summaries.get(team.teamId); const hasMessage = !!summary && summary.timestamp > 0; return ( {/* On Other teams, whose team it is tells one row from another. */} {owner && {owner}} {team.name} · {team.members.length} member{team.members.length === 1 ? "" : "s"} {!!summary?.unread && {summary.unread}} {pendingCount > 0 && ( {pendingCount} pending )} {/* A thread that could not be read is not a team where nobody wrote. */} {summary?.unreadable && ( Messages could not be loaded )} {reason ? ( {reason} ) : hasMessage ? ( {summary.preview} — {formatRelativeTime(summary.timestamp, now)} ) : null}