import { useMemo, useState } from 'react'; import type { Observation } from 'fhir/r4'; import { Button } from '../../../../components/ui/button.tsx'; import { Input } from '../../../../components/ui/input.tsx'; import { Label } from '../../../../components/ui/label.tsx'; import { Textarea } from '../../../../components/ui/textarea.tsx'; import { Alert, AlertDescription } from '../../../../components/ui/alert.tsx'; import { AlertCircle } from 'lucide-react'; import { ToggleGroupControl, ToggleGroupControlOption } from '../../../../components/ui/toggle-group-control.tsx'; import type { SelfReport, SelfReportSource } from '../../../../utils/selfReport'; import { PATIENT_MEASUREMENTS, findMeasurement, buildPatientObservations, buildSelfReportReading, buildNotedSelfReportReading, type PatientReadingInput, } from './patientObservationCatalog.ts'; import './SimpleReadingForm.css'; export interface SimpleReadingFormProps { patientId: string; // Who is recording the reading (e.g. "Patient/123" or "RelatedPerson/456"). // Defaults to the patient when omitted (direct self-entry). performerReference?: string; recentHeight?: { value: number; unitCode: string }; initial?: PatientReadingInput; /** An existing reading is being changed: its recorded time is kept unless changed, and a When left blank refuses. */ editing?: boolean; saving?: boolean; error?: string | null; // This opening saves nothing more (refused, unconfirmed or stopped part way); the page says why. ended?: boolean; /** The patient is adding a new reading about themself, so it may be their own report. */ askSource?: boolean; /** The patient is changing their own report: it carries no note, so where it came from is always asked again. */ reportOnly?: boolean; /** The answers offered; a reading copied from another record can only stay copied. */ sources?: readonly SelfReportSource[]; onSave: (observations: Observation[], report?: SelfReport) => void; onCancel: () => void; } // datetime-local string for "now" or from an ISO instant. const toLocalInput = (iso?: string): string => { const d = iso ? new Date(iso) : new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; const isLocalTime = (localValue: string): boolean => localValue.trim() !== '' && !Number.isNaN(new Date(localValue).getTime()); const SimpleReadingForm = ({ patientId, performerReference, recentHeight, initial, editing, saving, error, ended, askSource, reportOnly, sources, onSave, onCancel }: SimpleReadingFormProps) => { const [key, setKey] = useState(initial?.measurementKey ?? PATIENT_MEASUREMENTS[0].key); const measurement = useMemo(() => findMeasurement(key)!, [key]); const [unitCode, setUnitCode] = useState(initial?.unitCode ?? measurement.units[0].code); const [value, setValue] = useState(initial?.value != null ? String(initial.value) : ''); const [systolic, setSystolic] = useState(initial?.systolic != null ? String(initial.systolic) : ''); const [diastolic, setDiastolic] = useState(initial?.diastolic != null ? String(initial.diastolic) : ''); // An edit shows the recorded time (blank when none was recorded), and sends it back exactly as stored unless changed. const recorded = editing ? initial?.effectiveDateTime || undefined : undefined; const recordedShown = recorded ? toLocalInput(recorded) : ''; const [when, setWhen] = useState(editing ? recordedShown : toLocalInput(initial?.effectiveDateTime)); const whenMissing = !!editing && !isLocalTime(when); const [note, setNote] = useState(initial?.note ?? ''); const [touched, setTouched] = useState(false); // Asked, never assumed. A note cannot ride on a patient's own report, so a noted reading is saved the ordinary way. const [source, setSource] = useState(); const asksSource = !!askSource && (!!reportOnly || note.trim() === ''); const offers = (answer: SelfReportSource) => !sources || sources.includes(answer); const onPickMeasurement = (nextKey: string) => { setKey(nextKey); const next = findMeasurement(nextKey)!; setUnitCode(next.units[0].code); setValue(''); setSystolic(''); setDiastolic(''); }; const num = (s: string): number | undefined => { const n = Number(s); return s.trim() !== '' && !Number.isNaN(n) ? n : undefined; }; // Bounds (min/max) are calibrated to each measurement's default unit only. Only warn when // the reading is expressed in that default unit - a non-default unit (e.g. °C instead of // °F) would otherwise trip a false "unusual" warning without any per-unit conversion. const outOfRange = (n?: number): boolean => n != null && measurement.min != null && measurement.max != null && (n < measurement.min || n > measurement.max); const isBp = measurement.inputType === 'bp'; const valid = isBp ? num(systolic) != null && num(diastolic) != null : num(value) != null; const isDefaultUnit = unitCode === measurement.units[0].code; const bpOutOfRange = isBp && (outOfRange(num(systolic)) || outOfRange(num(diastolic))); const singleOutOfRange = !isBp && isDefaultUnit && outOfRange(num(value)); // datetime-local can be cleared to '' by the user; never let that crash the save - fall // back to "now" instead of blocking submit. const toEffectiveIso = (localValue: string): string => { const d = new Date(localValue); return localValue.trim() !== '' && !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString(); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setTouched(true); if (!valid || whenMissing || (asksSource && !source)) return; const input: PatientReadingInput = { measurementKey: key, effectiveDateTime: recorded && when === recordedShown ? recorded : toEffectiveIso(when), note, ...(isBp ? { systolic: num(systolic), diastolic: num(diastolic) } : { unitCode, value: num(value), ...(key === 'weight' && recentHeight && !reportOnly ? { knownHeight: recentHeight } : {}) }), }; // A new reading with the patient's note goes by the same route, carrying the note and no answer about its source. const report = asksSource && source ? buildSelfReportReading(input, source) : askSource && !reportOnly && !editing ? buildNotedSelfReportReading(input) : undefined; onSave(buildPatientObservations(input, patientId, performerReference), report); }; return (
{error && ( {error} )}
{isBp ? (
setSystolic(e.target.value)} />
setDiastolic(e.target.value)} />
mmHg
) : (
setValue(e.target.value)} />
{measurement.units.length > 1 ? ( ) : ( {measurement.units[0].display} )}
)} {touched && !valid && (

Enter a number{isBp ? ' for both top and bottom.' : '.'}

)} {(bpOutOfRange || singleOutOfRange) && (

That looks unusual — double-check the number.

)}
setWhen(e.target.value)} aria-invalid={touched && whenMissing ? true : undefined} aria-describedby={touched && whenMissing ? 'when-hint' : undefined} /> {touched && whenMissing && (

Enter when this reading was taken.

)}
{!reportOnly && (