import { useCallback, useEffect, useRef, useState } from 'react'; import { FormTokenField } from "../../../../components/ui/form-token-field"; import { useLocation, useNavigate } from 'react-router-dom'; import { Controller, useFieldArray, useForm, FormProvider } from 'react-hook-form'; import { Card, CardBody, CardHeader } from '../../../../components/ui/wp-card'; import { Elevation } from '../../../../components/ui/elevation'; import { InputControl } from '../../../../components/ui/input-control'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Spacer } from '../../../../components/ui/spacer'; import { useAuthServiceContext, usePractitionerRoleServiceContext, usePractitionerServiceContext, useStaffPermissionServiceContext } from '../../../../services'; import { AppHeader, FhirContactPoint, ProfileLayoutContainer, ScrollableTab, StaffWorkspaceAssignment } from '../../../../components'; import StaffRoleAssignmentFields from '../../../../components/StaffRoleAssignment/StaffRoleAssignmentFields.tsx'; import { Check } from 'lucide-react'; import type { PractitionerNpiMatch } from '../../../../services/PractitionerService.tsx'; import { HttpError } from '../../../../services/HttpService.tsx'; import { useClinicianInvitationService, type InvitationOutcome, type NewClinicianInvitation } from '../../../../services/ClinicianInvitationService.tsx'; import { heldInvitationOperation, heldInvitationNotice, heldOperationId, holdInvitationOperation, newInvitationOperationId, invitationOperationHolder, invitationOutcomeNotice, invitationOutcomeUnknown, releaseInvitationOperation, retainInvitationReceipt, requestDigest, TRACKING_REFUSED, } from '../../../../utils/staff/invitationOutcome'; import { isSaveRetired, useSaveScope } from '../../../../utils/saveOperation'; import { useFhirPractitioners } from '../../../../hooks'; import { getStaffCreationBlockers } from '../../../../utils/organizationSetup'; import { useFhirLocations } from '../../../../hooks/useFhirLocations'; import { onlySites } from '../../../../utils/roomLocation'; import { defaultRoleLocationPatch } from '../../../../utils/locationAssignments'; import { readOperatingScope } from '../../../../utils/operatingScope'; import ConfirmPracticeOffice from '../../../../components/ReceivingOffice/ConfirmPracticeOffice'; import { HeldInvitationHelp } from '../../../../components/SetupHelpWidget/HeldInvitationHelp'; import type { Location } from 'fhir/r4'; import { STAFF_WORKSPACES, type StaffRoleOption, type StaffWorkspace, getOrgRoleByCode, getWorkspaceFormValue, getWorkspaceFromFormValue, reconcileCoupledAssignments, } from '../../../../utils/staffRoleCatalog.ts'; import { describeWriteFailure } from '../../../../utils/fetchFailureMessage'; import { codingsMayOpenPractitionerWorkspace, codingsReachDiagnostics } from '../../../../utils/staffAccess'; // Thin adapter over the shared reconciler: converts the create form's shapes (FHIR codings + // workspace FORM keys) to canonical and back. All coupling logic lives in // reconcileCoupledAssignments - do not reimplement it here, or the create and edit paths will drift. /** * - separates the two PERSONAL fields from everything that becomes a PractitionerRole. * * This is a privacy boundary, not tidying. Every key left in the returned `roleFields` is handed to * `transformToFhirPractitionerRole`, which produces an ORG-SCOPED resource that org staff - and, before * the projection landed, patients - can read. A personal mobile arriving there is exactly the leak the * whole home-gear split exists to prevent. Exported so the boundary is testable rather than buried * inside `onSubmit`, the same reason `reconcileRolesAndWorkspaces` below is exported. */ // Captain, merge gate 2026-08-10 - GENERIC so the caller's shape SURVIVES the split. A flat // `Record` erased it, and the two consumers below then got `unknown`: both // `withoutUser.code ?? []` and `withoutUser.telecom ?? []` collapsed to `{}` and failed TS2345 against // `reconcileRolesAndWorkspaces` and `transformToTelecom`. `Omit` keeps whatever the caller has, // so this gets STRICTER for free the day this form stops being `useForm()` with `data: any`. export const splitPersonalFields = >( fields: T ): { npi?: string; personalMobile?: string; roleFields: Omit } => { const { npi, personalMobile, ...roleFields } = fields; return { npi: typeof npi === 'string' ? npi : undefined, personalMobile: typeof personalMobile === 'string' ? personalMobile : undefined, roleFields }; }; /** * - whether an NPI is worth asking the server about. * * THE FIELD IS THE GATE, NOT THE ROLE. An NPI is a CLINICIAN'S identifier (Spencer, 2026-08-14: *"it's * just for practitioner role users"*), and reception, HR and billing simply have none - so a check keyed * on a complete NPI never fires for them, without the form having to branch on role or the two definitions * of "who is a clinician" having to be kept in step. * Shape only, deliberately: the server owns the check digit and is authoritative. Duplicating the * checksum here would be a second definition of "valid" - the same reasoning the field's own validator * already carries. */ export const isCheckableNpi = (value: string | undefined): boolean => /^\d{10}$/.test(value?.trim() ?? ''); /** * - what an admin is told when the NPI they typed already belongs to someone. * * BOTH BRANCHES NAME AN ACTION THAT WORKS TODAY, which is why this is worth showing before the link * path exists. Already here look at your staff list. Exists elsewhere *use the email they already sign * in with*, which is not a placeholder: the create handler ALREADY reuses an existing human when the email * matches an existing Cognito user, so this turns a path that worked only by coincidence into one an admin * can take deliberately. * It never reveals the email, or where they work. That is the disclosure the endpoint's projection * exists to prevent - the admin asks the person. */ export const npiDuplicateMessage = (match: PractitionerNpiMatch): string => match.signsInWithThisEmail ? match.alreadyInYourOrganization ? `${match.name} already works here with this sign-in email. Open their staff record to change their roles or workspaces.` : `${match.name} already signs in to Akashic with this email. Saving invites that account to your practice; no second account is made, and they join once they accept.` : match.alreadyInYourOrganization ? `${match.name} already works here — check your staff list before adding them again.` : `${match.name} already has a practitioner record on Akashic. If this is the same person, ask which email they already sign in with and use that here — they will be added to your organization instead of getting a second record.`; /** * - may this form be submitted while a possible duplicate is on screen? * * THE DEFECT THIS CLOSES, OBSERVED END TO END ON 2026-08-15: the warning fired, it NAMED the human, and * Save went through anyway. Adding a staff member on Dr. Practitioner One's own NPI produced a second * `Practitioner`, carrying the same NPI, with no link between them - **and a second personal-profile * `Patient` underneath it.** That is the fork the form's own helper text promises to prevent (*"so your * records stay together instead of splitting into separate accounts"*). * **A warning that does not ASK is not a gate.** The prevention half was good and it was advisory. * * IT MUST NOT HARD-BLOCK, and that is not timidity - the match can arrive from a CONTACT lookup, and a * practice publishes one main line that several staff legitimately carry. A shared front-desk number is * evidence for a human, never proof of one, so a real second hire has to be able to get through. What is * removed is the SILENT path: forking now takes a deliberate second click that says what it does. */ export const duplicateDecisionPending = (state: { match: PractitionerNpiMatch | undefined; confirmedLinkId: string | undefined; acknowledgedDistinct: boolean; }): boolean => Boolean(state.match) && !state.confirmedLinkId && !state.acknowledgedDistinct; /** * Every reason this form refuses to submit, in one place - or `undefined` when it may go ahead. * * WHY ONE FUNCTION RATHER THAN THREE EARLY RETURNS IN `onSubmit`. The gates ARE the feature here, and * inside a component body they are reachable only by rendering the whole page against a provider tree. * Folded out, the precedence between them is a fact a unit test can pin - and precedence matters: the * organization-setup refusal has to come first, because a half-configured org produces a staff member with * maximum visibility, which is worse than a duplicate. * The honest limit: this makes the RULES testable, not the wiring. That `onSubmit` still consults it is * one line, and only a browser run proves that line is there - which is the check already owes. */ export const submitBlockReason = (state: { organizationSetupComplete: boolean; organizationSetupMissingItems: string[]; hasOrganization: boolean; match: PractitionerNpiMatch | undefined; confirmedLinkId: string | undefined; acknowledgedDistinct: boolean; }): string | undefined => { if (!state.organizationSetupComplete) { return `Complete primary organization information before adding staff. Missing: ${state.organizationSetupMissingItems.join(', ')}.`; } if (!state.hasOrganization) { return 'Organization information is missing. Please try again.'; } if (state.match?.signsInWithThisEmail && state.match.alreadyInYourOrganization) { return `${state.match.name} is already on your staff. Open their staff record to change their roles or workspaces.`; } if (duplicateDecisionPending(state)) { return 'Choose one of the options in the duplicate notice above before adding this person — ' + 'either they are the same person, or they are somebody new.'; } return undefined; }; /** * WHAT TO SAY WHEN SAVE REFUSES BECAUSE A FIELD IS INVALID, rather than saying nothing. * * The defect this exists for is generic and this form was only its worked example: react-hook-form * reports per-field, each field renders its own message where it sits, and on a long form the failing * one is usually below the fold. The Save button is in a fixed header, so pressing it produced no * visible change anywhere - and "the button is broken" is the reasonable reading of that. * * `shouldFocusError` cannot rescue it here. It needs a ref on the input, and these fields are WordPress * `TextControl`s and `Select`s inside Controllers that do not forward one - so focus silently no-ops and * the page does not move. * * NAMES THE FIELDS rather than counting them: "Save could not complete" with a number is one step better * than silence and still leaves somebody hunting. Unknown keys fall back to the humanised path, which is * ugly and honest - far better than omitting a field from the list, which would send somebody looking * everywhere except at the thing that is actually wrong. */ const INVALID_FIELD_LABELS: Record = { email: 'Email address', firstname: 'First name', lastname: 'Last name', organization: 'Organization', code: 'Organization role', primaryOrganizationRole: 'Primary organization role', npi: 'NPI', personalMobile: 'Personal mobile', telecom: 'Contact details', workspaces: 'Workspaces', defaultWorkspace: 'Default workspace', }; export const describeInvalidSubmit = (errors: Record): string | undefined => { // The TOP-LEVEL key is what a reader can find on the screen. `telecom.0.value` is an index into a // list nobody thinks of as indexed, so every contact row collapses to one entry called what the // section is called. const roots = Array.from(new Set(Object.keys(errors ?? {}).map((path) => path.split('.')[0]))); if (roots.length === 0) return undefined; const labels = roots.map( (root) => INVALID_FIELD_LABELS[root] ?? root.replace(/([a-z])([A-Z])/g, '$1 $2'), ); return labels.length === 1 ? `${labels[0]} needs attention before this staff member can be saved.` : `These need attention before this staff member can be saved: ${labels.join(', ')}.`; }; export const reconcileRolesAndWorkspaces = ( existing: Array<{ code?: string; system?: string; display?: string }>, workspaceFormKeys: string[], ) => { const canonicalWorkspaces = workspaceFormKeys .map((key) => getWorkspaceFromFormValue(key)) .filter((w): w is StaffWorkspace => Boolean(w)); const existingCodes = existing.map((c) => c.code).filter((c): c is string => Boolean(c)); const { orgRoleCodes, workspaces } = reconcileCoupledAssignments({ orgRoleCodes: existingCodes, workspaces: canonicalWorkspaces, }); // Keep the caller's original coding objects (they carry system/display); append only the new ones. const known = new Set(existingCodes); const additions = orgRoleCodes .filter((code) => !known.has(code)) .map((code) => getOrgRoleByCode(code)) .filter((option): option is StaffRoleOption => Boolean(option)); return { codings: [...existing, ...additions], workspaceFormKeys: workspaces.map(getWorkspaceFormValue), }; }; const NewUserPage = () => { const navigate = useNavigate(); const { state } = useLocation() as { state?: { presetWorkspaceFormKey?: string; presetPrimaryRoleCode?: string }; }; const presetWorkspaceFormKey = state?.presetWorkspaceFormKey; const presetPrimaryRoleCode = state?.presetPrimaryRoleCode; const presetRole = presetPrimaryRoleCode ? getOrgRoleByCode(presetPrimaryRoleCode) : undefined; const { USER_ROLES, wpOrganization } = useAuthServiceContext(); const { activeWorkspace, canManage } = useStaffPermissionServiceContext(); const { findExistingByNpi, findExistingByContact } = usePractitionerServiceContext(); const { transformToFhirPractitionerRole } = usePractitionerRoleServiceContext(); const { createInvitation, checkInvitationOperation } = useClinicianInvitationService(); // This opening of the page, and the one invitation press from it whose answer is not known yet: pressing Save // again repeats that same press rather than starting another, and Check reads what it did. const opening = useRef(newInvitationOperationId()); const frozen = useRef<{ operationId: string; request: NewClinicianInvitation; digest: string; holder: string } | undefined>(undefined); const inFlight = useRef(false); const [unresolved, setUnresolved] = useState(() => heldOperationId(invitationOperationHolder('create'))); const [checking, setChecking] = useState(false); const saveScope = useSaveScope(wpOrganization.current?.id); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [existingStaffId, setExistingStaffId] = useState(); // An invitation the server held names a reference for help, under the press that was held. const [heldHelp, setHeldHelp] = useState<{ reference: string; opening: string } | undefined>(); const errorRef = useRef(null); // THE REFUSAL HAS TO MOVE THE EYE, NOT MERELY EXIST SOMEWHERE ON THE PAGE. // // Fired from an effect rather than from the submit handler, because the alert does not exist in the // DOM until the state change has rendered - scrolling from the handler would scroll to nothing. // // KEYED ON A COUNTER, NOT ON THE MESSAGE, and that is the half that was broken. Two identical // refusals produce the same string, so with `[error]` as the dependency the SECOND press changed // nothing and the effect never re-ran. The reader presses Save a second time precisely because the // first press appeared to do nothing - so the one case that most needed the scroll was the one case // guaranteed not to get it. // // INSIDE A FRAME, because the alert is inserted in the same commit this effect runs after. Measured // in a browser on 2026-08-30: the summary ended 593px above the viewport and the page never moved, // while calling scrollIntoView by hand on that same element a moment later worked perfectly. The // element is there but the browser has not finished placing it. // // `block: 'start'`, and INSTANT rather than smooth. 'nearest' is a request to move as little as // possible, which on a form this long is a request not to move. A smooth animation started at the // same instant as a layout change can be dropped before it travels, and a refusal is not a place to // glide to - the reader wants to be there already. const scrollToRefusal = useCallback(() => { const frame = requestAnimationFrame(() => errorRef.current?.scrollIntoView({ block: 'start' })); return () => cancelAnimationFrame(frame); }, []); const [refusalSeq, setRefusalSeq] = useState(0); // Every refusal goes through here so none of them can be the one that forgets to move the eye. An // EMPTY message is a clear, not a refusal, so it must not advance the counter. const reportRefusal = useCallback((message: string | undefined) => { setError(message); if (message) setRefusalSeq((sequence) => sequence + 1); }, []); useEffect(() => { if (!refusalSeq) return; return scrollToRefusal(); }, [refusalSeq, scrollToRefusal]); /** * - the human this NPI already belongs to, if any. * * TRIGGERED BY A VALID NPI, NOT BY A ROLE. An NPI is a CLINICIAN'S identifier (Spencer, 2026-08-14: * *"it's just for practitioner role users"* - reception, HR and billing have none), so the field is * the gate: it is empty for everyone else and the check simply never runs for them. * * WARN, NEVER BLOCK. The match is EVIDENCE, not proof - the NPI is self-asserted, NPPES validation * is unbuilt - so an admin who knows better must still be able to continue. A hard stop here would * make a typo in a 10-digit number unrecoverable without support. */ const [npiMatch, setNpiMatch] = useState(); /** * step 3 - the practitioner the admin has EXPLICITLY confirmed this hire already is. * * SEPARATE FROM `npiMatch` ON PURPOSE. Finding a match is the server's answer; deciding it is the * same human is the admin's, and collapsing the two would make an automatic link out of a suggestion * built on a self-asserted NPI. Nothing is sent until this is set. * Cleared whenever the NPI changes: a confirmation is about ONE person, and letting it survive a * retyped number would attach the wrong human to the org silently. */ const [confirmedLinkId, setConfirmedLinkId] = useState(); /** * - the admin has looked at the match and said this is somebody ELSE. * * A SEPARATE FACT FROM `confirmedLinkId`, and the pair is the whole gate: one says "same human, link * them", the other says "different human, a second record is what I mean". Until one of them is set, * the form will not submit - which is the difference between warning and asking. * IT IS CLEARED WHENEVER A NEW MATCH ARRIVES (see `rememberMatch`). Without that, acknowledging one * duplicate would silently cover the NEXT one the admin has not seen yet - the same shape as a stamp * that sets but never clears, and indistinguishable from correct until the day it is wrong. */ const [acknowledgedDistinct, setAcknowledgedDistinct] = useState(false); /** * Every path that sets a match goes through here, so no path can forget to reset the decision. * Not `setNpiMatch` directly - three call sites set it, and a reset that lives in two of them is a * bug waiting for the third. */ const rememberMatch = (match: PractitionerNpiMatch | undefined) => { setNpiMatch(match); setConfirmedLinkId(undefined); setAcknowledgedDistinct(false); }; /** * THE NOTICE NAMES AN ACTION THAT WORKS TODAY, which is the whole reason it is worth showing before * the link path exists: the create handler ALREADY reuses an existing human when the email matches an * existing Cognito user. So *"use the email they already sign in with"* is not a placeholder - it is * the path that produces the correct outcome, turning something that worked only by coincidence into * something an admin can do deliberately. * And it deliberately does NOT tell them the email: that would disclose where the person already * works. The admin asks the person. */ const checkNpiForExistingHuman = async (value: string) => { const npi = value?.trim() ?? ''; // A confirmation is about ONE person. Retyping the NPI means the admin is talking about somebody // else, and a stale confirmation would attach the wrong human to the org without anyone choosing to. // (`rememberMatch` clears it on every path below; this covers the early return above it.) setConfirmedLinkId(undefined); if (!isCheckableNpi(npi)) { rememberMatch(undefined); return; } try { const { matches } = await findExistingByNpi(npi); rememberMatch(matches[0]); } catch { // A failed lookup must never obstruct a hire. This is an ASSIST - silence is the correct // degraded state, and an error banner here would train admins to ignore the real warning. rememberMatch(undefined); } }; /** * step 4 - the same question, on the identifier a receptionist ACTUALLY TYPES. * * THIS DOOR WAS SILENT, and claude-4 measured it in a browser: entering an email that already * belonged to a practitioner AT THIS SAME ORGANIZATION produced no warning and no lookup at all. The * prevention was NPI-only, so it never applied to reception, HR or billing staff - who have no NPI, * and who are the roles most likely to already exist somewhere. * * Spencer, 2026-08-10: *"NPI helps satisfy practitioners but what about non-practitioner staff? * That's where a personal mobile number will matter."* * * IT DOES NOT OVERWRITE AN NPI MATCH. An NPI is a professional identifier and a far stronger claim * than a shared front-desk number; replacing a certain match with a weaker one would downgrade what * the admin is looking at without them doing anything. */ const checkContactForExistingHuman = async (contact: { email?: string; phone?: string }) => { if (npiMatch) return; const email = contact.email?.trim(); const phone = contact.phone?.trim(); if (!email && !phone) return; setConfirmedLinkId(undefined); try { const { matches } = await findExistingByContact({ email, phone }); rememberMatch(matches.find((match) => match.signsInWithThisEmail) ?? matches[0]); } catch { // Identical degraded state to the NPI door: a failed lookup must never obstruct a hire, and // an error banner here would train admins to ignore the real warning. rememberMatch(undefined); } }; const spacerMargin = '24px'; const namePrefixes = ['Dr.', 'Mr.', 'Mrs.', 'Ms.', 'Prof.']; const nameSuffixes = ['MD', 'PhD', 'DDS', 'DO', 'NP', 'PA', 'RN', 'Jr.', 'Sr.', 'II', 'III']; // - a staff member may not be created until the org they are created UNDER actually exists // as a place: a name, an address, a phone, and at least one Location. Without a Location their // `PractitionerRole.location` is empty, and the product reads an empty location as "covers EVERY // site" - so skipping org setup does not produce an incomplete staff record, it produces a staff // member with maximum visibility. // // The location requirement is suspended while the list is still LOADING, deliberately. Blocking a // legitimate admin because a fetch had not returned is a worse failure than letting one through: this // gate is a UI sequence, not the boundary. The boundary is the server-side write guard ( // phase 3), which does not depend on anything having loaded in a browser. const { locationsList, loading: locationsLoading } = useFhirLocations(); const orgSites = onlySites(locationsList); // A receiving practice places new staff at an office a person confirmed for this practice: never one chosen for // them, never none, and never another practice's after switching. const receiving = readOperatingScope(wpOrganization.current) === 'receiving'; const [confirmedOffice, setConfirmedOffice] = useState<{ practiceId: string; office: Location }>(); const office = receiving && confirmedOffice && confirmedOffice.practiceId === wpOrganization.current?.id ? confirmedOffice.office : undefined; // Leaving the practice ends the confirmation: coming back to it asks again. const officePracticeId = wpOrganization.current?.id; useEffect(() => { setConfirmedOffice((held) => (held && held.practiceId !== officePracticeId ? undefined : held)); }, [officePracticeId]); const organizationSetupMissingItems = getStaffCreationBlockers( wpOrganization.current, receiving || locationsLoading ? 1 : orgSites.length, ); const organizationSetupComplete = organizationSetupMissingItems.length === 0; // Only true Cognito admins may assign the Admin workspace when onboarding staff. A delegated // staff:manage actor (e.g. HR) can create staff and assign every other workspace, not Admin. const isAdminActor = activeWorkspace === USER_ROLES.ADMIN; useFhirPractitioners(); // Guard note: presetWorkspaceFormKey seeds `workspaces` (and defaultWorkspace) once, here, at // useForm init - it is NOT live-synced against `code`/the coupling reconciler the way a user's own // pick is (StaffRoleAssignmentFields' add-direction effect, StaffWorkspaceAssignment's checkbox // handler). That's safe ONLY because every caller today either presets an UNCOUPLED workspace // (Practitioners) or presets Receptionists together with the matching presetPrimaryRoleCode // (RoleSetupModal.tsx:29-31,58-59) - never a COUPLED workspace without its role. If a future caller // ever presets a coupled workspace alone, this form would render it ticked with no role chip // showing, then reconcileRolesAndWorkspaces would silently ADD the role at submit - ticked-with-no- // visible-role is exactly the misleading state this feature exists to prevent. Revisit this seed if // that invariant ever changes. const methods = useForm({ defaultValues: { email: '', workspaces: presetWorkspaceFormKey ? [presetWorkspaceFormKey] : [], defaultWorkspace: presetWorkspaceFormKey ?? '', firstname: '', lastname: '', prefix: [], suffix: [], active: true, organization: null, code: presetRole ? [presetRole] : [], primaryOrganizationRole: presetPrimaryRoleCode ?? '', leadershipDesignations: [], trainingLevel: null, specialty: [], extension: [], // NO PRE-ADDED CONTACT ROW. The form used to seed `[{}]`, and `FhirContactPoint` marks a // row's value `required` - so the form created a row nobody asked for and then refused to // save because of it. Somebody who never opened the Contact section was blocked by the // Contact section, and the only sign was a message far down a long page. // // The empty state is not a degraded one: `FhirContactPoint` renders its own heading, an // "Add Contact" button, and an honest "No contact added yet." when the array is empty, and // `canAddContact()` returns true at length 0. This is the state it was built for. // the element shape is annotated because the array is now EMPTY. The seed `[{}]` // was what react-hook-form inferred the row type from; without it the default is `never[]` // and `useFieldArray({ name: 'telecom' })` stops type-checking as an array field. telecom: [] as Array<{ system?: string; value?: string; use?: string }>, // - captured here, stored apart by the server. See onSubmit: both are destructured // OUT of `withoutUser`, because everything left in it is handed to the PractitionerRole // transform, and a personal mobile must never reach an org-scoped resource. npi: '', personalMobile: '' } }); const { control, handleSubmit } = methods; // The seat note applies only to someone given the Practitioner workspace. Watched rather than // read once, so unticking Practitioner removes it in the same breath. const watchedWorkspaces = methods.watch('workspaces'); const practitionerWorkspaceSelected = (watchedWorkspaces ?? []) .includes(getWorkspaceFormValue(STAFF_WORKSPACES.PRACTITIONER)); // Does this practice give the roles being picked the practitioner workspace at all? // // WATCHED, not read once, for the same reason the workspaces above are: the admin is choosing org // roles on this very form, so ticking Physician has to open the workspace option in the same breath // that ticking Receptionist closes it. // // IT ASKS THE QUESTION THE TOKEN MINT WILL ASK, through the same resolver. Anything else here would // be a second opinion about a refusal this screen does not perform. const watchedOrgRoleCodes = methods.watch('code'); const practitionerWorkspaceGranted = codingsMayOpenPractitionerWorkspace( wpOrganization.current, Array.isArray(watchedOrgRoleCodes) ? watchedOrgRoleCodes : [], ); const diagnosticsGranted = codingsReachDiagnostics( wpOrganization.current, Array.isArray(watchedOrgRoleCodes) ? watchedOrgRoleCodes : [], ); const telecomFieldArray = useFieldArray({ control: control, name: 'telecom' }); const onSubmit = async (data: any) => { // EVERY REASON THIS FORM REFUSES, IN ONE CALL - see `submitBlockReason`. The one that is new // here is: until 2026-08-16 the form WARNED about a duplicate and then submitted // anyway. Adding a staff member on an NPI that already belonged to somebody produced a second // `Practitioner` with the same NPI, no link between them, and a second personal-profile `Patient` // underneath it. The admin was told, by name, and nothing asked them to decide. // A warning that does not ask is not a gate. It refuses the SUBMIT rather than disabling Save: // a disabled button under a warning makes the reader hunt for the connection, and there are two // legitimate ways forward - link them, or say plainly that this is somebody else. const blockedReason = submitBlockReason({ organizationSetupComplete, organizationSetupMissingItems, hasOrganization: Boolean(wpOrganization.current), match: npiMatch, confirmedLinkId, acknowledgedDistinct, }); if (blockedReason) { reportRefusal(blockedReason); return; } // One press at a time, decided before anything awaits: a second press in the same turn joins the first. if (inFlight.current) return; inFlight.current = true; const { email, firstname, lastname, prefix, suffix, workspaces, defaultWorkspace, ...rest } = data; // - the personal fields are split off HERE, and the split is exported so it can // be tested. Whatever remains goes to transformToFhirPractitionerRole below, and a personal // mobile reaching an org-scoped PractitionerRole is precisely the leak this work prevents. const { npi, personalMobile, roleFields: withoutUser } = splitPersonalFields(rest); // Admin guardrail: a non-admin actor cannot assign the Admin workspace to a new staff member. const adminFormValue = getWorkspaceFormValue(STAFF_WORKSPACES.ADMIN); const safeWorkspaces: string[] = isAdminActor ? workspaces : (workspaces ?? []).filter((workspace: string) => workspace !== adminFormValue); const safeDefaultWorkspace = (!isAdminActor && defaultWorkspace === adminFormValue) ? (safeWorkspaces[0] ?? '') : defaultWorkspace; // Reconcile role <-> workspace BOTH directions: the form lists Organization roles above // Workspaces, so a role-first pick must also assign its required workspace, not just // workspace-first picks assigning their required role (Admin is never in this coupling, // so the guardrail above cannot be bypassed by reconciliation). const { codings: reconciledCode, workspaceFormKeys: reconciledWorkspaces } = reconcileRolesAndWorkspaces(withoutUser.code ?? [], safeWorkspaces); // An invitation, not a staff member: nothing joins the practice, and nobody gets access, until // the person accepts. The server re-checks the role it will bind. const request = { kind: 'staff' as const, email, firstname, lastname, ...(npi?.trim() && { npi: npi.trim() }), ...(personalMobile?.trim() && { personalMobile: personalMobile.trim() }), practitionerRole: transformToFhirPractitionerRole({ ...withoutUser, code: reconciledCode, workspaces: reconciledWorkspaces, defaultWorkspace: safeDefaultWorkspace, // - DEFAULT, DON'T ASK. A single-site practice should not have to think // about this, and the alternative is not "no location" but "every location", because // that is what an empty array means everywhere in the product. // Only when the form supplied nothing. A location the admin actually chose always // wins - this fills a blank, it never overrides an answer. ...(office ? { location: [{ reference: `Location/${office.id}`, display: office.name }] } : defaultRoleLocationPatch(withoutUser.location, orgSites)), // 🔻 `?? undefined` and not a non-null assertion. `submitBlockReason` above already // refused when there is no organization, but TypeScript cannot see a narrowing through // a function boundary — and an assertion here would silence the compiler on a fact it // genuinely cannot check, which is the wrong trade for one keystroke. Converting the // null is honest and costs nothing: the transform's own parameter is optional. }, wpOrganization.current ?? undefined), }; const digest = requestDigest(request); // A press whose answer is not known is repeated only as it was first sent; anything else waits for it. const holder = invitationOperationHolder('create'); const pending = frozen.current; const held = heldInvitationOperation(holder); if (pending && pending.holder !== holder) { inFlight.current = false; reportRefusal('This invitation belongs to the earlier signed-in person and practice. Return there to check it.'); return; } if (pending && pending.digest !== digest) { inFlight.current = false; reportRefusal('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.'); return; } if (!pending && held === 'unreadable') { inFlight.current = false; reportRefusal(TRACKING_REFUSED); return; } if (!pending && typeof held === 'object') { inFlight.current = false; setUnresolved(held.operationId); reportRefusal('An earlier invitation from this tab was not confirmed. Check the earlier invitation first, or start a new invitation instead.'); return; } const operationId = pending?.operationId ?? newInvitationOperationId(); const sent = pending?.request ?? request; if (!pending && !holdInvitationOperation(holder, { operationId, opening: opening.current })) { inFlight.current = false; reportRefusal(TRACKING_REFUSED); return; } setSubmitting(true); setSuccess(undefined); setError(undefined); setExistingStaffId(undefined); setHeldHelp(undefined); const save = saveScope.start(); try { const outcome = await createInvitation(sent, { operationId, ...(typeof held === 'object' && held.receipt?.invitationId && { originalInvitationId: held.receipt.invitationId }), transport: save.transport }); save.admit(); settle(holder, operationId, outcome, `${firstname} ${lastname}`.trim(), { request: sent, digest }); } catch (error: any) { if (isSaveRetired(error) || !save.belongs()) return; if (invitationOutcomeUnknown(error)) { frozen.current = { operationId, request: sent, digest, holder }; setUnresolved(operationId); reportRefusal('We could not confirm whether the invitation was saved or sent. Check it before doing anything else; pressing Save again repeats this same invitation and sends no second one.'); return; } // A refusal is an answer: this press saved and sent nothing, so it need not be checked. const refusal = error instanceof HttpError ? error.body as { reason?: string; practitionerId?: string; recovery?: { reference?: unknown } } | undefined : undefined; if (refusal?.reason !== 'operation-mismatch') { releaseInvitationOperation(holder, operationId); if (frozen.current?.operationId === operationId) frozen.current = undefined; setUnresolved(undefined); } console.error('Failed to create staff member:', error); if (refusal?.reason === 'already-staff') setExistingStaffId(refusal.practitionerId); if (refusal?.reason === 'invitation-claim-held' && typeof refusal.recovery?.reference === 'string') { setHeldHelp({ reference: refusal.recovery.reference, opening: operationId }); } reportRefusal(describeWriteFailure(error, `Failed to create staff member`)); } finally { inFlight.current = false; if (save.belongs()) setSubmitting(false); } }; // An answer that says what happened: the press is done, and the staff list says it in the same words. const settle = (holder: string, operationId: string, outcome: InvitationOutcome, name: string, sent?: { request: NewClinicianInvitation; digest: string }) => { retainInvitationReceipt(holder, operationId, outcome.receipt); if (outcome.receipt.save !== 'saved') { if (sent) frozen.current = { operationId, ...sent, holder }; setUnresolved(operationId); reportRefusal(invitationOutcomeNotice(outcome, name || 'this person').text); return; } releaseInvitationOperation(holder, operationId); frozen.current = undefined; setUnresolved(undefined); const invitedName = outcome.invitation ? `${outcome.invitation.firstname} ${outcome.invitation.lastname}`.trim() : name; navigate('..', { relative: 'path', state: { invitedName, alreadyInvited: outcome.existing, outcome: { receipt: outcome.receipt, existing: outcome.existing } }, }); }; // Reads what the unresolved press did, through its own transport. It sends, saves and rotates nothing. const checkUnresolved = async () => { const operationId = unresolved; if (!operationId) return; setChecking(true); const save = saveScope.start(); try { const outcome = await save.step(() => checkInvitationOperation(operationId, { transport: save.transport })); retainInvitationReceipt(invitationOperationHolder('create'), operationId, outcome.receipt); if (outcome.receipt.save === 'saved') { settle(invitationOperationHolder('create'), operationId, outcome, ''); } else { reportRefusal('Still not confirmed. The invitation may yet have been saved, so check again in a moment or look for it in the staff list.'); } } catch (error) { if (isSaveRetired(error) || !save.belongs()) return; reportRefusal('The invitation could not be checked just now. Try Check again in a moment.'); } finally { if (save.belongs()) setChecking(false); } }; // A deliberate choice to stop waiting on the unresolved press. The server keeps whatever it did; a new // invitation to the same address finds and reuses it rather than making a second. const startOver = () => { const operationId = unresolved; if (operationId) releaseInvitationOperation(invitationOperationHolder('create'), operationId); frozen.current = undefined; setUnresolved(undefined); setError(undefined); }; if (!organizationSetupComplete) { return ( <> Complete primary organization information before adding staff. Missing: {organizationSetupMissingItems.join(', ')}. ); } if (receiving && !office) { return ( <>

Add staff

First confirm the office your staff work from.

{ const practiceId = wpOrganization.current?.id; if (practiceId) setConfirmedOffice({ practiceId, office: confirmed }); }} />
); } return ( {/* THE SECOND ARGUMENT TO handleSubmit IS THE POINT. Without it an invalid submit reports only beside the offending field, which on this page is below the fold - so Save reads as inert. */}
onSubmit(data), (errors) => reportRefusal(describeInvalidSubmit(errors)))}>

Add staff

Add the person once, then choose what they do here and which workspaces they can open.

{success && ( <> setSuccess(undefined)}> {success} )} {unresolved && !error && ( <> {heldInvitationNotice()} )} {error && ( <> {/* Scrolled to rather than merely rendered. The alert sits above a long scrollable form, so on a page somebody has scrolled down it would otherwise appear off-screen and change nothing they can see - which is the same silence, one element higher up. How it scrolls, and why 'nearest' was the wrong request on a form this long, is on scrollToRefusal above. */}
setError(undefined)}>
{error}
{heldHelp && } {existingStaffId && ( )} {unresolved && ( )}
)}

Identity and invitation

Use the same email they use for Akashic elsewhere. We will invite them after this record is saved.

{/* Two columns as the board draws them; one on a phone. */}
{/* email */} ( <> {/* Card 215 step 4 — the door that was silent. This is the identifier a receptionist ACTUALLY types, and the only one non-clinician staff have. */} { field.onBlur(); void checkContactForExistingHuman({ email: field.value }); }} label="Email address" help="We email them an invitation to sign in here. It is also the address they sign in with."/> {fieldState.error && ( Email is required )} )}/>
{/* NPI and personal mobile are optional and only recognise one person across practices; the help text says why, or nobody fills them in. */} !value?.trim() || /^\d{10}$/.test(value.trim()) }} render={({ field, fieldState }) => ( <> { field.onBlur(); void checkNpiForExistingHuman(field.value); }} label="NPI (optional)" help="Used to recognise this person if they already work at another practice on Akashic."/> {fieldState.error && ( An NPI is 10 digits )} {/* Card 215 — the duplicate warning, and the reason it names an ACTION rather than just a problem. */} {npiMatch && !confirmedLinkId && !acknowledgedDistinct && ( {npiDuplicateMessage(npiMatch)} {/* Card 215 step 3 — offered ONLY for a practitioner who is not already here. "Add them again" is not an action; going to the staff list is, and that is what the already-here copy says instead. */} {npiMatch.signsInWithThisEmail && npiMatch.alreadyInYourOrganization && ( )} {!npiMatch.alreadyInYourOrganization && ( )} {/* 🚨 Card 215 — THE OTHER HALF, and the form will not submit until one of the two is chosen. ⛔ It is offered on BOTH arms, including already-here: a contact match can be a shared front-desk line, so a real second hire must have a way through. ⭐ The label says what it DOES rather than "continue" — the outcome it commits to is a second record, and that is the thing the admin is being asked to mean. */} {/* One email is one account, so a sign-in match has no separate-record choice. */} {!npiMatch.signsInWithThisEmail && ( )} )} {confirmedLinkId && ( {npiMatch ? `${npiMatch.name} will be added to your organization and will sign in with the account they already have — no new login is created.` : 'This hire will be linked to an existing practitioner.'} )} {/* ⚖️ The acknowledged-distinct state says what it costs, because the fork goes a level deeper than a spare staff row — the personal profile splits too, which is the thing this form's own helper text promises to prevent. Stating it is what makes the choice informed rather than merely explicit. */} {!confirmedLinkId && acknowledgedDistinct && npiMatch && ( {`A separate staff record and a separate personal profile will be created — they will not be connected to ${npiMatch.name}.`} )} )}/>
{/* firstname */} ( <> {fieldState.error && ( First name is required )} )}/>
{/* lastname */} ( <> {fieldState.error && ( Last name is required )} )}/>
( )}/>
( field.onChange(tokens)} disabled={submitting}/> )}/>
( field.onChange(tokens)} disabled={submitting}/> )}/>

Role at this practice

Choose what this person does here. Their role and workspace access remain separate.

{/* telecom */}

Workspace access

Choose the workspaces this person can open. Their organization roles determine what they can do.

{practitionerWorkspaceSelected && ( A clinician seat is assigned from their staff record once they have signed in. )}
); }; export default NewUserPage;