import { useEffect, useRef, useState, type ReactNode } from "react"; import { OverlayActions } from "../../../components/ui/overlay-actions"; import { useDirtyReport } from "../../../hooks/useDirtyReport"; import type { Attachment, ContactPoint, Organization } from "fhir/r4"; import { Camera, Check } from "lucide-react"; import "../../../components/FhirPatientDisplay/ProfileSection.css"; import { useAuthServiceContext } from "../../../services/AuthService"; import { useOrganizationServiceContext } from "../../../services/OrganizationService"; import { useValueSetServiceContext } from "../../../services/ValueSetService"; import { usePhotoUploadService } from "../../../services/PhotoUploadService"; import { usePhotoUrl } from "../../../hooks/usePhotoUrl"; import { initialsOf } from "../../../components/ui/org-badge"; import { usePracticeTypeOptions } from "../../../config/serviceCategories"; import { ORG_LOGO_EXTENSION_URL } from "../../../config/organizationExtensions"; import { PRACTICE_TYPE_SYSTEM } from "../../../utils/staffRoleCatalog"; import { practiceTimeZoneOf, withTimezoneExtension } from "../../../utils/practiceTimezone"; import { isValidPhoneNumber } from "../../../utils/phoneNumber"; import { EMAIL_PATTERN } from "../../../utils/registerPatientRequirements"; import { describeWriteFailure, isRetryableFailure } from "../../../utils/fetchFailureMessage"; import { TextControl } from "../../../components/ui/text-control"; import { ComboboxControl } from "../../../components/ui/combobox-control"; import { CheckboxControl } from "../../../components/ui/checkbox-control"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { FieldError } from "../../../components/ui/field"; import { Spinner } from "../../../components/ui/spinner"; import { RECORD_CHANGED_MESSAGE, RELOAD_DISCARDS_MESSAGE } from "../../../utils/recordChanged"; import { useRefreshSessionOrganization } from "../../../hooks/useRefreshSessionOrganization"; import { outcomeUnknown, SaveRetiredError, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import { olderThan } from "../../../utils/versionOrder"; // The service's translation of the server's 412 (StaleOrganizationError), known by its name as the session hook // knows a retired save: someone else saved the practice first, so this opening saves nothing more until reloaded. const refusedAsStale = (err: unknown): boolean => (err as { name?: unknown } | null)?.name === "StaleOrganizationError"; const RELOAD_FAILED = "The practice could not be read. Try Reload record again."; type ContactForm = { name: string; phone: string; email: string; timezone: string }; const readContactForm = (org: Organization | null): ContactForm => ({ name: org?.name ?? "", phone: org?.telecom?.find((c) => c.system === "phone")?.value ?? "", email: org?.telecom?.find((c) => c.system === "email")?.value ?? "", timezone: practiceTimeZoneOf(org) ?? "", }); /** Someone else saved what this form edits after it was filled: nothing is written, and the form is reloaded. */ class ChangedElsewhere extends Error { constructor() { super("Changed elsewhere"); this.name = "ChangedElsewhere"; } } const sameContact = (a: ContactForm, b: ContactForm) => (Object.keys(a) as (keyof ContactForm)[]).every((field) => a[field].trim() === b[field].trim()); const sameTypes = (a: string[], b: string[]) => [...a].sort().join("|") === [...b].sort().join("|"); const readPracticeTypeCodes = (org: Organization | null): string[] => (org?.type ?? []) .flatMap((t) => t.coding ?? []) .filter((c) => c.system === PRACTICE_TYPE_SYSTEM && c.code) .map((c) => c.code as string); // Smaller than this on either side is not a picture; staging holds a 1x1 logo that drew as a colour disc. const SMALLEST_USABLE_IMAGE = 8; const readLogo = (org: Organization | null): Attachment | undefined => org?.extension?.find((e) => e.url === ORG_LOGO_EXTENSION_URL)?.valueAttachment; /** The leave guard's sentence: which sections hold unsaved changes, and what discarding them does. */ export const identityUnsavedCopy = ({ contact, types }: { contact: boolean; types: boolean }): string | undefined => { const what = contact && types ? "name, primary contact and practice type" : contact ? "name and primary contact" : types ? "practice type" : undefined; return what && `Your ${what} changes haven't been saved. Discarding them keeps what was last saved.`; }; // Staff roles keep their own contacts; a save that moved the practice's says so. export const STAFF_CONTACT_NOTICE = "Practice details saved. Staff profiles may still show the old phone number or email. Update those separately if needed."; const mainContact = (org: Organization, system: "phone" | "email") => org.telecom?.find((c) => c.system === system)?.value?.trim() ?? ""; const sameContactValue = { phone: (a: string, b: string) => a.replace(/\D/g, "") === b.replace(/\D/g, ""), email: (a: string, b: string) => a.toLowerCase() === b.toLowerCase() }; /** A practice phone or email that had a value before this save and is now different, or gone. */ export const practiceContactMoved = (before: Organization, after: Organization): boolean => (["phone", "email"] as const).some((system) => { const was = mainContact(before, system); return was !== "" && !sameContactValue[system](was, mainContact(after, system)); }); const withMainTelecom = (existing: ContactPoint[] | undefined, phone: string, email: string): ContactPoint[] => [ { system: "phone", use: "work", value: phone }, { system: "email", use: "work", value: email }, ...(existing ?? []).filter((c) => c.system !== "phone" && c.system !== "email"), ]; // admin-v2 dashboard "Identity" overlay: name, logo, contacts, time zone and practice types in one // place. The contact rule is the 2026-09-18 ruling in mockups/admin-v2/_implementation-notes.md: // primary phone AND primary email are both required, with field-specific errors, and entered values // survive a failed save. The contact-step variant is setup step 3: the two contacts alone. type IdentityPanelProps = { variant?: "full" | "contact-step"; onDirtyChange?: (dirty: boolean) => void; // The leave guard names what is unsaved; undefined when nothing is. onUnsavedChange?: (copy: string | undefined) => void; onContactSaved?: () => void; onSaveFailedChange?: (failed: boolean) => void; }; // The full panel saves each section on its own, so its buttons stay beside their fields. const InlineActions = ({ className, children }: { className?: string; children: ReactNode }) =>
{children}
; const IdentityPanel = ({ variant = "full", onDirtyChange, onUnsavedChange, onContactSaved, onSaveFailedChange }: IdentityPanelProps) => { const full = variant === "full"; const ContactActions = full ? InlineActions : OverlayActions; const { currentOrganization, setWpOrganization } = useAuthServiceContext(); const { updateOrganization } = useOrganizationServiceContext(); const { fetchTimezoneOptions } = useValueSetServiceContext(); const { uploadPhoto } = usePhotoUploadService(); const practiceTypeOptions = usePracticeTypeOptions(); const { url: logoUrl } = usePhotoUrl(readLogo(currentOrganization), "organization"); const [contact, setContact] = useState(() => readContactForm(currentOrganization)); const [touched, setTouched] = useState({ phone: false, email: false }); const [savingContact, setSavingContact] = useState(false); const [contactError, setContactError] = useState(); // The setup step shows a retryable failure as the deck's "not saved" state; a refusal keeps its own words. const [contactRetry, setContactRetry] = useState(false); const showContactRetry = !full && contactRetry && Boolean(contactError); useEffect(() => onSaveFailedChange?.(showContactRetry), [showContactRetry, onSaveFailedChange]); const [contactSaved, setContactSaved] = useState(false); const [staffContactNotice, setStaffContactNotice] = useState(); const [timezoneOptions, setTimezoneOptions] = useState<{ label: string; value: string }[]>([]); const [practiceTypes, setPracticeTypes] = useState(() => readPracticeTypeCodes(currentOrganization)); const [savingTypes, setSavingTypes] = useState(false); const [typesError, setTypesError] = useState(); const [typesSaved, setTypesSaved] = useState(false); // Refused because someone else changed what the form edits: nothing more is saved from it until Reload record. const [contactStale, setContactStale] = useState(false); const [typesStale, setTypesStale] = useState(false); const [reloading, setReloading] = useState(false); const refreshOrganization = useRefreshSessionOrganization(); // Saves and reloads belong to this sheet, practice and viewer; closing or replacing the sheet retires them. const saveScope = useSaveScope(currentOrganization?.id); // The practice as the session holds it now: a reload never refills the form from anything older. const live = useRef(currentOrganization); live.current = currentOrganization; const fileInputRef = useRef(null); const [uploadingLogo, setUploadingLogo] = useState(false); const [logoError, setLogoError] = useState(); // The logo's save was refused or its answer lost: no further logo save until Reload record reads the practice again. const [logoStale, setLogoStale] = useState(false); const [unusableLogo, setUnusableLogo] = useState(null); // WHAT EACH FORM WAS FILLED FROM. A save is built on the practice as it is now, but only while what this form // edits is still what it was filled from: a newer save by someone else to these fields refuses the save, and the // form is reloaded before anything more is saved from it. Another practice setting changing does not stand in the way. const contactOpening = useRef(readContactForm(currentOrganization)); const typesOpening = useRef(readPracticeTypeCodes(currentOrganization)); // Seeded per organization, not per render, so a save that replaces the organization does not // wipe what the administrator is in the middle of typing into the other form. useEffect(() => { setContact(readContactForm(currentOrganization)); setPracticeTypes(readPracticeTypeCodes(currentOrganization)); contactOpening.current = readContactForm(currentOrganization); typesOpening.current = readPracticeTypeCodes(currentOrganization); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentOrganization?.id]); useEffect(() => { let cancelled = false; fetchTimezoneOptions() .then((zones) => { if (!cancelled) setTimezoneOptions(zones.map((z) => ({ label: z.display || z.code, value: z.code }))); }) // An empty list still lets the rest of the form save; the field keeps the zone already set. .catch(() => undefined); return () => { cancelled = true; }; }, [fetchTimezoneOptions]); const phoneValid = isValidPhoneNumber(contact.phone); const emailValid = EMAIL_PATTERN.test(contact.email.trim()); const contactValid = phoneValid && emailValid; // Unsaved edits, so the dashboard can offer Keep editing or Discard changes on close. const savedForm = readContactForm(currentOrganization); const contactDirty = (Object.keys(savedForm) as (keyof ContactForm)[]) .some((field) => savedForm[field].trim() !== contact[field].trim()); const typesDirty = [...practiceTypes].sort().join("|") !== readPracticeTypeCodes(currentOrganization).sort().join("|"); useDirtyReport(contactDirty || typesDirty, onDirtyChange); useEffect(() => { onUnsavedChange?.(identityUnsavedCopy({ contact: contactDirty, types: typesDirty })); }, [contactDirty, typesDirty, onUnsavedChange]); const updateContact = (patch: Partial) => { setContact((current) => ({ ...current, ...patch })); setContactSaved(false); }; // Every save here replaces the whole Organization, so they run one at a time, each built from the // last confirmed version: a slow save can never land after, and overwrite, a newer one. const confirmed = useRef(currentOrganization); const queue = useRef>(Promise.resolve()); useEffect(() => { confirmed.current = currentOrganization; }, [currentOrganization]); const enqueue = (operation: SaveOperation, build: (base: Organization) => Organization) => { const run = queue.current.then(async () => { // Retired while it waited its turn: never sent. if (!operation.belongs()) throw new SaveRetiredError(); const base = confirmed.current; if (!base) throw new Error("The organization is not loaded."); // A bounded write: past its deadline, or cancelled once sent, it may have landed. const saved = await operation.step(() => updateOrganization(build(base), operation.transport), true); // Retired while it was answered: nothing of it is published here. if (!operation.belongs()) throw new SaveRetiredError(); confirmed.current = saved; setWpOrganization(saved); return { previous: base, saved }; }); queue.current = run.catch(() => undefined); return run; }; // A refusal or an unknown outcome stops this opening's Save; anything else may be retried. const stoppingFailure = (err: unknown): string | undefined => { if (err instanceof ChangedElsewhere || refusedAsStale(err)) return RECORD_CHANGED_MESSAGE; if (outcomeUnknown(err)) return TIMED_OUT_MAY_HAVE_SAVED; return undefined; }; const handleContactSave = async () => { if (!currentOrganization || !contactValid) return; const operation = saveScope.start(); setSavingContact(true); setContactError(undefined); setContactRetry(false); setStaffContactNotice(undefined); try { const { previous, saved: result } = await enqueue(operation, (org) => { if (!sameContact(readContactForm(org), contactOpening.current)) throw new ChangedElsewhere(); return { ...org, name: contact.name.trim() || org.name, telecom: withMainTelecom(org.telecom, contact.phone.trim(), contact.email.trim()), extension: withTimezoneExtension(org.extension, contact.timezone), }; }); contactOpening.current = readContactForm(result); // Closed or replaced meanwhile: it says nothing here and closes nothing. if (!operation.belongs()) return; setContactSaved(true); // The sheet stays open so the warning can be read. if (practiceContactMoved(previous, result)) { setStaffContactNotice(STAFF_CONTACT_NOTICE); } else { onContactSaved?.(); } } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; const stopped = stoppingFailure(err); if (stopped) { setContactError(stopped); setContactStale(true); return; } setContactRetry(isRetryableFailure(err)); setContactError(describeWriteFailure(err, "Failed to update practice details")); } finally { if (operation.belongs()) setSavingContact(false); operation.retire(); } }; // Reload record: the practice as it is now, read fresh, refills the refused form (discarding its edits, as it said // first) and becomes what the next save is checked against. // Never backward: an answer older than the practice the session holds refills from that newer one. const reloadPractice = async (refill: (fresh: Organization) => void, setError: (error: string | undefined) => void) => { const operation = saveScope.start(); setReloading(true); try { const fresh = await refreshOrganization(operation.transport); if (!operation.belongs()) return; if (!fresh) { setError(RELOAD_FAILED); return; } const known = live.current; const newest = known && known.id === fresh.id && olderThan(fresh, known) ? known : fresh; confirmed.current = newest; refill(newest); setError(undefined); } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; setError(RELOAD_FAILED); } finally { if (operation.belongs()) setReloading(false); operation.retire(); } }; const reloadContact = () => reloadPractice((fresh) => { setContact(readContactForm(fresh)); contactOpening.current = readContactForm(fresh); setContactStale(false); }, setContactError); const reloadTypes = () => reloadPractice((fresh) => { setPracticeTypes(readPracticeTypeCodes(fresh)); typesOpening.current = readPracticeTypeCodes(fresh); setTypesStale(false); }, setTypesError); const handleTypesSave = async () => { if (!currentOrganization) return; const operation = saveScope.start(); setSavingTypes(true); setTypesError(undefined); try { const codings = practiceTypes.map((code) => { const option = practiceTypeOptions.find((pt) => pt.code === code); return { coding: [{ system: PRACTICE_TYPE_SYSTEM, code, display: option?.display || code }], text: option?.display || code, }; }); const { saved: result } = await enqueue(operation, (org) => { if (!sameTypes(readPracticeTypeCodes(org), typesOpening.current)) throw new ChangedElsewhere(); return { ...org, type: [ ...(org.type ?? []).filter((t) => !t.coding?.some((c) => c.system === PRACTICE_TYPE_SYSTEM)), ...codings, ], }; }); if (!operation.belongs()) return; typesOpening.current = readPracticeTypeCodes(result); setTypesSaved(true); } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; const stopped = stoppingFailure(err); if (stopped) { setTypesError(stopped); setTypesStale(true); return; } setTypesError(describeWriteFailure(err, "Failed to update practice types")); } finally { if (operation.belongs()) setSavingTypes(false); operation.retire(); } }; const handleLogoChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !currentOrganization) return; const operation = saveScope.start(); setUploadingLogo(true); setLogoError(undefined); try { const { key, contentType, size } = await uploadPhoto(file, "org-asset"); const photo: Attachment = { // Named by key only: the server fills the bytes once this exact upload scans clean. contentType, url: key, title: file.name, size, creation: new Date().toISOString(), }; // Patched onto the resource as it stands; rebuilding it from a form would drop fields the // form does not carry, such as partOf. await enqueue(operation, (org) => ({ ...org, extension: [ ...(org.extension ?? []).filter((e) => e.url !== ORG_LOGO_EXTENSION_URL), { url: ORG_LOGO_EXTENSION_URL, valueAttachment: photo }, ], })); } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; const stopped = stoppingFailure(err); if (stopped) { setLogoError(stopped); setLogoStale(true); return; } setLogoError(describeWriteFailure(err, "Failed to update the logo")); } finally { if (operation.belongs()) setUploadingLogo(false); operation.retire(); event.target.value = ""; } }; if (!currentOrganization) { return (
); } return (
{full && (

Logo

{logoError && ( setLogoError(undefined)}> {logoError} )} {logoStale && ( )}
)}
{full ? (

Name and primary contact

) : (

How patients contact your practice. These details are separate from your sign-in email and personal profile.

)} {contactError && (showContactRetry ? ( Contact information wasn't saved Your entries are still here. Try again. ) : ( setContactError(undefined)}> {contactError} ))} {staffContactNotice && ( setStaffContactNotice(undefined)}> {staffContactNotice} )} {full && ( updateContact({ name })} disabled={savingContact} /> )}
Primary practice phone (required)} help={full ? undefined : "Use the general number patients should call."} placeholder={full ? undefined : "Enter the practice phone number"} className="ak-field" type="tel" value={contact.phone} onChange={(phone) => updateContact({ phone })} onBlur={() => setTouched((t) => ({ ...t, phone: true }))} aria-invalid={touched.phone && !phoneValid} disabled={savingContact} /> {touched.phone && !phoneValid && Enter a 10-digit phone number.}
Primary practice email (required)} help={full ? undefined : "Use a monitored general inbox for the practice."} placeholder={full ? undefined : "Enter the practice email address"} className="ak-field" type="email" value={contact.email} onChange={(email) => updateContact({ email })} onBlur={() => setTouched((t) => ({ ...t, email: true }))} aria-invalid={touched.email && !emailValid} disabled={savingContact} /> {touched.email && !emailValid && Enter a valid email address.}
{full ? ( updateContact({ timezone: timezone ?? contact.timezone })} placeholder="Start typing a city or region" disabled={savingContact} /> ) : (

Both fields are required to complete this step. We do not copy them from the onboarding administrator's account.

)} {!full && savingContact &&
Saving contact information…
} {contactSaved && Saved} {contactStale && ( <> {RELOAD_DISCARDS_MESSAGE} )}
{full && (

Practice types

{typesError && ( setTypesError(undefined)}> {typesError} )} {practiceTypeOptions.map((option) => ( { setPracticeTypes((current) => checked ? [...current, option.code] : current.filter((c) => c !== option.code), ); setTypesSaved(false); }} disabled={savingTypes} /> ))}
{typesSaved && Saved} {typesStale && ( <> {RELOAD_DISCARDS_MESSAGE} )}
)}
); }; export default IdentityPanel;