import { useRef, useState, type ReactNode } from "react"; import { Phone } from "lucide-react"; import { Patient, ContactPoint, Address } from "fhir/r4"; import { Input } from "../ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { StateSelect } from "../ui/state-select"; import { formatPhoneNumber } from "../../utils/phoneNumber"; import { sanitizeUsPostalCode } from "../../utils/usStates"; import { ProfileField, ProfileInput, ProfileRow, ProfileRows } from "./ProfileFields"; import { ProfileSection } from "./ProfileSection"; import { setTelecomSlots, telecomSlots, telecomUseSlots } from "./telecomSlots"; import { useAuthServiceContext } from "../../services/AuthService"; import ChangeLoginEmailDialog, { type LoginEmailChanged } from "./ChangeLoginEmailDialog"; import { usePatientServiceContext } from "../../services/PatientService"; import { viewerEpoch } from "../../utils/writeThroughHold"; import { useSectionEditor, type SectionUpdatePatient } from "./useSectionEditor"; import { PROFILE_HISTORY, formatProfileAddress, profileAddressHistory } from "../../utils/fieldHistory/profileHistoryFields"; /** What How to reach you edits, compared at save time against the latest read. */ const reachYouOf = (patient: Patient) => ({ telecom: patient.telecom, address: patient.address }); const ADDRESS_USE_LABELS: Record = { home: "Home", work: "Work", temp: "Temporary address", old: "Old address", billing: "Billing address", }; export const addressLabel = (address: Address, index: number): string => (address.use && ADDRESS_USE_LABELS[address.use]) || (index === 0 ? "Address" : `Address ${index + 1}`); const PHONE_USES: { value: string; label: string }[] = [ { value: "mobile", label: "Mobile" }, { value: "home", label: "Home" }, { value: "work", label: "Work" }, ]; /** Radix selects cannot hold an empty value, so "no use recorded" has a name of its own. */ const NO_USE = "none"; /** An address with blank parts removed, or undefined when no street, city or ZIP is left: the state * picker has no empty choice, so a state alone is what clearing an address leaves behind. */ export const cleanAddress = (address: Address): Address | undefined => { const line = (address.line ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry)); const cleaned: Address = { ...address, line: line.length ? line : undefined, city: address.city?.trim() || undefined, state: address.state?.trim() || undefined, postalCode: address.postalCode?.trim() || undefined, }; return cleaned.line || cleaned.city || cleaned.postalCode ? cleaned : undefined; }; interface ContactsProps { data: ContactPoint[]; patient: Patient; updatePatient: SectionUpdatePatient; onSeen?: (patient: Patient) => void; submitting: boolean; /** Shows the section without its Edit control. */ readOnly?: boolean; setSubmitting: (submitting: boolean) => void; } type ReachDraft = { phones: [string, string]; phoneUses: [string, string]; emails: [string, string]; addresses: Address[]; }; const reachDraftOf = (patient: Patient): ReachDraft => ({ phones: telecomSlots(patient.telecom, "phone"), phoneUses: telecomUseSlots(patient.telecom, "phone"), emails: telecomSlots(patient.telecom, "email"), addresses: patient.address ?? [], }); /** One number or address under its sentence-case subheading. */ const Slot = ({ heading, children }: { heading: string; children: ReactNode }) => ( <> {heading} {children} ); /** How to reach you: primary and secondary phone and email, and addresses, edited as one section. */ const Contacts = ({ patient, updatePatient, onSeen, submitting, readOnly = false }: ContactsProps) => { const { currentUser } = useAuthServiceContext(); const [changeLoginOpen, setChangeLoginOpen] = useState(false); // The sign-in email is the account's identity, not a contact point: read-only here, changed through // its own confirmation, and shown only on the viewer's own profile. It is the signed-in account's email // claim: the record's username tag can hold the account's UUID, which is not an email. const isSelfProfile = Boolean(currentUser?.fhir?.id && patient.id && currentUser.fhir.id === patient.id); const loginEmail = isSelfProfile ? currentUser?.cognito?.email || undefined : undefined; const { fetchPatient } = usePatientServiceContext(); const shownPatientId = useRef(patient.id); shownPatientId.current = patient.id; // A committed change is read back through the section reload's path, for the same viewer and patient only. const loginEmailChanged = ({ patientId, epoch }: LoginEmailChanged) => { const admit = () => { if (viewerEpoch() !== epoch || shownPatientId.current !== patientId) throw new DOMException("The viewer or record changed.", "AbortError"); }; void fetchPatient(patientId, false, undefined, true, { admit, signal: new AbortController().signal }) .then((latest) => { admit(); onSeen?.(latest); }) .catch(() => undefined); }; const saved = reachDraftOf(patient); // Saved over the record as it is now, only while this section is as it was when editing opened; a write that // loses a race is read and checked again, never written over another change here. See useSectionEditor. const { editing, draft, setDraft, saving, error, stale, reloading, open, cancel, save, reload } = useSectionEditor({ patient, pick: reachYouOf, draftOf: reachDraftOf, apply: (next, reach) => { setTelecomSlots(next, "phone", reach.phones, reach.phoneUses); setTelecomSlots(next, "email", reach.emails); const addresses = reach.addresses.map(cleanAddress).filter((address): address is Address => Boolean(address)); next.address = addresses.length ? addresses : undefined; return next; }, updatePatient, onSeen, failure: "Your contact details were not saved", }); const input = (kind: "phones" | "emails", index: 0 | 1, label: string) => ( { const values = [...draft[kind]] as [string, string]; values[index] = event.target.value; setDraft({ ...draft, [kind]: values }); }} /> ); const useSelect = (index: 0 | 1) => { const current = draft.phoneUses[index]; const options = current && !PHONE_USES.some((option) => option.value === current) ? [...PHONE_USES, { value: current, label: current.charAt(0).toUpperCase() + current.slice(1) }] : PHONE_USES; return ( ); }; const setAddress = (index: number, patch: Partial
) => setDraft({ ...draft, addresses: draft.addresses.map((current, at) => (at === index ? { ...current, ...patch } : current)) }); const setLine = (index: number, lineIndex: 0 | 1, value: string) => { const line = draft.addresses[index].line ?? []; const lines = [line[0] ?? "", line[1] ?? ""]; lines[lineIndex] = value; setAddress(index, { line: lines }); }; const phoneValue = (index: 0 | 1) => ( <> {formatPhoneNumber(saved.phones[index])} {saved.phoneUses[index] && {saved.phoneUses[index]}} ); return ( <>