import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { AlertTriangle, ChevronDown, Lock } from 'lucide-react'; import type { Attachment, Organization, Practitioner, PractitionerRole, Schedule } from 'fhir/r4'; import { Card, CardContent } from '../ui/card'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { getOnboardingStatus, setOnboardingTaskStatus } from '../../utils/orgOnboardingStatus'; import { buildOrgSystemTasks, resolveOnboarding } from '../../utils/orgSystemTasks'; import { readOperatingScope } from '../../utils/operatingScope'; import type { OrgSystemTaskStatus, ResolvedOrgTask } from '../../utils/orgSystemTasks'; import { readSiteTransportSecure } from '../../utils/siteTransport'; import { COLLAPSED_VALUE, collapsedFromStored, onboardingCollapseKey } from './onboardingCollapse'; import { BUILT_IN_WORKSPACE_OPTIONS, getPractitionerRoleWorkspaces, getOrganizationRoleCodes, getWorkspaceFormValue, STAFF_WORKSPACES, type StaffWorkspace, } from '../../utils/staffRoleCatalog'; import { getProviderReadiness } from '../../utils/practitionerBookingReadiness'; import { isClinicianEnabled } from '../../utils/clinicianEnabled'; import { useStaffBookabilityContext } from '../../hooks/useStaffBookabilityContext'; import { useServiceCatalogueBasis } from '../../hooks/useServiceCatalogueBasis'; import { useAuthServiceContext, useStaffPermissionServiceContext } from '../../services'; import OnboardingTaskFocus from './OnboardingTaskFocus'; import ProviderReadinessAlert from './ProviderReadinessAlert'; import RoleSetupModal from '../RoleSetup/RoleSetupModal'; export type OrgOnboardingPractitioner = { practitioner: Practitioner; roles: PractitionerRole[]; }; export type OrgOnboardingProps = { organization: Organization; locationCount: number; /** - sites for which `resolveBookingTimeZone` returns null. */ sitesMissingTimezone: number; practitioners: OrgOnboardingPractitioner[]; schedulesList: Schedule[]; bookingMode: 'direct' | 'review' | 'request-only'; questionnaireCount: number; publicFormCount: number; portalPagesConfigured: boolean; // False until the org's schedules have finished loading - the readiness alert stays hidden until then // so it can't flash "can't be booked" against a momentarily-empty schedule list. schedulesLoaded: boolean; // - `undefined` until the BAA record has answered, and the step is then omitted rather than // shown as outstanding. Never `?? false` at the call site: that is the contradiction itself. baaActive?: boolean; // - whether this practice has a designated owner. Same contract as baaActive above and for the // same reason: `undefined` means NOT ANSWERED and the step is omitted, never shown as outstanding. // Never `?? false` at the call site - this step gates the BAA, so a false accusation here stalls the // one sequence that has to run before a practice can open. ownerAssigned?: boolean; onOpenContact: () => void; onOpenPracticeTypes: () => void; onOpenLocation: () => void; onOpenPolicies: () => void; onUploadLogo: (photo: Attachment) => Promise; onPersist: (updated: Organization) => Promise; /** Offered only on the receiving track: the practice chooses to run its practice here. */ onChooseToOperate?: () => Promise; className?: string; }; /** * IS THIS THE WHOLE RECORD, OR THE BOOTSTRAP COPY? -, and it is the difference between an * onboarding write and destroying a practice's billing linkage. * * MEASURED IN A BROWSER ON A PLAIN PAGE LOAD, nothing clicked: **two** full `PUT /R4/Organization` about * half a second apart, and they are not the same document. The first carries `wpInstallationId` only - **no * `meta.versionId`, no `stripeCustomerId` tag, and no `subscription` extension at all**; the second carries * all of it. * * **THE CARD'S HEADLINE - that this destroys the practice's Stripe linkage - IS DISPROVEN**, and the * proof is `anAdminPutIsTakenLiterally.test.ts` in the api repo: the server carries every akashic tag and * the subscription extension forward from the stored record, for every caller. **What it does NOT carry * forward is everything else** - role permissions, consent settings, appointment policies, `contact`, * `alias`, `identifier` - because a FHIR PUT replaces the resource and an admin's body is taken literally * by design, so that an admin can genuinely REMOVE a setting. * * THE CAUSE IS UPSTREAM OF THIS COMPONENT and this is the cheapest place to refuse it. The bootstrap * reads the org unauthenticated, so the client held the PUBLIC PROJECTION until `App.tsx` learned to * re-read it as the caller. This effect fired against that copy. * * `meta.versionId` IS THE TELL: HealthLake stamps it on every record it serves, and the bootstrap copy * has none. No version, not a record we read, do not write it back. * * HONEST BOUND: this stops us SENDING a stale document. It is **not** optimistic concurrency - whether * HealthLake enforces a body-level `meta.versionId` on PUT is unverified, and the real guard for that would * be an `If-Match` header. What this removes is the writer that could not have been rejected anyway, * because it carried no version to check. */ const isCompleteRecord = (organization: Organization | null | undefined): boolean => Boolean(organization?.meta?.versionId); const OrgOnboarding = ({ organization, locationCount, sitesMissingTimezone, practitioners, schedulesList, bookingMode, questionnaireCount, publicFormCount, portalPagesConfigured, schedulesLoaded, baaActive, ownerAssigned, onOpenContact, onOpenPracticeTypes, onOpenLocation, onOpenPolicies, onUploadLogo, onPersist, onChooseToOperate, className, }: OrgOnboardingProps) => { const navigate = useNavigate(); // - a step can live under a different hat, and the checklist is what carries the admin there. // `activeWorkspace` comes from the permission service rather than being recomputed here: it is the // same value `RequirePlatformManager` gates on, and a second definition of "which hat" would drift from // the guard it exists to satisfy. const { availableWorkspaces, activateWorkspace, selectedPractitionerRole } = useAuthServiceContext(); const { activeWorkspace } = useStaffPermissionServiceContext(); const [roleSetup, setRoleSetup] = useState(null); const [modalTaskId, setModalTaskId] = useState(undefined); const persistingRef = useRef(false); const hasReceptionistWorkspace = useMemo( () => practitioners.some(({ roles }) => roles.some((r) => getPractitionerRoleWorkspaces(r).includes(STAFF_WORKSPACES.RECEPTIONIST)), ), [practitioners], ); // Readiness now DERIVES from the same resolver the patient booking flow uses, so this checklist can no // longer show a green tick for a provider no patient can actually book. // (An earlier comment here claimed the receptionist booking page also used this helper - it does not. The // consumers are this file, UsersPage, and the alert below.) // Readiness stands on a verified catalogue: until then no provider counts as ready, and none is named as not ready. const serviceBasis = useServiceCatalogueBasis(); const bookabilityCtx = useStaffBookabilityContext({ practitioners, serviceBasis }); const providerReadiness = useMemo( () => practitioners.map((p) => getProviderReadiness(p, schedulesList, bookabilityCtx)), [practitioners, schedulesList, bookabilityCtx], ); const providers = providerReadiness.filter((r) => r.isProvider); // Only providers with a real GAP. A provider the practice deliberately keeps staff-book-only is not // "not ready" - alerting on that would flag a correct decision as a fault. const notReadyProviders = providers.filter((r) => !r.staffBookable && r.basis === 'confirmed'); const servicesUnverified = providers.some((r) => r.basis !== 'confirmed'); const practitionerWorkspaceExists = providers.length > 0; // Read off the ROLE rather than derived from a blocker, deliberately: the seat is a fact about the // person and is true whether or not they have a schedule yet. Deriving it from bookability would // report a seated provider with no schedule as seatless, which sends somebody to the wrong screen. const practitionerHoldsClinicianSeat = providers.some((r) => r.roles.some((role) => role.active !== false && isClinicianEnabled(role)), ); // Derived from the resolver, not from a second reading of PractitionerRole.healthcareService. const practitionerOffersService = providers.some( (r) => r.hasAnySchedule && !r.blockers.includes('BLOCK_DOES_NOT_OFFER_SERVICE'), ); const practitionerHasBookableSchedule = providers.some( (r) => r.hasAnySchedule && !r.blockers.includes('BLOCK_NO_HOURS') && !r.blockers.includes('BLOCK_NO_PLANNING_HORIZON') && !r.blockers.includes('BLOCK_HORIZON_EXPIRED'), ); // "Ready" = the practice can take appointments for this provider AT ALL (Spencer, 2026-07-28: // bookable-by-anyone counts). Using patient-self-bookable here would leave the step permanently incomplete // for a practice that deliberately keeps its providers behind the front desk. const practitionerBookingReady = providers.some((r) => r.staffBookable && r.basis === 'confirmed'); const focusPractitionerId = (providers.find((r) => !r.staffBookable) ?? providers[0])?.practitioner?.id; // The receiving track asks for somebody who works incoming referrals. const hasReferralCoordinator = practitioners.some(({ roles }) => roles.some((role) => getOrganizationRoleCodes(role).includes('referral-coordinator'))); const { resolved, completedCount, totalCount, allComplete } = useMemo(() => { const tasks = buildOrgSystemTasks({ organization, locationCount, sitesMissingTimezone, questionnaireCount, publicFormCount, portalPagesConfigured, hasReceptionistWorkspace, practitionerWorkspaceExists, practitionerHoldsClinicianSeat, practitionerOffersService, practitionerHasBookableSchedule, practitionerBookingReady, bookingMode, // Deliberately not coalesced. `undefined` means "not running inside WordPress", which drops the // step; `?? false` would accuse a non-WordPress deployment and wedge a required step forever. siteTransportSecure: readSiteTransportSecure(), // Same contract, same reason: `undefined` is "we have not been told", not "unsigned". baaActive, ownerAssigned, operatingScope: readOperatingScope(organization), hasReferralCoordinator, }); return resolveOnboarding(tasks, getOnboardingStatus(organization)); }, [ organization, locationCount, sitesMissingTimezone, questionnaireCount, publicFormCount, portalPagesConfigured, hasReceptionistWorkspace, practitionerWorkspaceExists, practitionerHoldsClinicianSeat, practitionerOffersService, practitionerHasBookableSchedule, practitionerBookingReady, bookingMode, baaActive, ownerAssigned, hasReferralCoordinator, ]); // Auto-persist: any task live-complete but not yet persisted done/skipped write "done" (sticky), // coalesced into one org PUT. Guarded so it fires once per satisfying state, not in a loop. useEffect(() => { if (persistingRef.current) return; if (!isCompleteRecord(organization)) return; const persisted = getOnboardingStatus(organization); const toPersist = resolved.filter( // `liveOnly` steps are never written. `resolveOnboarding` ignores their persisted value anyway, // so storing one would be an org PUT whose result nothing reads - and it would leave a stale // "done" in the record for anyone who later reads the raw status without going through the // resolver. (t) => !t.liveOnly && t.liveComplete && persisted[t.id] !== 'done' && persisted[t.id] !== 'skipped', ); if (toPersist.length === 0) return; let next = organization; for (const t of toPersist) { next = setOnboardingTaskStatus(next, t.id, 'done'); } persistingRef.current = true; void onPersist(next).finally(() => { persistingRef.current = false; }); }, [organization, resolved, onPersist]); /** Does the viewer hold the Platform Manager hat at all? Several destinations here live under it. */ const holdsPlatformManager = availableWorkspaces.includes(STAFF_WORKSPACES.PLATFORM_MANAGER); /** * - SWITCH THE HAT, THEN NAVIGATE. * * THE DEFECT THIS CLOSES: three onboarding destinations sit behind `RequirePlatformManager`, which * gates on the **active workspace** rather than on a permission. An admin clicking them was bounced by * its `` - the dialog closed, the route did not change, and **nothing said they had been * refused**, on the guided setup that is a new practice's first impression. * * The admin workspace design settles what to do, and it needed no ruling: *"Onboarding crosses every * workspace, and that is inherent... **the checklist navigates; the items do not move.** So the answer to * 'should an admin have to switch workspaces to onboard?' is **no** - without relocating anything."* * **Switching IS the navigation.** * * NOT A PERMISSIONS CHANGE, and that distinction is the whole reason this is safe: the same person * with the same grants is allowed or refused purely by which hat is selected. Widening the guard would * move an item the design says stays put. */ const goToRoute = (route: string, workspace?: string, focusId?: string) => { if (workspace && workspace !== activeWorkspace) { activateWorkspace(workspace as StaffWorkspace); } navigate(route); // A step whose destination is the page the checklist already sits on would otherwise do nothing // visible. Wait a frame for the route to settle, then bring the named element into view. if (focusId) { window.setTimeout(() => { const target = document.getElementById(focusId); target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); target?.focus({ preventScroll: true }); }, 50); } }; const runAction = (task: ResolvedOrgTask) => { // A step with no action is a condition the practice satisfies outside Akashic (the https step), so // there is nothing to run and no button rendered to run it. if (!task.action) return; if (task.action.kind === 'route') { /* * - SWITCH THE HAT, THEN NAVIGATE. * * Three of these routes sit behind `RequirePlatformManager`, which gates on the ACTIVE WORKSPACE * rather than on a permission. An admin clicking them was bounced by its `` - the dialog * closed, the route did not change, and **nothing said they had been refused.** * * The admin workspace design settles what to do and it needed no ruling: *"the checklist * navigates; the items do not move... the answer to 'should an admin have to switch workspaces to * onboard?' is no - without relocating anything."* Switching is the navigation. * Not a permissions change: the same person with the same grants is refused only by which hat is * selected. Widening the guard would move an item the design says stays put. */ goToRoute(task.action.route, task.action.workspace, task.action.focusId); return; } switch (task.action.modalId) { case 'contact': onOpenContact(); break; case 'practice-types': onOpenPracticeTypes(); break; case 'location': onOpenLocation(); break; case 'appointment-policies': onOpenPolicies(); break; case 'role-receptionist': setRoleSetup('receptionist'); break; case 'role-practitioner': if (!practitionerWorkspaceExists) { setRoleSetup('practitioner'); } else if (focusPractitionerId) { navigate(`/dashboard/staff/${focusPractitionerId}/services`); } break; // 'logo' is handled by the focus panel's own picker (onUploadLogo) default: break; } }; const handleSkip = (task: ResolvedOrgTask) => { // Same rule as the auto-persist above: never PUT a document we did not fully read. if (!isCompleteRecord(organization)) return; void onPersist(setOnboardingTaskStatus(organization, task.id, 'skipped')); }; const incompleteTasks = resolved.filter((t) => t.status === 'todo' || t.status === 'locked'); // The single next actionable step is surfaced as a prominent "Next step" CTA; the rest of the // incomplete steps render as a plain list beneath it. const nextTask = incompleteTasks.find((t) => t.status === 'todo'); const remainingIncomplete = nextTask ? incompleteTasks.filter((t) => t.id !== nextTask.id) : incompleteTasks; const completedTasks = resolved.filter((t) => t.status === 'done' || t.status === 'skipped'); const modalTask = resolved.find((t) => t.id === modalTaskId); /** * - the hat this step needs and the viewer does not hold, or `undefined`. * * THE OTHER HALF OF THE FIX, AND THE ONE THAT KEEPS IT HONEST. Switching hats only works for somebody * who HAS the hat, and an admin at a real practice may well not - the Platform Manager is often not an * employee at all, it is the IT person or the web agency. For them the step cannot be a button, so it * says who does it instead of failing silently. * The one thing it must never do again is offer a control that does nothing. */ const workspaceTheyDoNotHold = (task: ResolvedOrgTask): string | undefined => { const needed = task.action?.kind === 'route' ? task.action.workspace : undefined; if (!needed || availableWorkspaces.includes(needed as never)) return undefined; // The catalogue keys options on the FORM value (`PlatformManagers`), not the workspace value // (`PlatformManager`). Translated through the catalogue's own accessor rather than by hand - the two // differ by a plural on some entries and not others, which is exactly where a hand-map goes wrong. const formValue = getWorkspaceFormValue(needed); return BUILT_IN_WORKSPACE_OPTIONS.find((option) => option.key === formValue)?.name ?? needed; }; const openTask = (task: ResolvedOrgTask) => { if (task.status === 'locked') return; setModalTaskId(task.id); }; const fireAndClose = (task: ResolvedOrgTask) => { setModalTaskId(undefined); setTimeout(() => runAction(task), 0); }; const skipAndClose = (task: ResolvedOrgTask) => { handleSkip(task); setModalTaskId(undefined); }; /* * COLLAPSED IS REMEMBERED PER PERSON, and the key is why this is not one flag. * * Two administrators at one practice are two different people with two different screens, and a * practice with two sites has two different setups to be part-way through. The key carries the * practitioner role and the organization, so one person folding this away on their own dashboard * never folds it away on their colleague's. * * IT FAILS OPEN, DELIBERATELY. A browser with storage blocked, or a first visit, reads as EXPANDED * rather than collapsed: the cost of showing a finished practice a card they had hidden is a click, * and the cost of hiding an unfinished practice's next step is that setup quietly stops. */ const collapseKey = onboardingCollapseKey(selectedPractitionerRole?.id, organization?.id); const [collapsed, setCollapsed] = useState(() => { try { return collapsedFromStored(window.localStorage.getItem(collapseKey)); } catch { return false; } }); const toggleCollapsed = () => { setCollapsed((was) => { const next = !was; try { window.localStorage.setItem(collapseKey, next ? COLLAPSED_VALUE : 'false'); } catch { // A preference that could not be saved is not worth telling anybody about: the card is in the // state they just asked for, and the only cost is that it forgets by tomorrow. } return next; }); }; // "today" recurring actions (setup complete) - reuse the matching task ids' actions. const TODAY_ACTIONS: { label: string; run: () => void }[] = [ { label: 'Onboard a practitioner', run: () => setRoleSetup('practitioner') }, { label: 'Onboard a receptionist', run: () => setRoleSetup('receptionist') }, { label: 'Add a location', run: () => onOpenLocation() }, // - the same two destinations as the setup steps, so the same hat switch. These are the // "today" shortcuts a finished practice sees, and they were dead for an admin for the same reason. // A shortcut for a hat you do not hold is simply not offered: unlike a setup step there is nothing // to explain and nothing to skip, so a sentence here would be noise on a list of things to DO. ...(holdsPlatformManager ? [ { label: 'New questionnaire', run: () => goToRoute('/dashboard/questionnaires', STAFF_WORKSPACES.PLATFORM_MANAGER) }, { label: 'Publish an Akashic Public Form', run: () => goToRoute('/dashboard/questionnaires', STAFF_WORKSPACES.PLATFORM_MANAGER) }, ] : []), ]; // - the padlock is an icon; ` ⤼ ` stay characters. Same reasoning as OnboardingStepRail: const receiving = readOperatingScope(organization) === 'receiving'; const [choosing, setChoosing] = useState(false); const [chooseError, setChooseError] = useState(); const chooseToOperate = async () => { if (!onChooseToOperate) return; setChoosing(true); setChooseError(undefined); try { await onChooseToOperate(); } catch { setChooseError('That did not save. Please try again.'); } finally { setChoosing(false); } }; // WordPress rewrites EMOJI into a 120px third-party image, and only U+1F512 is emoji here. const glyph = (status: OrgSystemTaskStatus): ReactNode => status === 'done' ? '✓' : status === 'skipped' ? '⤼' : status === 'locked' ? : '○'; return ( <> {/* THE WHOLE HEADER IS THE CONTROL, not a chevron beside it. This card is a permanent fixture on the dashboard, and a 44px-wide hit area on a screen designed for a tablet is the difference between a control and a decoration. THE COUNT STAYS VISIBLE WHEN IT IS FOLDED, and the title changes to name the next step. Folding away the one number that says whether setup is finished would make the collapse a way to lose track of it, which is the opposite of what a pull-down is for. */} {/* pinned alert — only once schedules have loaded, so it can't flash a false "can't be booked" */} {!collapsed && schedulesLoaded && void serviceBasis.retry()} />} {/* scrollable list */}
{allComplete ? (
    {TODAY_ACTIONS.map((a) => (
  • ))}
) : ( <> {nextTask && (
{nextTask.title}
)} {remainingIncomplete.length > 0 && (
    {remainingIncomplete.map((task) => (
  • ))}
)} )} {receiving && onChooseToOperate && (

This checklist covers receiving referrals. When you are ready to run your practice on Akashic, the rest of setup appears here: locations, services, schedules, staff and clinician seats.

{chooseError &&

{chooseError}

}
)} {completedTasks.length > 0 && ( <>
    {completedTasks.map((task) => (
  • ))}
)}
{/* guidance modal */} {modalTask && ( { if (!next) setModalTaskId(undefined); }}> {modalTask.title} Setup step guidance. fireAndClose(modalTask)} onUploadLogo={async (photo) => { await onUploadLogo(photo); setModalTaskId(undefined); }} onSkip={() => skipAndClose(modalTask)} /> )} setRoleSetup(null)} /> ); }; export default OrgOnboarding;