import React, { useState, useEffect } from 'react'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { Spacer } from '../../../../../components/ui/spacer'; import { useForm, FormProvider } from 'react-hook-form'; import { Practitioner, Attachment, PractitionerRole } from 'fhir/r4'; import { AppHeader, HistoryNavigator, ProfileLayoutContainer, ScrollableTab, FhirPractitioner, operationStatusToast } from '../../../../../components'; import StaffPersonalProfile from '../../../../../components/StaffPersonalProfile/StaffPersonalProfile'; import OrganizationOwnerNotice from '../../../../../components/OrganizationOwnerNotice/OrganizationOwnerNotice'; import type { StaffPersonalProfileInput } from '../../../../../services/PractitionerService'; import { useAppointmentServiceContext, useAuthServiceContext, usePractitionerServiceContext, usePractitionerRoleServiceContext, useStaffPermissionServiceContext } from '../../../../../services'; import { actorReferencesForStaff, staffAppointmentImpact, impactSummary, type StaffAppointmentImpact, } from '../../../../../utils/staffAppointmentImpact'; import { useReassignPlan } from '../../../../../hooks/useReassignPlan'; import { executeReassign } from '../../../../../utils/booking/executeReassign'; import { REASSIGN_BLOCKER_TEXT } from '../../../../../utils/booking/reassignFeasibility'; import { useScheduleServiceContext } from '../../../../../services'; import { formatFhirHumanName } from '../../../../../utils/fhirHumanName.tsx'; import { getLoginEmail } from '../../../../../utils/cognitoIdentity.ts'; import { holdsClinicalRole } from '../../../../../utils/staffRoleCatalog'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../../../../components/ui/dialog'; import { describeWriteFailure, ownFailureSentence } from '../../../../../utils/fetchFailureMessage'; import { isSaveRetired, isSaveTimedOut, useSaveScope } from '../../../../../utils/saveOperation'; import { appointmentsChangedMessage, cancelStaffAppointments, STAFF_APPOINTMENT_BATCH_DEADLINE_MS, } from '../../../../../utils/booking/cancelStaffAppointments'; import { appointmentFollowUpLines } from '../../../../../utils/appointmentVersion'; import { staffDeactivationConsequence, staffReactivationSeatLine } from '../../../../../utils/staffDeactivationCopy'; interface EditPractitionerProfileProps { practitioner: Practitioner; roles: PractitionerRole[]; showBackButton?: boolean; title?: string; fill?: boolean; // Staff-management context only: show the Deactivate/Reactivate control. The caller owns authorization // (not-self, and non-admins may not deactivate admins) - see UserEditPractitionerProfilePage. allowDeactivation?: boolean; } const EditPractitionerProfile: React.FC = ({ practitioner, roles, showBackButton = true, allowDeactivation = false, title, fill = false }) => { const { currentUser, setCurrentUser, currentOrganization } = useAuthServiceContext(); const { canManage } = useStaffPermissionServiceContext(); const { setPractitionerState, setPractitionersList } = usePractitionerServiceContext(); const { transformFromFhirPractitioner, transformToFhirPractitioner, updatePractitioner, updateOwnPersonalProfile } = usePractitionerServiceContext(); const { updatePractitionerRole } = usePractitionerRoleServiceContext(); const [submitting, setSubmitting] = useState(false); // - pending edits to the PERSONAL half. `undefined` means nothing was touched, which is what // keeps an ordinary staff-profile save from rewriting the home gear on every submit. const [personalInput, setPersonalInput] = useState(); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [photo, setPhoto] = useState(undefined); const [showActivationConfirm, setShowActivationConfirm] = useState(false); const [togglingActivation, setTogglingActivation] = useState(false); // - what deactivation would strand. `undefined` means "not looked yet", which is a different // state from "looked and found none" and must not render as a reassuring zero. const { fetchAppointmentsForActors, fetchAppointment, updateAppointment } = useAppointmentServiceContext(); const [impact, setImpact] = useState(); const [impactError, setImpactError] = useState(); const [appointmentPlan, setAppointmentPlan] = useState<'cancel' | 'keep' | 'reassign' | undefined>(); // - reassign. The plan is only worked out once a colleague is actually chosen. const { practitionersList } = usePractitionerServiceContext(); const { createSlot, releaseBookingHold } = useScheduleServiceContext(); // A deactivation belongs to this staff member and the person signed in; another of either retires it. const activationScope = useSaveScope(practitioner.id); const { state: reassignState, assess: assessReassignTo, clear: clearReassign } = useReassignPlan(impact?.affected ?? []); const [reassignTargetId, setReassignTargetId] = useState(''); // Colleagues this org could hand the visits to. Never the person being deactivated, and never // somebody who is themselves inactive - handing visits to a second account that cannot log in would // move this card's defect rather than fix it. // // - "is staff" WAS THE WRONG QUESTION, and it offered the billing specialist as somewhere // to move a clinical visit, in a dialog an admin uses under time pressure while somebody leaves. The // question is whether this person can HOLD a patient visit, which is the clinical role. // Not a deny-list of the billing/HR/marketing roles: a deny-list is wrong for the next role nobody // remembers to add to it, and it would still be answering "who is not clinical" rather than "who is". // THE ROLE ONLY, deliberately - NOT bookability. Whether their calendar can actually take these // specific visits is what `useReassignPlan` answers when one is picked, and it is the reason that hook // runs per-pick rather than up front: assessing every practitioner here would make this dialog fetch // every schedule in the practice before the admin has chosen anyone. A colleague who is clinical but // has no free slot must still be OFFERED, and then told what can and cannot move. const reassignCandidates = practitionersList.filter((entry) => entry.practitioner.id !== practitioner.id && holdsClinicalRole(entry.roles)); // A staff member is active for this org when at least one of their roles here is active. const isActive = roles.some((role) => role.active !== false); const canToggleActivation = allowDeactivation && roles.length > 0; const staffName = formatFhirHumanName(practitioner.name) || 'this staff member'; const orgName = currentOrganization?.name || 'this organization'; // The sign-in email is only recoverable from the server-stamped tag - it is not a profile field and is // not part of this form. Shown to whoever manages staff (they created the seat and get the "which email // did I use?" question) and to the staff member themselves. Never editable here: changing it would not // change the Cognito login, so an editable field would be a lie about how to sign in. const isSelf = currentUser?.fhir?.id === practitioner.id; const canSeeLoginEmail = isSelf || canManage('staff'); // The username tag can be the account UUID; the staff member's own claim is their address. const loginEmail = (isSelf ? currentUser?.cognito?.email : undefined) || getLoginEmail(practitioner); const spacerMargin = '24px'; const methods = useForm({ defaultValues: { active: true, gender: '', birthDate: '', name: [{ family: '', given: [''], prefix: [], suffix: [], use: '' }], communication: [], npi: [], identifier: [], qualification: [], address: [], telecom: [] } }); const { handleSubmit } = methods; useEffect(() => { const { photo: practitionerPhoto, formData } = transformFromFhirPractitioner(practitioner); setPhoto(practitionerPhoto); methods.reset(formData); }, [practitioner]); const onSubmit = async (data: any) => { setSubmitting(true); setSuccess(undefined); setError(undefined); const operationToastId = operationStatusToast.saving(); // - the form saves to TWO places on purpose, and they are not interchangeable. // The staff record goes to `Practitioner` (work identity, the org administers it); the personal // details go to the account's own home gear through a self-only endpoint the org cannot read. // // THE PERSONAL SAVE RUNS FIRST, and that ordering is the whole error story. If it fails we stop // and say so, rather than reporting "Profile updated" for a save that dropped half of what the // person typed - which is the failure they would be least able to detect, because the fields they // edited are the ones nobody else can see. if (personalInput) { try { await updateOwnPersonalProfile(personalInput); } catch (err: any) { console.error('Failed to update personal details:', err); setError( `${describeWriteFailure(err, 'Your personal details were not saved')}\n\nNothing else was changed.`, ); operationStatusToast.dismiss(operationToastId); setSubmitting(false); return; } } try { const result: Practitioner = await updatePractitioner({ id: practitioner?.id, ...transformToFhirPractitioner(data, photo) }); if (currentUser?.fhir?.id === practitioner.id) { setCurrentUser({ ...currentUser, fhir: result }); } else { setPractitionerState(prev => ({ practitioner: result, roles: prev?.roles || [] })); } setPractitionersList(prev => prev.map(item => item.practitioner.id === practitioner.id ? { ...item, practitioner: result } : item ) ); // The personal half already succeeded above, so it is cleared here rather than left pending - // otherwise a second Save would resubmit values the server has already stored. setPersonalInput(undefined); setSuccess(`Practitioner profile updated successfully!`); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { console.error(`Failed to update Practitioner profile:`, err); // Named separately from the personal half above: if the personal save DID land and this one // did not, saying "nothing was saved" would be false, and the person would retype details // that are already stored. setError( personalInput ? describeWriteFailure(err, `Your personal details were saved, but your staff profile was not`) : describeWriteFailure(err, `Failed to update Practitioner profile`) ); } finally { operationStatusToast.dismiss(operationToastId); setSubmitting(false); } }; const handlePhotoChange = (newPhoto: Attachment | undefined) => { setPhoto(newPhoto); }; /** * - look BEFORE the question is asked, so the dialog can lead with a number. * * THE COUNT, NOT THE POLICY. *"Dr. Ellis still has 14 appointments"* is a different decision from * *"this will cancel appointments"* - a number changes behaviour, a policy sentence gets clicked * through. Reactivation needs none of this, so it does not pay for the fetch. */ const openActivationConfirm = async () => { setAppointmentPlan(undefined); setImpact(undefined); setImpactError(undefined); setReassignTargetId(''); clearReassign(); setShowActivationConfirm(true); if (!isActive) return; try { const { appointments, truncated } = await fetchAppointmentsForActors( actorReferencesForStaff(practitioner, roles), ); setImpact(staffAppointmentImpact(appointments, new Date(), { partial: truncated })); } catch (err: any) { console.error('Failed to load this staff member\'s upcoming appointments:', err); // A FAILED LOOK IS NOT AN EMPTY CALENDAR. Rendering "no upcoming appointments" here would be // a false statement about a real patient's booking, and it is the exact reassurance that makes // the deactivation click safe-feeling. setImpactError('We could not check this staff member\'s upcoming appointments.'); } }; // Deactivate/reactivate this staff member FOR THIS ORG by flipping active on their org role(s). The token // pipeline (pre-token-generation-trigger) then excludes inactive roles, so their staff access here is // revoked on next sign-in while patient access + roles at other orgs are untouched. Full-object PUT // preserves every other field on the role. const handleToggleActivation = async () => { const nextActive = !isActive; setTogglingActivation(true); setError(undefined); const operation = activationScope.start(STAFF_APPOINTMENT_BATCH_DEADLINE_MS); // A visit whose answer never came is read as stored now, under a recovery of its own. const readNow = (appointmentId: string) => { const recovery = activationScope.start(); return recovery.step(() => fetchAppointment(appointmentId, true, recovery.transport)); }; // What each saved cancellation or move left unfinished, kept even when a later step fails. const unfinished: string[][] = []; try { /* - cancel FIRST, and stop if it fails. The order is the whole safety property. Deactivate first and a failed cancel leaves appointments standing against a practitioner who can no longer log in - which is exactly the defect this card exists to remove, reintroduced by the fix for it. Cancelling first means the worst case is an unchanged staff member with some appointments already cancelled: visible, recoverable, and reported rather than silent. */ if (!nextActive && appointmentPlan === 'cancel' && impact?.affected.length) { await cancelStaffAppointments(impact.affected, operation, updateAppointment, readNow, unfinished); } /* - the moves, under the same cancel-first rule and for the same reason: a failed move must not leave the staff member deactivated with visits still on their calendar. A PARTIAL move ABORTS the deactivation. Moving nine of fourteen and deactivating anyway would strand the other five against someone who can no longer log in - this card's exact defect, reintroduced by its own fix. The nine that moved are real and are reported; the admin re-opens the dialog and the count is smaller. */ if (!nextActive && appointmentPlan === 'reassign' && reassignState.status === 'ready') { const outcomes = await executeReassign( reassignState.plan.movable, reassignState.target, { createSlot: (slot) => operation.step(() => createSlot(slot, undefined, operation.transport)), releaseBookingHold, updateAppointment: (appointment, options) => operation.step( () => updateAppointment(appointment, { ...options, transport: operation.transport }), true, ), admit: operation.admit, readNow, }, ); unfinished.push(...outcomes.map((outcome) => outcome.followUps ?? [])); const failures = outcomes.filter((outcome) => !outcome.moved); if (failures.length > 0) { throw new Error( `${failures.length} of ${outcomes.length} appointments could not be moved, so nothing was ` + `deactivated. ${failures[0].error ?? ''}`, ); } } const updatedRoles = await operation.step(() => Promise.all( roles.map((role) => updatePractitionerRole({ ...role, active: nextActive })), ), true); const updatedById = new Map(updatedRoles.map((role) => [role.id, role])); setPractitionerState((prev) => prev ? { ...prev, roles: prev.roles.map((role) => updatedById.get(role.id) || role) } : prev); setPractitionersList((prev) => prev.map((item) => item.practitioner.id === practitioner.id ? { ...item, roles: item.roles.map((role) => updatedById.get(role.id) || role) } : item)); const cancelledCount = !nextActive && appointmentPlan === 'cancel' ? (impact?.count ?? 0) : 0; // What the saved cancellations or moves left unfinished stays on screen until dismissed. const scheduleLines = [...new Set(appointmentFollowUpLines(unfinished.flat()))]; setSuccess([nextActive ? 'Staff member reactivated.' : cancelledCount > 0 ? `Staff member deactivated for this organization, and ${cancelledCount} ${cancelledCount === 1 ? 'appointment was' : 'appointments were'} cancelled. Those patients have been emailed.` : 'Staff member deactivated for this organization.', ...scheduleLines].join(' ')); if (scheduleLines.length === 0) setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { // Nothing more is shown for a change that no longer belongs to what is on screen. if (isSaveRetired(err)) return; console.error('Failed to update staff activation status:', err); const changed = appointmentsChangedMessage(err, 'nothing was deactivated'); const scheduleLines = [...new Set(appointmentFollowUpLines(unfinished.flat()))]; setError([ changed ?? (isSaveTimedOut(err) ? ownFailureSentence(err) : describeWriteFailure(err, `Failed to update staff activation status`)), ...scheduleLines, ].join(' ')); } finally { setTogglingActivation(false); setShowActivationConfirm(false); } }; return ( resourceType="Practitioner" resourceId={practitioner.id || ''}> {(showHistory) => (
{canToggleActivation && ( )} {/* Green because it saves. Blue is for opening things. This was left blue by an earlier sweep that flagged it rather than widening its own scope - taking the flag now. Organization's nine save buttons are all green, and Deactivate and Reactivate beside this one already use the house classes. */} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} {/* Card 230 — SELF ONLY. An admin editing someone else's staff record must not see these fields at all: showing them, even empty, teaches the admin that these values are theirs to collect. The server refuses independently (the endpoint takes no id and resolves the subject from the caller's token), so this gate is what the person SEES, not what protects the data. */} {/* 🔒 THE OWNER NOMINEE HAD NOWHERE TO ACCEPT FROM, AND THIS IS THE FIX. 📏 Measured on staging 2026-08-17: an admin nominated a RECEPTIONIST as owner, the server returned 200, and her own `GET .../owner` returned 200 naming her as nominee — while every surface she can reach carried ZERO owner text. `#/dashboard/organization` bounces her to this page, and Organization is not in a receptionist's nav at all. ⭐ THE COMPONENT WAS NEVER THE PROBLEM. It already renders the nominee's own "You have been named the owner of this practice" with a Review and accept action; it was mounted ONLY on a page the person it was built for cannot open. The accept ROUTE was widened deliberately for this exact case — *"an agency admin nominating a practice owner who is not yet an admin themselves"* — so the server supported a case the UI could not express. ⇒ Here because this is the one page EVERY staff role reaches, and accepting ownership is a personal act rather than an administrative one. ⛔ NOT inside `StaffPersonalProfile`, which was my first placement and is wrong: that component returns early while its own fetch is loading and again if it fails, so the owner notice would vanish whenever an unrelated read broke. Ownership must not depend on the personal-details request. ⛔ It self-gates (`tone === 'none'` renders nothing), so a settled practice costs this page nothing. ⚖️ `canNominate` is FALSE on purpose: nominating is an administrative act and keeps its home on the Organization page. The notice must never offer what the reader cannot do, and false is the direction that cannot mislead. ⚠️ Gated on `isSelf` like the section below — an admin editing somebody else's record must not be handed that person's ownership task. */} {isSelf && ( )} {isSelf && (
)}
{isActive ? `Are you sure you want to deactivate ${staffName}?` : `Are you sure you want to reactivate ${staffName}?`}
{isActive ? ( <> {/* 🚨 Card 314 — this used to be the WHOLE dialog, and the sentence about bookings is what hid the defect. "Can no longer have appointments booked" is about FUTURE bookings and reads as though it covered everything; the question the person clicking actually has is what happens to Monday's patients. Nothing touched appointments already on the calendar. */}

{staffDeactivationConsequence(staffName, orgName)}

{impactError && (

{`${impactError} Check their calendar before deactivating — appointments already booked are not cancelled automatically.`}

)} {!impactError && impact === undefined && (

Checking their upcoming appointments…

)} {/* 🔬 The card's own control: a practitioner with nothing upcoming gets no count and no choice. A dialog that manufactures a decision that is not there teaches people to click past the one that is. */} {impact !== undefined && impact.count > 0 && (

{impactSummary(staffName, impact)}

Those appointments stay on the calendar unless you cancel them. Choose one:

{/* Card 314 slice 2 — the option practices will actually pick. A departing practitioner's patients are normally handed to a colleague, not turned away. */} {appointmentPlan === 'reassign' && (
{reassignState.status === 'loading' && (

Checking their calendar…

)} {/* ⚠️ A failed look is NOT an empty plan — the same rule the count above follows. */} {reassignState.status === 'error' && (

{reassignState.message}

)} {reassignState.status === 'ready' && (

{`${reassignState.plan.movable.length} of ${impact.count} can move.`}

{/* ⭐ THE SPLIT, WITH REASONS. "2 cannot" without saying why is no more actionable than refusing the whole thing. */} {reassignState.plan.blocked.length > 0 && (
    {reassignState.plan.blocked.slice(0, 5).map((blocked) => (
  • {blocked.blockers.map((code) => REASSIGN_BLOCKER_TEXT[code]).join('; ')}
  • ))}
)} {reassignState.plan.blocked.length > 0 && (

{`The other ${reassignState.plan.blocked.length} stay on the calendar — ` + 'deactivate again afterwards to cancel them, or handle them by hand.'}

)}
)}
)} {appointmentPlan === 'keep' && (

Until you do, those patients are still booked with {staffName}, who can no longer open these visits.

)}
)}

Their schedule and settings are preserved, and reactivating restores everything exactly as it is now.

{/* 🔬 The card's second control, stated where it is decided rather than discovered: reactivation must not resurrect what was cancelled. It cannot — a cancel is a status write on the appointment, and reactivation only flips the role's `active` flag — and saying so stops anyone treating "cancel" as reversible. */} {appointmentPlan === 'cancel' && (

Cancelled appointments are not restored by reactivating.

)}

Their patient access and any roles at other organizations are unaffected.

) : ( <>

{`${staffName} will regain access to ${orgName}'s staff portal and return to the active staff list.`}

Their schedule and settings are restored exactly as they were.

{staffReactivationSeatLine(roles) &&

{staffReactivationSeatLine(roles)}

} )}
)} ); }; export default EditPractitionerProfile;