import { onViewerChange, viewerEpoch } from "../../../utils/writeThroughHold"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useNavigate } from "react-router-dom"; 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 type { CollaborationTeam, TeamMembership, TeamActionRequest, TeamActionResponse } from "../../../services"; import { usePatientInbox } from "../../../components/PatientInbox/patientInboxContext"; type TeamWithMembers = CollaborationTeam & { members: TeamMembership[] }; interface TeamLifecycleSectionProps { team: TeamWithMembers; teamAction: (teamId: string, request: TeamActionRequest) => Promise; onChanged: () => Promise; } // Team-status-driven lifecycle controls (Task 9 brief): an active team can withdraw or dissolve, a // dormant team can rejoin or dissolve, and a dissolved team gets neither. Withdraw and Dissolve both // leave the patient's active view of this team, so both navigate back to the list afterward; Rejoin // keeps the patient on this page and just refreshes state back to "active". Dissolve is the one // irreversible action, so - unlike Withdraw/Rejoin - it's gated behind a ui/alert-dialog confirm // instead of firing on a single click (mirrors LocationsPage's delete-room confirm: the destructive // action is a plain Button inside the dialog, not AlertDialogAction, so it stays open on failure). export const TeamLifecycleSection = ({ team, teamAction, onChanged }: TeamLifecycleSectionProps) => { const navigate = useNavigate(); // The inbox's copy of this team takes the action's answer, so its thread stops offering a composer at once. const { noteTeam } = usePatientInbox(); const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); const key = JSON.stringify([team.teamId, team.patientId, epoch]); const opening = useRef({ key }); if (opening.current.key !== key) opening.current = { key }; const owner = opening.current; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const retained = useRef<{ owner: typeof owner; request: TeamActionRequest }>(); const inflight = useRef(); const [state, setState] = useState<{ owner: typeof owner; busy: boolean; error?: string; hold?: 'unknown' | 'owed' | 'changed'; settled?: string; dissolve: boolean; reactivate: boolean }>({ owner, busy: false, dissolve: false, reactivate: false }); const current = state.owner === owner ? state : { owner, busy: false, dissolve: false, reactivate: false }; if (retained.current?.owner !== owner) retained.current = undefined; const stored = team.lastDecision; if (!retained.current && team.lifecycleCleanupOwed && stored?.cleanup === 'owed' && current.settled !== stored.id) { retained.current = { owner, request: { action: stored.action, basis: stored.basis, decisionId: stored.id, ...(stored.name !== undefined ? { name: stored.name } : {}) } }; } const pendingRequest = retained.current?.request; const hold = current.hold ?? (pendingRequest && team.lifecycleCleanupOwed ? 'owed' : undefined); const { busy, error, dissolve: confirmDissolve, reactivate: confirmReactivate } = current; const belongs = () => mounted.current && opening.current === owner && viewerEpoch() === epoch; const publish = (next: Partial) => { if (belongs()) setState(previous => ({ ...(previous.owner === owner ? previous : current), ...next, owner })); }; const setConfirmDissolve = (value: boolean) => publish({ dissolve: value }); const setConfirmReactivate = (value: boolean) => publish({ reactivate: value }); const run = async (action?: TeamActionRequest['action']) => { if (!belongs() || inflight.current === owner || busy || hold === 'changed') return; if (!pendingRequest && !action) return; const request = pendingRequest ?? { action: action!, basis: team.lifecycleRevision ?? 0, decisionId: crypto.randomUUID() }; retained.current = { owner, request }; inflight.current = owner; publish({ busy: true, error: undefined }); try { const answer = await teamAction(team.teamId, request); if (!belongs()) return; if (!answer || answer.team?.teamId !== team.teamId || answer.team.patientId !== team.patientId || !['committed', 'adopted'].includes(answer.decision) || !['complete', 'owed'].includes(answer.cleanup)) { throw new Error('The original team decision could not be confirmed'); } const owed = answer.cleanup === 'owed'; publish({ hold: owed ? 'owed' : undefined, settled: owed ? undefined : request.decisionId, dissolve: false, reactivate: false }); if (!owed) retained.current = undefined; noteTeam(answer.team); if (!belongs()) return; await onChanged(); if (!belongs()) return; if (!owed && (request.action === 'withdraw' || request.action === 'dissolve')) navigate('/dashboard/collaboration-teams'); } catch (failure) { if (!belongs()) return; const code = (failure as { body?: { code?: string } })?.body?.code; if (code === 'TEAM_CHANGED') publish({ hold: 'changed', error: 'This team changed. Reload it before trying again.' }); else if (code === 'TEAM_CLEANUP_OWED') publish({ hold: 'changed', error: 'Continue the original team action before making another change.' }); else publish({ hold: 'unknown', error: 'We could not confirm this team action. Try again to check the same decision.' }); } finally { if (belongs() && inflight.current === owner) { inflight.current = undefined; publish({ busy: false }); } } }; const handleWithdraw = () => run('withdraw'); const handleRejoin = () => run('rejoin'); const handleDissolve = () => run('dissolve'); const handleReactivate = () => run('reactivate'); const continuation = pendingRequest && (hold === 'unknown' || hold === 'owed') ?
{hold === 'owed' &&

Done. Some members are still being updated; continue to finish.

}
: null; if (team.status === "dissolved") { return ( {continuation}

This team has been dissolved.

{error &&

{error}

} { if (!busy) setConfirmReactivate(open); }} > Reactivate “{team.name}”? This reopens the team. Practitioners who were on it will be re-invited and need to accept again before they can take part. Keep it closed
); } return ( {continuation} {/* THE DECK'S WORDS, AND IT CHOSE THEM FOR A REASON IT WRITES DOWN: "a patient reaching for the exit may want the reversible door". "Step back" sounds reversible and "Withdraw" does not, which matters most to somebody who is about to press one of these by mistake. */} {/* The section heading as the deck writes one: a label rather than a title, so the exits do not compete with the people above them. Muted rather than the deck's --faint, which our own token file reserves for large text and borders. */}

Ending it

{error &&

{error}

} {/* BOTH EXITS WERE BARE TEXT, WHICH IS THE WORST POSSIBLE ARRANGEMENT FOR THIS PAIR. A variant does not survive the portal's button reset - it zeroes background, border, shadow and colour at id specificity, and that outranks every utility class - so an outline Step back and a destructive Dissolve rendered as two identical words. The one that ends the team for everybody looked exactly like the one that does not. The classes are the deck's own, taken off the frame: a ghost blue for the reversible door and a ghost red for the irreversible one, which is the button standard's rule that the colour is the purpose. And the deck's wording with them, Dissolve the team rather than Dissolve, because the object of the verb is the thing worth being sure about. */}
{team.status === "active" && ( )} {team.status === "dormant" && ( )}
{ if (!busy) setConfirmDissolve(open); }} > Dissolve “{team.name}”? This closes the team for every member and ends the access it gave them. Its messages stay on record, and you can reactivate it later. Keep the team
); }; export default TeamLifecycleSection;