import { useCallback, useEffect, useRef, useState } from "react"; import { useClinicianInvitationService, type ClinicianInvitation, type InvitationOutcome, } from "../../../services/ClinicianInvitationService"; import { heldResends, holdInvitationOperation, newInvitationOperationId, invitationOutcomeNotice, invitationOutcomeUnknown, releaseInvitationOperation, resendHolder, TRACKING_REFUSED, type OutcomeTone, } from "../../../utils/staff/invitationOutcome"; import { isSaveRetired, useSaveScope } from "../../../utils/saveOperation"; import { RowStatus, type RowStatusTone } from "../../../components/ui/row-meta"; import { PersonBadge } from "../../../components/ui/person-badge"; import { Button } from "../../../components/ui/button.tsx"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../../../components/ui/sheet"; import { describeWriteFailure } from "../../../utils/fetchFailureMessage"; import { readBackResend, RESEND_CHANGED, RESEND_UNCONFIRMED } from "../../../utils/staff/invitationResend"; import { usePracticeUnlocked } from "../../../utils/practiceUnlocked"; import { invitesAClinician } from "../../../utils/clinicianInvitationState"; const INVITATION_REREAD_MS = 60_000; const isOpen = (invitation: ClinicianInvitation): boolean => invitation.status === "pending" || invitation.status === "expired"; /** Still waited on: not yet accepted, or accepted without the membership having been recorded. */ export const isAwaited = (invitation: ClinicianInvitation): boolean => isOpen(invitation) || (invitation.status === "accepted" && !invitation.practitionerRoleId); /** Each state an HR reader has to act on differently, kept distinct rather than folded into "pending". */ export const invitationState = (invitation: ClinicianInvitation): { text: string; tone: RowStatusTone } => { if (invitation.status === "revoked") return { text: "Invitation withdrawn", tone: "plain" }; if (invitation.status === "accepted" && invitation.practitionerRoleId) return { text: "Joined", tone: "done" }; if (invitation.status === "accepted") return { text: "Accepted - joining not finished", tone: "attention" }; if (invitation.attention === "identity-conflict") return { text: "Identity needs attention", tone: "attention" }; if (invitation.status === "expired") return { text: "Invitation expired", tone: "attention" }; if (invitation.delivery === "failed") return { text: "Invitation not delivered", tone: "attention" }; if (invitation.delivery === "unconfirmed") return { text: "Invitation email not confirmed", tone: "attention" }; if (invitation.delivery === "not-sent" || invitation.delivery === "draft") return { text: "Invitation not sent", tone: "attention" }; return { text: "Invitation pending", tone: "plain" }; }; /** Sent and not yet answered. A draft, a send never made and one never confirmed are not waiting on anyone yet. */ const isSentPending = (invitation: ClinicianInvitation): boolean => invitation.status === "pending" && invitation.delivery === "queued"; /** * Accepted, with a role, but that role is not on the staff list yet. The staff list is cached for the * session, so without this row the person is on neither list until a reload (ed-1 J1, 2026-10-03). */ export const joinedNotYetListed = (invitations: ClinicianInvitation[], listedRoleIds: ReadonlySet): ClinicianInvitation[] => invitations.filter((invitation) => invitation.status === "accepted" && Boolean(invitation.practitionerRoleId) && !listedRoleIds.has(invitation.practitionerRoleId as string)); /** The most recently sent clinician invitation still waiting, for the first-clinician step to name. */ export const latestPendingInvitee = (invitations: ClinicianInvitation[]): { name: string; email: string } | undefined => { const latest = invitations.filter((invitation) => isSentPending(invitation) && invitesAClinician(invitation)).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; return latest && { name: invitationName(latest) || latest.email, email: latest.email }; }; /** The most recent clinician who accepted and joined, for the first-clinician step to name until they can be booked. */ export const latestJoinedInvitee = (invitations: ClinicianInvitation[]): { name: string } | undefined => { const latest = invitations .filter((invitation) => invitation.status === "accepted" && Boolean(invitation.practitionerRoleId) && invitesAClinician(invitation)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; return latest && { name: invitationName(latest) || latest.email }; }; /** The dashboard's count of sent, unanswered invitations, and of accepted ones whose joining is not finished. */ export const pendingInvitationsLine = (invitations: ClinicianInvitation[], unreadable: boolean): string | undefined => { if (unreadable) return "Couldn't load invitations"; const pending = invitations.filter(isSentPending).length; const joining = invitations.filter((invitation) => invitation.status === "accepted" && !invitation.practitionerRoleId).length; const parts = [ pending > 0 && `${pending} pending ${pending === 1 ? "invitation" : "invitations"}`, joining > 0 && `${joining} accepted, joining not finished`, ].filter(Boolean); return parts.length > 0 ? parts.join(" ยท ") : undefined; }; export const invitationName = (invitation: ClinicianInvitation): string => `${invitation.firstname} ${invitation.lastname}`.trim(); /** An unreadable list is reported as unreadable, never as nobody waiting. */ export const useStaffInvitations = (enabled: boolean) => { const { listInvitations } = useClinicianInvitationService(); const [invitations, setInvitations] = useState([]); const [unreadable, setUnreadable] = useState(false); const [reads, setReads] = useState(0); usePracticeUnlocked(() => { if (unreadable) setReads((n) => n + 1); }); useEffect(() => { if (!enabled) return; let cancelled = false; listInvitations() .then((list) => { if (cancelled) return; setInvitations(list); setUnreadable(false); }) .catch(() => { if (!cancelled) setUnreadable(true); }); return () => { cancelled = true; }; }, [enabled, listInvitations, reads]); // An acceptance happens elsewhere, so the list is re-read while someone may still accept, and on return to the tab. const answerable = invitations.some((invitation) => invitation.status === "pending" || (invitation.status === "accepted" && !invitation.practitionerRoleId)); useEffect(() => { if (!enabled) return; const reread = () => { if (document.visibilityState === "visible") setReads((n) => n + 1); }; document.addEventListener("visibilitychange", reread); const timer = answerable ? window.setInterval(reread, INVITATION_REREAD_MS) : undefined; return () => { document.removeEventListener("visibilitychange", reread); if (timer !== undefined) window.clearInterval(timer); }; }, [enabled, answerable]); const replace = useCallback((next: ClinicianInvitation) => { setInvitations((list) => list.map((invitation) => (invitation.id === next.id ? next : invitation))); }, []); return { invitations, unreadable, replace }; }; export const StaffInvitationRow = ({ invitation, roleSummary, onOpen, }: { invitation: ClinicianInvitation; roleSummary: string; onOpen: () => void; }) => { const state = invitationState(invitation); return (
{ if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onOpen(); }} className="ahr-list-card staff-directory-row group cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" >

{invitationName(invitation)}

{roleSummary}
{state.text}
); }; export const StaffInvitationSheet = ({ invitation, roleSummary, onClose, onChanged, }: { invitation: ClinicianInvitation; roleSummary: string; onClose: () => void; onChanged: (next: ClinicianInvitation) => void; }) => { const { resendInvitation, revokeInvitation, listInvitations, checkInvitationOperation } = useClinicianInvitationService(); const [busy, setBusy] = useState(false); const [error, setError] = useState(); const [notice, setNotice] = useState<{ tone: OutcomeTone; text: string } | undefined>(); // Resends of this invitation whose answers are not known, kept in the tab: another may still be sent, but only as a // deliberate new link, and reopening the sheet still says so. const [earlier, setEarlier] = useState(() => heldResends(invitation.id)); const unconfirmed = earlier === "unreadable" || earlier.length > 0; const state = invitationState(invitation); const open = isOpen(invitation); const scope = useSaveScope(invitation.id); const name = invitationName(invitation) || invitation.email; // One press at a time, decided before anything awaits. const inFlight = useRef(false); const shown = (outcome: InvitationOutcome) => { if (outcome.invitation) onChanged(outcome.invitation); setNotice(invitationOutcomeNotice(outcome, name)); }; const resend = async () => { if (inFlight.current) return; inFlight.current = true; setError(undefined); setNotice(undefined); const operationId = newInvitationOperationId(); const holder = resendHolder(invitation.id, operationId); if (heldResends(invitation.id) === "unreadable" || !holdInvitationOperation(holder, { operationId, opening: invitation.id })) { inFlight.current = false; setError(TRACKING_REFUSED); return; } setBusy(true); const save = scope.start(); try { const outcome = await resendInvitation(invitation.id, { operationId, transport: save.transport }); save.admit(); if (outcome.receipt.send.state !== "unconfirmed") releaseInvitationOperation(holder, operationId); setEarlier(heldResends(invitation.id)); shown(outcome); } catch (failure) { if (isSaveRetired(failure) || !save.belongs()) return; if (!invitationOutcomeUnknown(failure)) { releaseInvitationOperation(holder, operationId); return setError(describeWriteFailure(failure, "Could not send the invitation again")); } // Lost: read what this resend did, by its operation and through its transport. Never repeated to find out. const checked = checkInvitationOperation ? await save.step(() => checkInvitationOperation(operationId, { transport: save.transport })).catch(() => undefined) : undefined; if (!save.belongs()) return; if (checked?.receipt.save === "saved" && checked.receipt.send.state !== "unconfirmed") { releaseInvitationOperation(holder, operationId); setEarlier(heldResends(invitation.id)); return shown(checked); } setEarlier(heldResends(invitation.id)); const read = await readBackResend(invitation, () => listInvitations({ transport: save.transport })); if (!save.belongs()) return; if (read.kind === "changed") onChanged(read.invitation); setError(read.kind === "changed" ? RESEND_CHANGED : RESEND_UNCONFIRMED); } finally { inFlight.current = false; if (save.belongs()) setBusy(false); } }; // Reads what each earlier, unconfirmed resend did. It sends and rotates nothing. const checkEarlier = async () => { if (inFlight.current || earlier === "unreadable") return; inFlight.current = true; setError(undefined); setBusy(true); const save = scope.start(); try { for (const operationId of earlier) { const checked = await save.step(() => checkInvitationOperation(operationId, { transport: save.transport })).catch((failure) => { if (isSaveRetired(failure)) throw failure; return undefined; }); if (checked?.receipt.save === "saved" && checked.receipt.send.state !== "unconfirmed") { releaseInvitationOperation(resendHolder(invitation.id, operationId), operationId); shown(checked); } } setEarlier(heldResends(invitation.id)); if (heldResends(invitation.id) !== "unreadable" && (heldResends(invitation.id) as string[]).length > 0) setError(RESEND_UNCONFIRMED); } catch (failure) { if (!isSaveRetired(failure) && save.belongs()) setError(RESEND_UNCONFIRMED); } finally { inFlight.current = false; if (save.belongs()) setBusy(false); } }; // A withdrawal belongs to this sheet and the person signed in; a late answer after either moved on changes nothing. const revoke = async () => { if (inFlight.current) return; inFlight.current = true; setBusy(true); setError(undefined); setNotice(undefined); const save = scope.start(); try { const withdrawn = await revokeInvitation(invitation.id, { transport: save.transport }); save.admit(); onChanged(withdrawn); } catch (failure) { if (isSaveRetired(failure) || !save.belongs()) return; setError(describeWriteFailure(failure, "Could not withdraw the invitation")); } finally { inFlight.current = false; if (save.belongs()) setBusy(false); } }; return ( { if (!open) onClose(); }}> {invitationName(invitation)} {open ? `${state.text} - they join your staff when they accept` : state.text}
{error && ( setError(undefined)}>
{error}
)} {notice && ( setNotice(undefined)}> {notice.text} )}

{roleSummary}

Invited at {invitation.email}

{invitation.status === "expired" ? "The link has expired. Send it again to give them a new one." : `The link works until ${new Date(invitation.expiresAt).toLocaleDateString()}.`}

Nothing is added to your practice until they accept, and they get no access before then.

{!open && invitation.status === "accepted" && (

They accepted, but joining did not finish. It completes when they open the invitation link again.

)} {open && unconfirmed && (

Sending another link starts a new email. The earlier one may still arrive, and only the newest link will work.

)} {open && (
{unconfirmed && earlier !== "unreadable" && ( )}
)}
); };