import { useCallback, useEffect, useId, useRef, useState } from "react"; import { OverlayActions } from "../../../components/ui/overlay-actions"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { RowStatus } from "../../../components/ui/row-meta"; import { useClinicianInvitationService, type ClinicianInvitation, type InvitationOutcome, type NewClinicianInvitation, } from "../../../services/ClinicianInvitationService"; import { heldInvitationOperation, heldInvitationNotice, heldOperationId, heldResends, holdInvitationOperation, invitationOperationHolder, invitationOutcomeNotice, invitationOutcomeUnknown, newInvitationOperationId, receiptOf, releaseInvitationOperation, retainInvitationReceipt, requestDigest, resendHolder, TRACKING_REFUSED, type InvitationResult, } from "../../../utils/staff/invitationOutcome"; import { isSaveRetired, useSaveScope } from "../../../utils/saveOperation"; import { invitationStateOf, openClinicianInvitations } from "../../../utils/clinicianInvitationState"; import { describeFetchFailure, describeWriteFailure } from "../../../utils/fetchFailureMessage"; import { readBackResend, RESEND_CHANGED, RESEND_UNCONFIRMED } from "../../../utils/staff/invitationResend"; import { usePractitionerRoleServiceContext } from "../../../services/PractitionerRoleService"; import { isClinicianEnabled } from "../../../utils/clinicianEnabled"; import { useDirtyReport } from "../../../hooks/useDirtyReport"; import { notifyPractitionerRolesChanged } from "../../../utils/practitionerRolesChanged"; import { HttpError } from "../../../services/HttpService"; import { CLINICAL_ROLE_GROUPS, clinicalRoleTemplate } from "../../../utils/clinicalRoleChoice"; import ClinicianReadinessPage from "./ClinicianReadinessPage"; import FirstClinicianSeat from "./FirstClinicianSeat"; import FirstClinicianAppointments from "./FirstClinicianAppointments"; import "./FirstClinicianPanel.css"; import { HeldInvitationHelp } from "../../../components/SetupHelpWidget/HeldInvitationHelp"; /** * Setup step 5: invite a clinician, or make yourself one. Nothing here assigns a seat until the person * holds a practitioner role and has signed in; then the included seat is assigned here. The step completes on readiness. */ type Read = "checking" | "failed" | ClinicianInvitation[]; type Mode = "invite" | "self"; const EMPTY: NewClinicianInvitation = { firstname: "", lastname: "", email: "", npi: "", personalMobile: "" }; const INVITEE_ELIGIBLE = "Invitation accepted, signed in, and practitioner role confirmed for this practice."; const SELF_ELIGIBLE = "Your practitioner role is confirmed for this practice."; export const FirstClinicianPanel = ({ onDirtyChange, showReadiness = true, offerDraft = true, onSetupChange, onModeChange, onSeatChange, signedInName, }: { onDirtyChange?: (dirty: boolean) => void; /** The setup step reports readiness beneath the invitations; the staff directory does not. */ showReadiness?: boolean; offerDraft?: boolean; // Lets the host sheet retitle itself for appointment setup; the panel then drops its own heading. onSetupChange?: (inSetup: boolean) => void; // Lets the host sheet title each mode the way the approved frames do. onModeChange?: (mode: Mode) => void; onSeatChange?: (inSeat: boolean) => void; // The self mode names the account it adds a practitioner role to. signedInName?: string | null; }) => { const service = useClinicianInvitationService(); const inviteFormId = useId(); const selfFormId = useId(); const [read, setRead] = useState("checking"); const [mode, setMode] = useState("invite"); const [showForm, setShowForm] = useState(false); const [draft, setDraft] = useState(EMPTY); const [selfNpi, setSelfNpi] = useState(""); const [roleCode, setRoleCode] = useState(""); const [selfRoleCode, setSelfRoleCode] = useState(""); const [saving, setSaving] = useState(false); const [busyId, setBusyId] = useState(null); const [problem, setProblem] = useState(null); // Help for a held invitation belongs to the one problem it came with; any other problem, or none, hides it. const [heldHelp, setHeldHelp] = useState<{ reference: string; opening: string; problem: string } | undefined>(); const [notice, setNotice] = useState(null); // The one invitation press whose answer is not known yet: this opening's, with the request it sent, or one held in // the tab from an earlier opening, which only Check or a deliberate Start over settles. const frozen = useRef<{ operationId: string; request: NewClinicianInvitation; send: boolean; digest: string; holder: string } | undefined>(undefined); const [unresolved, setUnresolved] = useState(() => heldOperationId(invitationOperationHolder("create"))); const [checking, setChecking] = useState(false); // One press at a time, decided before anything awaits. const inFlight = useRef(false); // Rows with a resend whose answer is not known, read again after each press. const [resendsHeld, setResendsHeld] = useState(0); const [openingId] = useState(() => newInvitationOperationId()); // Every invitation press belongs to this panel and the person signed in: once either moves on, nothing it learns is shown. const inviteScope = useSaveScope("first-clinician-invitations"); const [selfRole, setSelfRole] = useState<{ practitionerId: string; practitionerRoleId: string; seatKnown: boolean } | null>(null); const [seatFor, setSeatFor] = useState, "onAssigned" | "onBack"> | null>(null); const [setupFor, setSetupFor] = useState<{ practitionerId: string; practitionerRoleId: string; name: string } | null>(null); const [setupDirty, setSetupDirty] = useState(false); // A saved setup stays on screen; dropping back to the role form asked for a role already held. const [saved, setSaved] = useState<{ target: NonNullable; warning?: string } | null>(null); const { readPractitionerRole } = usePractitionerRoleServiceContext(); // A role that already holds a seat goes straight to appointment setup: offering the seat again would // price a second one for somebody already seated. const openSeatOrSetup = async (target: NonNullable) => { setProblem(null); setBusyId(target.practitionerRoleId); try { const role = await readPractitionerRole(target.practitionerRoleId); if (isClinicianEnabled(role)) { setSelfRole(null); setSetupFor({ practitionerId: target.practitionerId, practitionerRoleId: target.practitionerRoleId, name: target.name }); } else { setSeatFor(target); } } catch (error) { setProblem(describeFetchFailure(error, "This clinician's seat could not be checked")); } finally { setBusyId(null); } }; const dirty = Object.values(draft).some((value) => Boolean(value?.trim())) || Boolean(selfNpi.trim()) || (setupFor !== null && setupDirty); useDirtyReport(dirty, onDirtyChange); useEffect(() => { onSetupChange?.(setupFor !== null || saved !== null); }, [setupFor, saved, onSetupChange]); useEffect(() => { onModeChange?.(mode); }, [mode, onModeChange]); useEffect(() => { onSeatChange?.(seatFor !== null); }, [seatFor, onSeatChange]); const load = useCallback(async () => { setRead("checking"); try { setRead(await service.listInvitations()); } catch { setRead("failed"); } }, [service]); useEffect(() => { void load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // An accepted invitation says nothing about the seat; the invitee's role does, so read it per list. const [seats, setSeats] = useState>({}); useEffect(() => { if (!Array.isArray(read)) return; let live = true; const roleIds = [...new Set(read.filter((i) => i.status === "accepted" && i.practitionerRoleId).map((i) => i.practitionerRoleId as string))]; void Promise.all(roleIds.map(async (id) => [id, await readPractitionerRole(id).then(isClinicianEnabled, () => undefined)] as const)) .then((pairs) => { if (live) setSeats(Object.fromEntries(pairs)); }); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [read]); const replace = (next: ClinicianInvitation) => setRead((current) => (Array.isArray(current) ? [next, ...current.filter((invitation) => invitation.id !== next.id)] : [next])); const save = async (sendNow: boolean) => { setProblem(null); setNotice(null); const practitionerRole = clinicalRoleTemplate(roleCode); if (sendNow && !practitionerRole) { setProblem("Choose their clinical role."); return; } if (inFlight.current) return; inFlight.current = true; const request: NewClinicianInvitation = { firstname: draft.firstname.trim(), lastname: draft.lastname.trim(), email: draft.email.trim(), ...(draft.npi?.trim() && { npi: draft.npi.trim() }), ...(draft.personalMobile?.trim() && { personalMobile: draft.personalMobile.trim() }), ...(practitionerRole && { practitionerRole }), }; const digest = requestDigest({ request, send: sendNow }); // A press whose answer is not known is repeated only exactly as it was first sent; anything else waits for it. const holder = invitationOperationHolder("create"); const pending = frozen.current; const held = heldInvitationOperation(holder); const refuse = (text: string) => { inFlight.current = false; setProblem(text); }; if (pending && pending.holder !== holder) return refuse("This invitation belongs to the earlier signed-in person and practice. Return there to check it."); if (pending && pending.digest !== digest) { return refuse("This invitation has changed since the invitation whose result is not known. Check that one first, or put its details back to send it again exactly as it was."); } if (!pending && held === "unreadable") return refuse(TRACKING_REFUSED); if (!pending && typeof held === "object") { setUnresolved(held.operationId); return refuse("An earlier invitation from this tab was not confirmed. Check the earlier invitation first, or start a new invitation instead."); } const operationId = pending?.operationId ?? newInvitationOperationId(); const sent = pending ? { request: pending.request, send: pending.send } : { request, send: sendNow }; if (!pending && !holdInvitationOperation(holder, { operationId, opening: openingId })) return refuse(TRACKING_REFUSED); setSaving(true); const press = inviteScope.start(); const name = `${sent.request.firstname} ${sent.request.lastname}`.trim() || sent.request.email; const unknown = () => { frozen.current = { operationId, ...sent, digest, holder }; setUnresolved(operationId); }; try { const outcome = await service.createInvitation(sent.request, { send: sent.send, operationId, ...(typeof held === "object" && held.receipt?.invitationId && { originalInvitationId: held.receipt.invitationId }), transport: press.transport }); press.admit(); if (outcome.receipt) retainInvitationReceipt(holder, operationId, outcome.receipt); if (receiptOf(outcome).save !== "saved") { unknown(); setProblem(invitationOutcomeNotice(outcome, name).text); return; } settledCreate(operationId, outcome, name); } catch (error) { if (isSaveRetired(error) || !press.belongs()) return; // NO ANSWER IS NOT A NO. It may have saved and sent, so this press is read back by its operation, through its // own transport, never repeated to find out. The entries stay in the form either way. if (invitationOutcomeUnknown(error) || !(error instanceof HttpError)) { unknown(); let checked = service.checkInvitationOperation ? await Promise.resolve().then(() => service.checkInvitationOperation(operationId, { transport: press.transport })).catch(() => undefined) : undefined; if (!press.belongs()) return; try { if (checked?.receipt) retainInvitationReceipt(holder, operationId, checked.receipt); } catch { checked = undefined; } if (checked?.receipt.save === "saved") return settledCreate(operationId, checked, name); setProblem("We could not confirm whether the invitation was saved or sent. Press it again to repeat this same invitation; no second one is sent."); return; } if ((error.body as { reason?: string } | undefined)?.reason !== "operation-mismatch") { releaseInvitationOperation(holder, operationId); if (frozen.current?.operationId === operationId) frozen.current = undefined; setUnresolved(undefined); } const text = describeWriteFailure(error, "The invitation was not saved"); const held = error.body as { reason?: string; recovery?: { reference?: unknown } } | undefined; setHeldHelp(held?.reason === "invitation-claim-held" && typeof held.recovery?.reference === "string" ? { reference: held.recovery.reference, opening: operationId, problem: text } : undefined); setProblem(text); } finally { inFlight.current = false; if (press.belongs()) setSaving(false); } }; // A create whose result is known: the press is done, and the form gives way to what was saved. const settledCreate = (operationId: string, outcome: InvitationResult, name: string) => { releaseInvitationOperation(invitationOperationHolder("create"), operationId); frozen.current = undefined; setUnresolved(undefined); if (outcome.invitation) replace(outcome.invitation); else void load(); setDraft(EMPTY); setRoleCode(""); setShowForm(false); setNotice(outcome.existing ? `An invitation to ${outcome.invitation?.email ?? draft.email.trim()} is already open, so nothing new was sent.` : invitationOutcomeNotice(outcome, name).text); }; // Reads what the unresolved create did, through its own transport. It sends, saves and rotates nothing. const checkUnresolved = async () => { const operationId = unresolved; if (!operationId || inFlight.current) return; inFlight.current = true; setChecking(true); setProblem(null); const press = inviteScope.start(); try { const outcome = await press.step(() => service.checkInvitationOperation(operationId, { transport: press.transport })); retainInvitationReceipt(invitationOperationHolder("create"), operationId, outcome.receipt); if (outcome.receipt.save === "saved") settledCreate(operationId, outcome, outcome.invitation ? `${outcome.invitation.firstname} ${outcome.invitation.lastname}`.trim() : ""); else setProblem("Still not confirmed. The invitation may yet have been saved, so check again in a moment."); } catch (error) { if (isSaveRetired(error) || !press.belongs()) return; setProblem("The invitation could not be checked just now. Try Check again in a moment."); } finally { inFlight.current = false; if (press.belongs()) setChecking(false); } }; // A deliberate choice to stop waiting on the unresolved create. The server keeps whatever it did; a new invitation // to the same address finds and reuses it rather than making a second. const startOver = () => { if (unresolved) releaseInvitationOperation(invitationOperationHolder("create"), unresolved); frozen.current = undefined; setUnresolved(undefined); setProblem(null); }; // Whether this row has a resend whose answer is not known, as the tab holds it (an unreadable record counts). const resendHeld = (invitationId: string): boolean => { void resendsHeld; const held = heldResends(invitationId); return held === "unreadable" || held.length > 0; }; // Reads what this row's earlier, unconfirmed resends did, through the check's own transport. It sends and rotates // nothing, and lets go of each resend only once its result is known. const checkEarlier = async (invitation: ClinicianInvitation) => { const earlier = heldResends(invitation.id); if (earlier === "unreadable" || inFlight.current) return; inFlight.current = true; setProblem(null); setNotice(null); setBusyId(invitation.id); const press = inviteScope.start(); const name = `${invitation.firstname} ${invitation.lastname}`.trim() || invitation.email; try { for (const operationId of earlier) { const checked = await press.step(() => service.checkInvitationOperation(operationId, { transport: press.transport })).catch((error) => { if (isSaveRetired(error)) throw error; return undefined; }); if (checked?.receipt.save === "saved" && checked.receipt.send.state !== "unconfirmed") { releaseInvitationOperation(resendHolder(invitation.id, operationId), operationId); if (checked.invitation) replace(checked.invitation); setNotice(invitationOutcomeNotice(checked, name).text); } } const left = heldResends(invitation.id); if (left === "unreadable" || left.length > 0) setProblem(RESEND_UNCONFIRMED); } catch (error) { if (isSaveRetired(error) || !press.belongs()) return; setProblem(RESEND_UNCONFIRMED); } finally { inFlight.current = false; setResendsHeld((n) => n + 1); if (press.belongs()) setBusyId(null); } }; const send = (event: React.FormEvent) => { event.preventDefault(); void save(true); }; const act = async (invitation: ClinicianInvitation, kind: "resend" | "revoke") => { if (inFlight.current) return; inFlight.current = true; setProblem(null); setNotice(null); const name = `${invitation.firstname} ${invitation.lastname}`.trim() || invitation.email; const operationId = newInvitationOperationId(); const holder = resendHolder(invitation.id, operationId); if (kind === "resend" && (heldResends(invitation.id) === "unreadable" || !holdInvitationOperation(holder, { operationId, opening: openingId }))) { inFlight.current = false; return setProblem(TRACKING_REFUSED); } setBusyId(invitation.id); const press = inviteScope.start(); const shown = (outcome: InvitationOutcome) => { if (outcome.invitation) replace(outcome.invitation); setNotice(invitationOutcomeNotice(outcome, name).text); }; try { if (kind === "revoke") { const withdrawn = await service.revokeInvitation(invitation.id, { transport: press.transport }); press.admit(); replace(withdrawn); return; } const outcome = await service.resendInvitation(invitation.id, { operationId, transport: press.transport }); press.admit(); if (outcome.receipt.send.state !== "unconfirmed") releaseInvitationOperation(holder, operationId); shown(outcome); } catch (error) { if (isSaveRetired(error) || !press.belongs()) return; if (kind === "resend" && invitationOutcomeUnknown(error)) { const checked = service.checkInvitationOperation ? await Promise.resolve().then(() => service.checkInvitationOperation(operationId, { transport: press.transport })).catch(() => undefined) : undefined; if (!press.belongs()) return; if (checked?.receipt.save === "saved" && checked.receipt.send.state !== "unconfirmed") { releaseInvitationOperation(holder, operationId); return shown(checked); } const read = await readBackResend(invitation, () => service.listInvitations({ transport: press.transport })); if (!press.belongs()) return; if (read.kind === "changed") replace(read.invitation); return setProblem(read.kind === "changed" ? RESEND_CHANGED : RESEND_UNCONFIRMED); } if (kind === "resend") releaseInvitationOperation(holder, operationId); setProblem(describeWriteFailure(error, kind === "resend" ? "The invitation was not sent again" : "The invitation was not revoked")); } finally { inFlight.current = false; setResendsHeld((n) => n + 1); if (press.belongs()) setBusyId(null); } }; const makeMyself = async (event: React.FormEvent) => { event.preventDefault(); setProblem(null); const practitionerRole = clinicalRoleTemplate(selfRoleCode); if (!practitionerRole) { setProblem("Choose your clinical role."); return; } setSaving(true); try { const result = await service.addMyPractitionerRole(selfNpi.trim() || undefined, practitionerRole); setSelfNpi(""); setSelfRoleCode(""); const target = { practitionerId: result.practitionerId, practitionerRoleId: result.practitionerRoleId }; const role = await readPractitionerRole(result.practitionerRoleId).catch(() => undefined); const seated = role ? isClinicianEnabled(role) : undefined; if (role) notifyPractitionerRolesChanged(role); if (seated) setSetupFor({ ...target, name: "You" }); else setSelfRole({ ...target, seatKnown: seated === false }); } catch (error) { setProblem(describeWriteFailure(error, "Your practitioner role was not added")); } finally { setSaving(false); } }; const field = (key: "firstname" | "lastname" | "email" | "npi" | "personalMobile", label: string, props: React.ComponentProps = {}, hint?: string) => (
{/* ak-field gives every type, tel included, the shared field shell. */} setDraft((current) => ({ ...current, [key]: event.target.value }))} disabled={saving} {...props} /> {hint &&

{hint}

}
); const roleField = (id: string, value: string, onChange: (code: string) => void, placeholder: string, hint: string) => (

{hint}

); // A receptionist invited from Staff is not who this step waits on (R1 #6/#48). const invitations = Array.isArray(read) ? openClinicianInvitations(read) : []; const formOpen = mode === "invite" && Array.isArray(read) && (showForm || invitations.length === 0); const composing = formOpen || (mode === "self" && !selfRole); if (setupFor) { return (
setSetupFor(null)} onDone={(warning) => { notifyPractitionerRolesChanged(); setSaved({ target: setupFor, warning }); setSetupFor(null); }} />
); } if (saved) { return (
Appointment setup is saved. The step completes once this clinician can take appointments.{saved.warning ? " " + saved.warning : ""}
{showReadiness && (

Clinician appointment readiness

)}
); } if (seatFor) { return (
setSeatFor(null)} onAssigned={() => { setSeatFor(null); setSelfRole(null); setSetupFor({ practitionerId: seatFor.practitionerId, practitionerRoleId: seatFor.practitionerRoleId, name: seatFor.name }); void load(); notifyPractitionerRolesChanged(); }} />
); } return (
{problem && ( setProblem(null)}> {problem} {heldHelp?.problem === problem && } )} {notice && setNotice(null)}>{notice}} {unresolved && !frozen.current && ( {heldInvitationNotice()} )} {read === "checking" &&

Checking invitation status…

} {read === "failed" && (

We could not check your invitations, so nothing is shown rather than a list that may be wrong.

)} {!composing && invitations.length > 0 && (
{invitations.map((invitation) => { const state = invitationStateOf(invitation, { seated: invitation.practitionerRoleId ? seats[invitation.practitionerRoleId] : undefined }); const busy = busyId === invitation.id; return (

{invitation.firstname} {invitation.lastname}

{state.words}

{state.detail}

{resendHeld(invitation.id) && (

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

)} {resendHeld(invitation.id) && heldResends(invitation.id) !== "unreadable" && ( )}
{state.action === "send" && ( )} {state.action === "retry" && ( )} {state.action === "resend" && ( )} {state.action === "renew" && ( )} {state.action === "assign-seat" && invitation.practitionerId && invitation.practitionerRoleId && ( )} {state.action === "set-up" && invitation.practitionerId && invitation.practitionerRoleId && ( )} {state.revocable && ( )}
); })}
)} {Array.isArray(read) && invitations.length > 0 && !showForm && mode === "invite" && ( )} {formOpen && (

Send an invitation to their work email. They can accept using an existing Akashic account, even if they sign in with a different email.

{field("firstname", "First name", { required: true, autoComplete: "given-name" })} {field("lastname", "Last name", { required: true, autoComplete: "family-name" })}
{field("email", "Invitation email", { required: true, type: "email", autoComplete: "email" }, "The work inbox where they should receive this invitation.")} {roleField("first-clinician-role", roleCode, setRoleCode, "Choose their clinical role", "Opens their clinical workspace when they sign in.")} {field("npi", "NPI, if held", { inputMode: "numeric", placeholder: "Enter their individual NPI" }, "Helps identify duplicate practitioner records. A match does not automatically link them.")} {field("personalMobile", "Personal mobile (optional)", { type: "tel", placeholder: "Enter a personal mobile number" }, "An additional identity-matching clue, not a public practice contact.")}
An invitation does not assign a seat. After they accept, sign in and hold a practitioner role, you can assign the included seat.

{offerDraft && ( )}
)} {mode === "self" && !selfRole && (
{signedInName &&

{signedInName} · signed in

}
Your Admin role stays unchanged. Add a practitioner role to your existing account before assigning a clinician seat. No invitation is needed.
setSelfNpi(event.target.value)} disabled={saving} />

We check whether this person already has an Akashic record. Any possible match must be resolved before continuing.

{roleField("first-clinician-self-role", selfRoleCode, setSelfRoleCode, "Choose your clinical role", "Opens your clinical workspace beside your Admin one.")}

This action does not assign a seat. Once your practitioner role is confirmed, you can assign the included seat.

{/* Adding yourself is an option beside inviting, never the step itself; the way back stays open. */}

)} {selfRole && (
Your practitioner role is confirmed. {selfRole.seatKnown ? "No seat is assigned yet. You can assign the included seat now." : "We could not check whether you already hold a clinician seat."}
)} {/* The approved invite and self frames show the form alone; readiness has its own door. */} {showReadiness && !formOpen && !(mode === "self" && !selfRole) && (

Clinician appointment readiness

)}
); }; export default FirstClinicianPanel;