import React, { useMemo, useState } from 'react'; import type { Encounter, Patient } from 'fhir/r4'; import { Check, Loader2, TriangleAlert } from 'lucide-react'; import { Input } from '../../../components/ui/input'; import { ToggleGroupControl, ToggleGroupControlOption } from '../../../components/ui/toggle-group-control'; import { useNativeVitalService } from '../../../services/NativeVitalService'; import { isSaveRetired, isSaveTimedOut, useSaveScope } from '../../../utils/saveOperation'; import { toLocalDateTimeInput } from '../../../utils/dateUtils'; import { buildNativeVitalEntries, SIMPLE_VITALS, computeBmi, type VitalSource, type VitalsInput } from './vitalsCatalog'; type RawVitals = Partial>; type VitalField = keyof VitalsInput; // The server allows a reading time this far ahead as clock difference; later is refused as a future reading. const CLOCK_SKEW_MS = 5 * 60 * 1000; type SaveStatus = | { kind: 'idle' } | { kind: 'success'; count: number } | { kind: 'error' } | { kind: 'empty' } | { kind: 'badTime' } | { kind: 'partial'; saved: number; failed: number } | { kind: 'unconfirmed'; saved: number; unconfirmed: number; failed: number }; // Everything typed for one patient, at one visit, by one viewer. It is shown only for that owner: another patient or // visit on screen sees an empty form at once, and a save started for this one never writes into the next. interface Draft { owner: string; raw: RawVitals; source?: VitalSource; /** When the readings were taken, as typed; unset is the time the form was opened. */ when?: string; saving: boolean; status: SaveStatus; // Sent, and whether they landed is not known. They are not offered for another save until someone has checked. unconfirmed: VitalField[]; } const blank = (owner: string): Draft => ({ owner, raw: {}, saving: false, status: { kind: 'idle' }, unconfirmed: [] }); const fieldsOf = (field: VitalField | 'bloodPressure'): VitalField[] => (field === 'bloodPressure' ? ['systolic', 'diastolic'] : [field]); /** * How one reading's write ended. A 4xx is an answer that nothing was written, so the reading may be sent again. A * request that ran out of time before it was sent wrote nothing either. Anything else - a 5xx, a lost connection, an * answer that could not be read, a deadline with the request out - may have written it, so it is not resent blind. */ const outcomeOf = (result: PromiseSettledResult): 'saved' | 'retired' | 'refused' | 'unconfirmed' => { if (result.status === 'fulfilled') return 'saved'; const error = result.reason as { status?: unknown } | null; if (isSaveRetired(error)) return 'retired'; if (isSaveTimedOut(error)) return error.mayHaveSaved ? 'unconfirmed' : 'refused'; return typeof error?.status === 'number' && error.status >= 400 && error.status < 500 ? 'refused' : 'unconfirmed'; }; interface Props { patient?: Patient; encounter?: Encounter; /** The queue row this form was opened from: a draft belongs to one row, not only to its patient and visit. */ rowKey?: string; /** Asks when the readings were taken, for a chart entry made after the measurement; otherwise they are taken now. */ askWhen?: boolean; onSaved?: () => void; } /** * Compact nurse-facing vitals-entry grid - one-shot capture (no edit-existing/de-dup, a fast-follow). * Raw string state per field so the inputs never fight the user over partial numeric entry; parsed to * numbers only on save. BMI is derived live from height/weight as the user types. */ export const VitalsForm: React.FC = ({ patient, encounter, rowKey, askWhen, onSaved }) => { const { recordVital } = useNativeVitalService(); const owner = patient?.id ? `${patient.id}|${encounter?.id ?? ''}|${rowKey ?? ''}` : ''; const [stored, setStored] = useState(() => blank(owner)); // A change of patient, visit or viewer retires every save started before it; the draft goes with them. const saveScope = useSaveScope(owner || undefined, () => setStored(blank(''))); const draft = stored.owner === owner ? stored : blank(owner); const { raw, source, saving, status, unconfirmed } = draft; // Each owner's form opens at its own time; a typed time replaces it. const openedAt = useMemo(() => (owner ? toLocalDateTimeInput(new Date()) : ''), [owner]); const when = draft.when ?? openedAt; // Writes only into the draft of the owner on screen when it is called. const update = (change: (current: Draft) => Draft) => setStored((prev) => change(prev.owner === owner ? prev : blank(owner))); const bmi = useMemo( () => computeBmi(parseFloat(raw.height ?? ''), parseFloat(raw.weight ?? '')), [raw.height, raw.weight], ); const anyFilled = useMemo( () => (['systolic', 'diastolic', ...SIMPLE_VITALS.map((v) => v.key)] as VitalField[]) .some((key) => !unconfirmed.includes(key) && (raw[key] ?? '').trim() !== ''), [raw, unconfirmed], ); if (!patient?.id) { return (
Vitals capture needs a patient — none selected.
); } const setField = (key: VitalField) => (e: React.ChangeEvent) => { const value = e.target.value; update((d) => ({ ...d, raw: { ...d.raw, [key]: value }, // An unconfirmed outcome stays on screen until someone has checked; typing elsewhere does not hide it. status: d.status.kind === 'idle' || d.status.kind === 'unconfirmed' ? d.status : { kind: 'idle' }, })); }; const handleSave = async () => { if (!source || saving || unconfirmed.length > 0) return; const parsed: VitalsInput = {}; const assign = (key: VitalField) => { const n = parseFloat(raw[key] ?? ''); if (!Number.isNaN(n)) parsed[key] = n; }; assign('systolic'); assign('diastolic'); for (const v of SIMPLE_VITALS) assign(v.key); let takenAt = new Date(); if (askWhen) { takenAt = new Date(when); if (Number.isNaN(takenAt.getTime()) || takenAt.getTime() > Date.now() + CLOCK_SKEW_MS) { update((d) => ({ ...d, status: { kind: 'badTime' } })); return; } } const entries = buildNativeVitalEntries(parsed, encounter?.id, takenAt.toISOString(), source); // If no valid readings were built, show a neutral nudge and return early if (entries.length === 0) { update((d) => ({ ...d, status: { kind: 'empty' } })); return; } // One save for the whole set: its deadline bounds every reading, and it belongs to this patient, visit and viewer. const operation = saveScope.start(); const patientId = patient.id as string; update((d) => ({ ...d, saving: true, status: { kind: 'idle' } })); const results = await Promise.allSettled( entries.map(({ entry }) => operation.step(() => recordVital(patientId, entry, operation.transport), true)), ); const outcomes = results.map(outcomeOf); // Retired under it: the patient, visit or viewer moved on, and nothing it learned is shown on the next one. if (!operation.belongs() || outcomes.includes('retired')) return; operation.retire(); results.forEach((result) => { if (result.status === 'rejected') console.error('Failed to save a vital sign:', result.reason); }); const fields = (wanted: string) => entries.flatMap(({ field }, i) => (outcomes[i] === wanted ? fieldsOf(field) : [])); const savedFields = fields('saved'); const unconfirmedFields = fields('unconfirmed'); const saved = outcomes.filter((o) => o === 'saved').length; const failed = outcomes.filter((o) => o === 'refused').length; const held = outcomes.filter((o) => o === 'unconfirmed').length; update((d) => { // Only what was confirmed saved leaves the form. A reading that was never sent - a lone blood pressure half - // stays as typed, and so does one that was refused. const nextRaw = Object.fromEntries(Object.entries(d.raw).filter(([key]) => !savedFields.includes(key as VitalField))) as RawVitals; const remaining = Object.values(nextRaw).some((value) => (value ?? '').trim() !== ''); const nextStatus: SaveStatus = held > 0 ? { kind: 'unconfirmed', saved, unconfirmed: held, failed } : failed === 0 ? { kind: 'success', count: saved } : saved === 0 ? { kind: 'error' } : { kind: 'partial', saved, failed }; return { ...d, raw: nextRaw, saving: false, status: nextStatus, unconfirmed: unconfirmedFields, // The next set of readings is asked again; readings still on the form keep the answer they were given. source: remaining ? d.source : undefined, when: remaining ? d.when : undefined, }; }); // A reading that saved, or may have, is on the chart's list: refresh it so it can be checked. if (saved > 0 || held > 0) onSaved?.(); }; // The person has checked the chart: what could not be confirmed leaves the form, and is never sent from it. const clearUnconfirmed = () => update((d) => { const nextRaw = Object.fromEntries(Object.entries(d.raw).filter(([key]) => !d.unconfirmed.includes(key as VitalField))) as RawVitals; const remaining = Object.values(nextRaw).some((value) => (value ?? '').trim() !== ''); return { ...d, raw: nextRaw, unconfirmed: [], status: { kind: 'idle' }, source: remaining ? d.source : undefined, when: remaining ? d.when : undefined }; }); // The only-one-BP-half case is the common "why didn't it save" gotcha; call it out specifically. // Otherwise the filled value(s) just weren't parseable numbers, so keep the nudge generic. const sysFilled = (raw.systolic ?? '').trim() !== ''; const diaFilled = (raw.diastolic ?? '').trim() !== ''; const bpHalf = sysFilled !== diaFilled; const emptyHint = bpHalf ? 'Blood pressure needs both numbers to record.' : 'Enter a number to save.'; const locked = (key: VitalField) => saving || unconfirmed.includes(key); return (
{/* Two columns, because this cell holds TWO fields where every other holds one. In a single column each input was 63px, and 14px of field padding either side left ~35px of text - not enough for three digits, so a systolic of 120 clipped its last digit. The padding is --ak-field-padding-x and is right; the cell was too narrow for what it carries. */}
/
{SIMPLE_VITALS.map((v) => (
))}
{bmi ?? '—'}
{askWhen && (
{ const value = e.target.value; update((d) => ({ ...d, when: value, status: d.status.kind === 'badTime' ? { kind: 'idle' } : d.status })); }} />
)} {/* '' and not undefined: undefined lets the group keep the last choice on screen after the answer was cleared. */} update((d) => ({ ...d, source: value as VitalSource }))}>
{status.kind === 'partial' && ( Saved {status.saved} · {status.failed} not saved — retry )} {status.kind === 'success' && ( Saved · {status.count} recorded )} {status.kind === 'error' && ( Couldn't save — retry )} {status.kind === 'unconfirmed' && ( {status.saved > 0 && <>Saved {status.saved} · } {status.unconfirmed} could not be confirmed — check the vitals list before entering {status.unconfirmed === 1 ? 'it' : 'them'} again {status.failed > 0 && <> · {status.failed} not saved} )} {status.kind === 'badTime' && ( Give the time the readings were taken; it cannot be in the future. )} {status.kind === 'empty' && ( {emptyHint} )} {bpHalf && status.kind !== 'idle' && status.kind !== 'empty' && ( Blood pressure needs both numbers to record. )}
); }; export default VitalsForm;