import { useState, useEffect, useRef } from "react"; import { UserRound } from "lucide-react"; import { Input } from "../../components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../components/ui/select"; import { Skeleton } from "../../components/ui/skeleton"; import { Patient, Attachment } from "fhir/r4"; import { toast } from "sonner"; import { IValueSet, useValueSetServiceContext } from "../../services/ValueSetService"; import { codedAnswerLabel } from "../../utils/codedAnswerLabel"; import { usePatientServiceContext } from "../../services/PatientService"; import { formatLongDate } from "../../utils/dateUtils"; import { formatFhirHumanName } from "../../utils/fhirHumanName"; import { type EditableFieldOption } from "../FieldHistory/EditableField"; import { ProfileField, ProfileInput, ProfileRow, ProfileRows } from "./ProfileFields"; import { ProfileSection } from "./ProfileSection"; import { ProfilePhotoControl } from "./ProfilePhotoControl"; import { applyDemographics, listDisplays, readDemographics, type Demographics } from "./demographics"; import { DemographicsEditor } from "./DemographicsEditor"; import { DEMOGRAPHIC_EXTENSIONS } from "./demographics"; import { enrolledLabel } from "../../utils/tribalAffiliation"; import { useSectionEditor, type SectionUpdatePatient } from "./useSectionEditor"; import { interpreterNeededLabel } from "../../utils/interpreterNeeded"; import { PROFILE_HISTORY } from "../../utils/fieldHistory/profileHistoryFields"; import { isDemographicProjection } from "../../utils/patientProjection"; type PatientInfoProps = { data: Patient | undefined; isLoading: boolean | undefined; onPatientUpdated?: (updatedPatient: Patient) => void; updatePatient: SectionUpdatePatient; onSeen?: (patient: Patient) => void; submitting: boolean; /** Shows the section without its Edit control. */ readOnly?: boolean; setSubmitting: (submitting: boolean) => void; photo: Attachment | undefined; setPhoto: (photo: Attachment | undefined) => void; }; type NameDraft = { prefix: string; firstName: string; middleName: string; lastName: string; suffix: string; }; const PREFIX_OPTIONS: EditableFieldOption[] = [ { value: "none", label: "None" }, { value: "Mr", label: "Mr" }, { value: "Ms", label: "Ms" }, { value: "Mrs", label: "Mrs" }, { value: "Dr", label: "Dr" }, { value: "Prof", label: "Prof" }, ]; const SUFFIX_OPTIONS: EditableFieldOption[] = [ { value: "none", label: "None" }, { value: "Jr", label: "Jr" }, { value: "Sr", label: "Sr" }, { value: "II", label: "II" }, { value: "III", label: "III" }, { value: "IV", label: "IV" }, ]; const INTERPRETER_OPTIONS: EditableFieldOption[] = [ { value: "yes", label: "Yes" }, { value: "no", label: "No" }, ]; // "none" is the placeholder select value for an absent prefix/suffix -- normalize it (and any blank // value) back to `undefined` so it never round-trips into FHIR as the literal string "none". const normalizePrefixSuffix = (value: string): string | undefined => { if (!value || value === "none") return undefined; return value; }; const ensureNonEmptyArray = (array: T[] | undefined): T[] | undefined => { return array && array.length > 0 ? array : undefined; }; // Prefix, given, middle, family and suffix are edited and saved together as ONE `Patient.name[0]` // entry, so a name can never be half-saved as two separate writes. const nameSelect = ( id: string, label: string, value: string, options: EditableFieldOption[], onChange: (value: string) => void, disabled: boolean, ) => ( ); const NameEditor = ( draft: NameDraft, setDraft: (value: NameDraft) => void, disabled: boolean, ) => (
{nameSelect("you-prefix", "Prefix", draft.prefix, PREFIX_OPTIONS, (prefix) => setDraft({ ...draft, prefix }), disabled)} setDraft({ ...draft, firstName: event.target.value })} /> setDraft({ ...draft, middleName: event.target.value })} /> setDraft({ ...draft, lastName: event.target.value })} /> {nameSelect("you-suffix", "Suffix", draft.suffix, SUFFIX_OPTIONS, (suffix) => setDraft({ ...draft, suffix }), disabled)}
); type YouDraft = NameDraft & { gender: string; birthDate: string; maritalStatus: string; interpreter: "yes" | "no"; demographics: Demographics; }; /** The form, filled from a record. */ const youDraftOf = (patient: Patient | undefined): YouDraft => { const primaryName = patient?.name?.[0]; return { prefix: primaryName?.prefix?.[0] || "none", firstName: primaryName?.given?.[0] ?? "", middleName: primaryName?.given?.slice(1).join(" ") ?? "", lastName: primaryName?.family ?? "", suffix: primaryName?.suffix?.[0] || "none", gender: patient?.gender ?? "", birthDate: patient?.birthDate ?? "", maritalStatus: patient?.maritalStatus?.coding?.[0]?.code ?? "", // A recorded No (or Unknown) is an answer, not a Yes; only an absent answer reads as No. interpreter: interpreterNeededLabel(patient) === "Yes" ? "yes" : "no", demographics: readDemographics(patient), }; }; const PatientInfo = ({ data, isLoading, updatePatient, onSeen, submitting, setSubmitting, photo, setPhoto, readOnly = false, }: PatientInfoProps) => { const valueSetService = useValueSetServiceContext(); const { extensionUrls } = usePatientServiceContext(); const [genderOptions, setGenderOptions] = useState([]); const [maritalStatusOptions, setMaritalStatusOptions] = useState([]); const valueSetsLoaded = useRef(false); useEffect(() => { if (valueSetsLoaded.current) return; const loadValueSets = async () => { try { const [genderValueSet, maritalStatusValueSet] = await Promise.all([ valueSetService.fetchValueSet(valueSetService.valueSetUrls.administrativeGender), valueSetService.fetchValueSet(valueSetService.valueSetUrls.maritalStatus), ]); setGenderOptions(genderValueSet); setMaritalStatusOptions(maritalStatusValueSet); valueSetsLoaded.current = true; } catch { toast.error("Failed to load form options", { position: "top-right" }); } }; loadValueSets(); }, [valueSetService]); const genderSelectOptions: EditableFieldOption[] = genderOptions.map((option) => ({ value: option.code, label: option.display, })); // Labelled for reading; the code (and, on save, the value set's own display) is what is stored. const maritalSelectOptions: EditableFieldOption[] = maritalStatusOptions.map((option) => ({ value: option.code, label: codedAnswerLabel(option), })); const gender = data?.gender ?? ""; const birthDate = data?.birthDate ?? ""; const maritalStatus = data?.maritalStatus?.coding?.[0]?.code ?? ""; // A recorded No (or Unknown) is an answer, not a Yes; only an absent answer reads as No. const interpreterAnswer = interpreterNeededLabel(data); const saved = youDraftOf(data); const youEdits = [extensionUrls.interpreterRequired, ...Object.values(DEMOGRAPHIC_EXTENSIONS)]; /** What You edits, compared at save time against the latest read. */ const youOf = (patient: Patient) => ({ name: patient.name, gender: patient.gender, birthDate: patient.birthDate, maritalStatus: patient.maritalStatus, communication: patient.communication, extension: (patient.extension ?? []).filter((ext) => youEdits.includes(ext.url)), }); // One read of the latest record, every change in this section applied to it, one write: only while this section // is as it was when editing opened, and never over another change here. See useSectionEditor. const { editing, draft, setDraft, saving, error, stale, reloading, open, cancel, save, reload } = useSectionEditor({ patient: data, pick: youOf, draftOf: youDraftOf, // Only what the person changed is written, onto the record as it is now: an untouched name keeps its period and // extensions, an untouched marital status its other codings, untouched languages their own entries. apply: (next, you, opening) => { const was = opening.draft; const nameChanged = (["prefix", "firstName", "middleName", "lastName", "suffix"] as const).some((key) => you[key] !== was[key]); if (nameChanged) { const prefixValue = normalizePrefixSuffix(you.prefix); const suffixValue = normalizePrefixSuffix(you.suffix); // The recorded entry, its use, period and extensions kept; its text described the old name, so it goes. const recorded = { ...next.name?.[0] }; delete recorded.text; next.name = [ { ...recorded, family: you.lastName, given: you.middleName.trim() ? [you.firstName, you.middleName.trim()] : [you.firstName], prefix: ensureNonEmptyArray(prefixValue ? [prefixValue] : undefined), suffix: ensureNonEmptyArray(suffixValue ? [suffixValue] : undefined), use: recorded.use ?? ("official" as const), }, ...(next.name ?? []).slice(1), ]; } applyDemographics(next, you.demographics, was.demographics); if (you.gender !== was.gender) next.gender = (you.gender || undefined) as Patient["gender"]; if (you.birthDate !== was.birthDate) next.birthDate = you.birthDate || undefined; if (you.maritalStatus !== was.maritalStatus) { const marital = maritalStatusOptions.find((option) => option.code === you.maritalStatus); next.maritalStatus = you.maritalStatus ? { coding: [{ system: marital?.system, code: you.maritalStatus, display: marital?.display }], text: marital?.display } : undefined; } // Left as recorded unless changed here, so a saved No or Unknown is not dropped by another edit. if (you.interpreter !== opening.draft.interpreter) { const extensions = (next.extension ?? []).filter((ext) => ext.url !== extensionUrls.interpreterRequired); if (you.interpreter === "yes") extensions.push({ url: extensionUrls.interpreterRequired, valueCode: "Y" }); next.extension = extensions.length ? extensions : undefined; } return next; }, updatePatient, onSeen, failure: "Your details were not saved", }); const selectField = ( id: keyof YouDraft, label: string, options: EditableFieldOption[], placeholder: string, ) => ( ); if (isLoading) { return (
{Array.from({ length: 6 }, (_, index) => )}
); } return (