import { useEffect, useId, useRef, useState } from "react"; import { OverlayActions } from "../../../../components/ui/overlay-actions"; import { useDirtyReport } from "../../../../hooks/useDirtyReport"; import type { Address, Location, Organization } from "fhir/r4"; import { useAuthServiceContext, useLocationServiceContext, useOrganizationServiceContext, useValueSetServiceContext, } from "../../../../services"; import { useFhirLocations } from "../../../../hooks"; import { useLocationTimeZone } from "../../../../hooks/useLocationTimeZone"; import { locationTimeZoneOf, withTimezoneExtension } from "../../../../utils/practiceTimezone"; import { isSiteLocation } from "../../../../utils/roomLocation"; import { EMPTY_SITE_LOCATION, buildSiteLocation, findSameAddress, siteLocationAddress, validateSiteLocation, type SiteLocationDraft, type SiteLocationField, } from "../../../../utils/newSiteLocation"; import { US_STATES, sanitizeUsPostalCode } from "../../../../utils/usStates"; import { describeWriteFailure, isRetryableFailure, ownFailureSentence } from "../../../../utils/fetchFailureMessage"; import { TextControl } from "../../../../components/ui/text-control"; import { ComboboxControl } from "../../../../components/ui/combobox-control"; import { DismissibleAlert } from "../../../../components/ui/dismissible-alert"; import { FieldError } from "../../../../components/ui/field"; import LocationTimeZoneField from "../../../../components/LocationTimeZoneField/LocationTimeZoneField"; import { useRefreshSessionOrganization } from "../../../../hooks/useRefreshSessionOrganization"; import { normalizeFhirAddress } from "../../../../utils/fhirAddress"; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, useSaveScope, type SaveOperation } from "../../../../utils/saveOperation"; import { withNewer } from "../../../../utils/versionOrder"; import { holdOffice, isEligibleOffice, officeClaimOf, readOfficeHold, releaseOffice, type FirstOfficeAnswer, type OfficeBasis, type OfficeHold, } from "../../../../utils/receivingOfficeHold"; const PRIMARY_CHANGED_ELSEWHERE = "Someone else changed the practice's primary location after you started this, so this location was not made primary. Look at the locations, then choose again."; const primaryChangedElsewhere = (error: unknown) => error instanceof Error && error.message === PRIMARY_CHANGED_ELSEWHERE ? PRIMARY_CHANGED_ELSEWHERE : undefined; type AddLocationFormProps = { // The practice's primary address becomes this site's, which is what makes it the primary location. makePrimary: boolean; submitLabel: string; nameLabel?: string; nameHelp?: string; timezoneHelp: string; onSaved?: (location: Location) => void; onDirtyChange?: (dirty: boolean) => void; // A setup step names what was not saved, so a retryable failure reads as the deck's "not saved" state. failedTitle?: string; onSaveFailedChange?: (failed: boolean) => void; // A setup step says what it is saving while the request is out. savingText?: string; // A practice with no office: this is its first office, made once whatever the retry, reload or tab, and resumed // from the server after a reload rather than offered as a fresh create. firstOffice?: boolean; }; const FIELD_ORDER: SiteLocationField[] = ["name", "street", "city", "state", "postalCode", "timezone"]; const fieldId = (field: SiteLocationField) => `add-location-${field}`; const STATE_OPTIONS = US_STATES.map((s) => ({ label: s.name, value: s.code })); // admin-v2 "Add a location" and setup step 2: name, address and time zone only. Contacts, hours and // practice types stay in the full location editor. const AddLocationForm = ({ makePrimary, submitLabel, nameLabel = "Name", nameHelp, timezoneHelp, onSaved, onDirtyChange, failedTitle, onSaveFailedChange, savingText, firstOffice = false, }: AddLocationFormProps) => { const { wpOrganization, setWpOrganization } = useAuthServiceContext(); const { createLocation, createFirstOffice, readFirstOffice, updateLocation, setLocationsList } = useLocationServiceContext(); const { updateOrganization } = useOrganizationServiceContext(); const { fetchTimezoneOptions } = useValueSetServiceContext(); const { locationsList } = useFhirLocations(); const [draft, setDraft] = useState(EMPTY_SITE_LOCATION); const [errors, setErrors] = useState>>({}); const [timezoneOptions, setTimezoneOptions] = useState<{ label: string; value: string }[]>([]); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(); // Set once the site exists, so a retry after a failed primary update never creates it twice. const [created, setCreated] = useState(); // Set when making the site primary failed: a retry re-reads the practice instead of resending the refused copy. const [promoteFailed, setPromoteFailed] = useState(false); // Nothing was created and the request can simply be sent again. const [retryFailed, setRetryFailed] = useState(false); // A create whose answer never came may exist already: not offered again, so it is never made twice. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // Making it primary may have landed (no answer came): not tried again until the page is reloaded and checked. const [promoteUnconfirmed, setPromoteUnconfirmed] = useState(false); const showRetry = Boolean(failedTitle && retryFailed && saveError); useEffect(() => onSaveFailedChange?.(showRetry), [showRetry, onSaveFailedChange]); const refreshOrganization = useRefreshSessionOrganization(); // THE PRACTICE'S PRIMARY ADDRESS THE "MAKE PRIMARY" DECISION WAS TAKEN AGAINST, captured at the first attempt. A // retry finishes that decision only while the primary is still that; it is never re-applied over another one. const primaryBasis = useRef<{ address: string } | null>(null); // The first office's operation and frozen entries, and the primary basis the server froze on the office. const officeHold = useRef(undefined); const officeBasis = useRef(null); const practiceId = wpOrganization.current?.id; // Every attempt belongs to this form, the practice and the person signed in; closing the form or either changing // retires it: nothing more is sent, installed in the session or the list, or handed to the parent. And the form // starts again: a location created, a decision taken, or an uncertainty held for one owner is never another's. const saveScope = useSaveScope(wpOrganization.current?.id ?? "no-practice", () => { setSaving(false); setDraft(EMPTY_SITE_LOCATION); setErrors({}); setSaveError(undefined); setCreated(undefined); setPromoteFailed(false); setRetryFailed(false); setCreateUnconfirmed(false); setPromoteUnconfirmed(false); primaryBasis.current = null; officeHold.current = undefined; officeBasis.current = null; }); useEffect(() => { let cancelled = false; fetchTimezoneOptions() .then((zones) => { if (!cancelled) setTimezoneOptions(zones.map((z) => ({ label: z.display || z.code, value: z.code }))); }) .catch(() => { if (!cancelled) setSaveError("Time zones could not be loaded. Close this and try again."); }); return () => { cancelled = true; }; }, [fetchTimezoneOptions]); const timeZone = useLocationTimeZone(draft, !created); // A site already at this address, when this form is making the primary location: an earlier // attempt that created the site but did not finish. Resuming it never creates a second one. const resumeSite = makePrimary && !created && !firstOffice ? findSameAddress(draft, locationsList.filter(isSiteLocation)) : undefined; const dirty = FIELD_ORDER.some((field) => draft[field].trim() !== ""); useDirtyReport(dirty && !created, onDirtyChange); const update = (patch: Partial) => { setDraft((current) => ({ ...current, ...patch })); setErrors((current) => { const next = { ...current }; (Object.keys(patch) as SiteLocationField[]).forEach((field) => delete next[field]); return next; }); }; const promote = async (org: Organization, address: Address, operation: SaveOperation) => { const base = promoteFailed ? await operation.step(() => refreshOrganization(operation.transport)) : org; if (!base) throw new Error("The practice could not be reloaded. Reload the page and try again."); primaryBasis.current ??= { address: normalizeFhirAddress(org.address?.[0]) }; if (normalizeFhirAddress(base.address?.[0]) !== primaryBasis.current.address) throw new Error(PRIMARY_CHANGED_ELSEWHERE); const saved = await operation.step(() => updateOrganization({ ...base, address: [address, ...(base.address ?? []).slice(1)], }, operation.transport), true); setWpOrganization(saved); setPromoteFailed(false); }; // The first office becomes primary only against the basis frozen on it: already the primary is done, the practice // still at that version takes the write conditioned on it, and anything else changed elsewhere and is left alone. const promoteFirst = async (office: Location, operation: SaveOperation) => { const basis = officeBasis.current; if (!basis) return; const current = await operation.step(() => refreshOrganization(operation.transport)); if (!current) throw new Error("The practice could not be reloaded. Reload the page and try again."); const target = office.address ?? siteLocationAddress(draft); if (normalizeFhirAddress(current.address?.[0]) === normalizeFhirAddress(target)) { setWpOrganization(current); setPromoteFailed(false); return; } if (current.meta?.versionId !== basis.orgVersionId) throw new Error(PRIMARY_CHANGED_ELSEWHERE); const saved = await operation.step(() => updateOrganization({ ...current, address: [target, ...(current.address ?? []).slice(1)], }, operation.transport), true); setWpOrganization(saved); setPromoteFailed(false); }; // Takes one answer about the first office into this form: its own office continues, anything else ends this attempt. const settleOffice = (answer: FirstOfficeAnswer, hold: OfficeHold, organizationId: string): Location | undefined => { const release = () => { releaseOffice(organizationId, hold.operationId); officeHold.current = undefined; }; // This attempt's own office continues only while it is still an active site; otherwise it is held, never replaced. const ineligible = (answer.state === "committed" || answer.state === "mismatch") && !isEligibleOffice(answer.location, organizationId); if (answer.state === "committed" && !ineligible && officeClaimOf(answer.location)?.operationId === hold.operationId) { setCreated(answer.location); setCreateUnconfirmed(false); setLocationsList((current) => withNewer(current, answer.location)); officeBasis.current = answer.primary; return answer.location; } if (!ineligible && (answer.state === "committed" || answer.state === "mismatch")) { // Someone else's first office: an existing site to confirm, never this attempt's to make primary. release(); setLocationsList((current) => withNewer(current, answer.location)); setSaveError(`${answer.location.name || "An office"} is already this practice's office. Use it rather than adding another.`); return undefined; } if (ineligible || answer.state === "archived" || answer.state === "held") { release(); setSaveError(answer.state === "archived" ? "This practice's first office was archived. Add an office from Locations." : "This practice has an office we cannot confirm. Contact support before adding another."); return undefined; } setCreateUnconfirmed(true); setSaveError("Whether the office was added could not be confirmed. Check again before adding another."); return undefined; }; const submitFirstOffice = async (org: Organization & { id: string }) => { let hold = officeHold.current; if (!hold && !created) { const found = validateSiteLocation({ ...draft, timezone: timeZone.zone ?? "" }, locationsList); if (found.timezone) found.timezone = zoneError(); const first = FIELD_ORDER.find((field) => found[field]); if (first) { setErrors(found); document.getElementById(fieldId(first))?.focus(); return; } hold = { operationId: crypto.randomUUID(), makePrimary, draft: { ...draft, timezone: timeZone.zone ?? "" } }; officeHold.current = hold; holdOffice(org.id, hold); } const operation = saveScope.start(); setSaving(true); setSaveError(undefined); let location = created; try { if (!location && hold) { const owned = hold; const answer = await operation.step(() => createFirstOffice(buildSiteLocation(owned.draft, org), { operationId: owned.operationId, makePrimary: owned.makePrimary, transport: operation.transport, }), true); location = settleOffice(answer, owned, org.id); if (!location) return; } if (!location) return; await promoteFirst(location, operation); operation.admit(); if (hold) releaseOffice(org.id, hold.operationId); officeHold.current = undefined; onSaved?.(location); } catch (err) { if (isSaveRetired(err)) return; const unknown = outcomeUnknown(err); if (!location) { setCreateUnconfirmed(true); setSaveError(unknown ? "Whether the office was added could not be confirmed. Check again before adding another." : describeWriteFailure(err, "The office could not be added")); return; } setPromoteFailed(true); if (unknown) setPromoteUnconfirmed(true); setSaveError(unknown ? `${location.name || "The office"} was added, but whether it became your primary location could not be confirmed. Check again.` : isSaveTimedOut(err) ? ownFailureSentence(err) : primaryChangedElsewhere(err) ?? describeWriteFailure(err, `${location.name || "The office"} was added, but it could not be made your primary location`)); } finally { if (operation.belongs()) setSaving(false); } }; // After a reload the first office is read back from the server: this session's own office continues where it // stopped, someone else's joins the list to be confirmed, and an unanswered create is checked, never made afresh. useEffect(() => { if (!firstOffice || !practiceId) return; const hold = readOfficeHold(practiceId); const operation = saveScope.start(); setSaving(true); void (async () => { try { const answer = await operation.step(() => readFirstOffice(operation.transport), true); if (answer.state === "none") { if (hold) { officeHold.current = hold; setDraft(hold.draft); } return; } if (!hold) { if (answer.state === "committed") setLocationsList((current) => withNewer(current, answer.location)); else if (answer.state !== "unconfirmed") settleOffice(answer, { operationId: "", makePrimary: false, draft: EMPTY_SITE_LOCATION }, practiceId); return; } officeHold.current = hold; setDraft(hold.draft); const office = settleOffice(answer, hold, practiceId); if (office && officeBasis.current) { setPromoteFailed(true); setSaveError(`${office.name || "Your office"} was added. Save to finish making it your primary location.`); } else if (office) { releaseOffice(practiceId, hold.operationId); officeHold.current = undefined; operation.admit(); onSaved?.(office); } } catch (err) { if (isSaveRetired(err) || !hold) return; officeHold.current = hold; setDraft(hold.draft); setCreateUnconfirmed(true); setSaveError("Whether the office was added could not be checked. Check again before adding another."); } finally { if (operation.belongs()) setSaving(false); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [firstOffice, practiceId]); const zoneError = () => timeZone.check.state === "checking" ? "Still looking up the time zone." : timeZone.check.state === "unchecked" ? "Complete the address to look up its time zone." : "Choose the time zone."; // A zone message is written for the lookup state at save time; once that state moves on it is stale. useEffect(() => { setErrors(({ timezone: _stale, ...rest }) => rest); }, [timeZone.check.state]); // Finishes the interrupted attempt: the saved site gets its zone if it has none, then becomes // the primary location. const resume = async (org: Organization, site: Location) => { const zone = locationTimeZoneOf(site) ?? timeZone.zone; if (!zone) { setErrors({ timezone: zoneError() }); return; } const operation = saveScope.start(); setSaving(true); setSaveError(undefined); try { let location = site; if (!locationTimeZoneOf(site)) { location = await operation.step(() => updateLocation( { ...site, extension: withTimezoneExtension(site.extension, zone) }, site.meta?.versionId, operation.transport, ), true); setLocationsList((current) => withNewer(current, location)); } await promote(org, site.address ?? siteLocationAddress(draft), operation); operation.admit(); onSaved?.(location); } catch (err) { if (isSaveRetired(err)) return; setPromoteFailed(true); if (outcomeUnknown(err)) setPromoteUnconfirmed(true); setSaveError(outcomeUnknown(err) ? `${site.name || "That location"} was added, but whether it became your primary location could not be confirmed. Reload the page to check before trying again.` : isSaveTimedOut(err) ? ownFailureSentence(err) : primaryChangedElsewhere(err) ?? describeWriteFailure(err, `${site.name || "That location"} could not be made your primary location`)); } finally { if (operation.belongs()) setSaving(false); } }; const handleSubmit = async () => { const org = wpOrganization.current; if (!org?.id) { setSaveError("The practice is not loaded. Close this and try again."); return; } // The first office is named: checking again resends the same operation, which can only ever find or make one office. if (firstOffice && org.id) { await submitFirstOffice(org as Organization & { id: string }); return; } if (createUnconfirmed || promoteUnconfirmed) return; if (resumeSite) { await resume(org, resumeSite); return; } if (!created) { const found = validateSiteLocation({ ...draft, timezone: timeZone.zone ?? "" }, locationsList); if (found.timezone) found.timezone = zoneError(); const first = FIELD_ORDER.find((field) => found[field]); if (first) { setErrors(found); document.getElementById(fieldId(first))?.focus(); return; } } const operation = saveScope.start(); setSaving(true); setSaveError(undefined); setRetryFailed(false); let location = created; try { if (!location) { const saved = await operation.step(() => createLocation( buildSiteLocation({ ...draft, timezone: timeZone.zone ?? "" }, org), operation.transport, ), true); location = saved; setCreated(saved); setLocationsList((current) => withNewer(current, saved)); } if (makePrimary) await promote(org, siteLocationAddress(draft), operation); operation.admit(); onSaved?.(location); } catch (err) { if (isSaveRetired(err)) return; if (location) setPromoteFailed(true); // An answer that never came - out of time, or the connection failed after the request may have left - is not // offered again: the location may exist (or be primary) already, and is checked before anything more is tried. const unknown = outcomeUnknown(err); if (!location && unknown) setCreateUnconfirmed(true); if (location && unknown) setPromoteUnconfirmed(true); setRetryFailed(!location && !unknown && !isSaveTimedOut(err) && isRetryableFailure(err)); setSaveError( unknown ? location ? `${location.name} was added, but whether it became your primary location could not be confirmed. Reload the page to check before trying again.` : "Whether the location was added could not be confirmed. Reload the page to check before trying again." : isSaveTimedOut(err) ? ownFailureSentence(err) : location ? primaryChangedElsewhere(err) ?? describeWriteFailure(err, `${location.name} was added, but it could not be made your primary location`) : describeWriteFailure(err, "The location could not be added"), ); } finally { if (operation.belongs()) setSaving(false); } }; const errorOf = (field: SiteLocationField) => errors[field] ? {errors[field]} : null; const locked = saving || Boolean(created) || (firstOffice && Boolean(officeHold.current)); const checkAgain = firstOffice && (createUnconfirmed || promoteUnconfirmed); const formId = useId(); return (
{ event.preventDefault(); void handleSubmit(); }} > {saveError && (showRetry ? ( {failedTitle} Your entries are still here. Try again. ) : ( setSaveError(undefined)}> {saveError} ))}
update({ name })} aria-invalid={Boolean(errors.name)} disabled={locked} /> {errorOf("name")}
update({ street })} aria-invalid={Boolean(errors.street)} disabled={locked} /> {errorOf("street")}
update({ city })} aria-invalid={Boolean(errors.city)} disabled={locked} /> {errorOf("city")}
update({ state: state ?? "" })} disabled={locked} /> {errorOf("state")}
update({ postalCode: sanitizeUsPostalCode(value) })} aria-invalid={Boolean(errors.postalCode)} disabled={locked} /> {errorOf("postalCode")}
{!(resumeSite && locationTimeZoneOf(resumeSite)) && ( { timeZone.correct(zone); setErrors(({ timezone: _cleared, ...rest }) => rest); }} onRetry={timeZone.retry} /> )} {resumeSite && (
{resumeSite.name || "A location"} is already at this address. Use it rather than adding it again.
)} {saving && savingText &&
{savingText}
} ); }; export default AddLocationForm;