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 (