import React, { useMemo } from 'react'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { RecordItemOverlay } from './RecordItemOverlay'; import { RecordFieldRow } from './RecordFieldRow'; import { composeRecordForm, noteMayFill, readValue } from '../../../utils/composeRecordForm'; import { fieldsForKind } from '../../../utils/recordFieldCatalogue.generated'; import { setPath, toFormValues } from '../../../utils/recordItemValues'; import type { RecordProposal } from '../../../utils/recordProposals'; import { noteDateValue, noteTimeValue, toLocalDateTimeInput } from '../../../utils/dateUtils'; /** * One proposed item, opened to be confirmed and filed. * * IT IS A REAL FORM, on the same `react-hook-form` context every resource form uses. That is what lets * a coded field render the product's own value-set SEARCH rather than a text box - Spencer, 2026-09-04: * *"it needs to be coded"* - and it means a coded value arrives in exactly the shape that kind's own * `transformToFhirX` already reads. * * NOTHING IS FILED HERE. `onSave` is the caller's, and the write goes out on the ordinary resource route * for the kind. This is the screen between the offer and that write. */ interface Props { proposal: RecordProposal; open: boolean; onClose: () => void; /** * Called once nothing required is missing, with what the clinician confirmed - in the loose object * this kind's own `transformToFhirX` takes. */ onSave: (values: Record) => void; saving?: boolean; /** Save is not offered (see RecordItemOverlay). */ held?: boolean; saveLabel?: string; /** The entries can no longer change: the item is already stored. */ locked?: boolean; /** Why the last attempt did not file, shown inside the overlay because the overlay stays open. */ error?: string; /** An earlier filing still to be settled, shown inside the overlay. */ notice?: React.ReactNode; /** The overlay's heading - the kind being filed, e.g. "Prescription". */ title?: string; closeLabel?: string; subtitle?: string; footer?: string; } /** * What the note supplied, as the control for that field can hold it. * * A CODED FIELD SEEDS AS A DISPLAY-ONLY OPTION rather than a bare string, because that is what the * search renders as a token. It carries no code - the extraction read words, not a terminology - so the * clinician either confirms it by picking the real code or replaces it. What the note guessed is * visible and is never mistaken for something that was coded. */ const seedValue = (shape: string, list: boolean, value: unknown, dayAllowed = false): unknown => { if (value === undefined || value === null || value === '') return list ? [] : undefined; // A time seeds as the moment it names; one that is not a time here seeds empty, so it is owed, not now. // A record's own date also keeps a real day the note gave as that day. if (shape === 'dateTime') return dayAllowed ? noteDateValue(value) : noteTimeValue(value); // Already the right shape - the extraction supplied an object, or a previous edit did. if (typeof value === 'object') return value; const text = String(value); if (shape === 'codeableConcept' || shape === 'code') { const option = { display: text }; return list ? [option] : option; } /* A MEASUREMENT SEEDS AS THE NUMBER, WITH THE UNIT LEFT FOR THE CLINICIAN. WALKED ON THE MOUNT, 2026-09-13, and this is what the note actually produces: the extraction seeded `valueQuantity` with the string **"38.4 C"** - the reading and its unit run together, because the model was never told to separate them and its own filter keeps only scalars. The row showed it under "From your note" as though it were answered, Save armed on the three required fields, and the POST went out with NO value at all: `Number("38.4 C")` is NaN and the field was dropped on the way to the wire. The clinician watched a temperature they had read off their own note save, and the record holds an observation that says a temperature was observed and declines to say what it was. So the number is taken and the unit words are not. Seeding the unit would be worse than leaving it: the search binds a value-set object and renders from its `code`, so a unit carrying only words would be held invisibly and would file as a code nobody chose. The clinician picks it, which is what a coded field already asks of them. A PAIR IS NOT A NUMBER, AND ITS FIRST HALF IS NOT THE READING. "128/78 mmHg" parses as 128 if you let it, which would file a blood pressure of 128 over nothing - a plausible number in the chart that nobody measured, and worse than the empty observation above. A reading whose remainder begins with a slash keeps the note's own words instead, where the row can say it cannot be filed as one number. */ if (shape === 'quantity') { const measured = /^\s*(-?\d+(?:\.\d+)?)\s*(.*)$/.exec(text); if (!measured || measured[2].trimStart().startsWith('/')) return { value: text }; return { value: measured[1] }; } return text; }; /** * An observation filed from the note starts at now when the note gave no time: the server refuses * a vital sign with none, and a time the note did give is kept. */ export const withObservationTime = ( kind: string, seeded: Record, now: Date, ): Record => (kind === 'observation' && !seeded.effectiveType && seeded.effectiveDateTime === undefined ? { ...seeded, effectiveType: 'dateTime', effectiveDateTime: toLocalDateTimeInput(now) } : seeded); export const AddProposedItem: React.FC = ({ proposal, open, onClose, onSave, saving, held, saveLabel, locked, error, notice, title, closeLabel, subtitle, footer, }) => { const fields = fieldsForKind(proposal.kind); const { defaultValues, timeStartedAtNow, timeUnread } = useMemo(() => { const seeded: Record = {}; for (const field of fields ?? []) { // A choice is the clinician's to make, never the note's: it starts unanswered. if (!noteMayFill(field)) continue; const value = seedValue(field.shape, Boolean(field.list), proposal.prefilled[field.name], Boolean(field.dayAllowed)); // Seeded where the control writes it - `oralDiet.type` as `{ oralDiet: { type } }` - so no flat key is left // behind to outlive the clinician's own choice in the same field. if (value !== undefined) setPath(seeded, field.name, value); } const timed = withObservationTime(proposal.kind, seeded, new Date()); // Each date and time the note gave that is not one here (an impossible day, words, a day with no time): what the // note gave, so the row can show it while the field is owed. const timeUnread: Record = {}; for (const field of fields ?? []) { if (field.shape !== 'dateTime' || readValue(seeded, field.name) !== '') continue; const given = proposal.prefilled[field.name]; timeUnread[field.name] = typeof given === 'string' ? given : ''; } return { defaultValues: timed, timeStartedAtNow: timed !== seeded, timeUnread }; // Seeded ONCE, from the proposal. Re-seeding would overwrite a correction the clinician just // made with the extraction's original guess. // eslint-disable-next-line react-hooks/exhaustive-deps }, [proposal.id]); const methods = useForm({ defaultValues }); /* WATCHED, so the bands are live. Answering the last required field has to move it up into "From your note", drop the count and enable Save as the clinician types - a form whose bands were computed once at open would leave Save disabled over a finished item. */ const values = useWatch({ control: methods.control }) as Record; // A date the note gave that cannot be filed is owed even on an arm the overlay would not otherwise demand: left // optional, the item would file without it and the note's date would be lost without a word. const owedFields = useMemo( () => fields?.map((field) => (timeUnread[field.name] !== undefined && !field.required ? { ...field, required: true } : field)), [fields, timeUnread], ); const composed = useMemo( () => (owedFields ? composeRecordForm(owedFields, values ?? {}) : undefined), [owedFields, values], ); /* A KIND WITH NO FORM CANNOT BE OPENED, and it says so by rendering nothing rather than showing an empty overlay with a working Save. Referral and lab order are served by their own purpose-built dialogs; the manual search is how a clinician reaches those, and an empty form here would file an item nobody described. */ if (!fields || !composed) { return null; } return ( ( )} onSave={() => onSave(toFormValues(fields, methods.getValues()))} saving={saving} held={held} saveLabel={saveLabel} error={error} notice={notice} subtitle={subtitle} footer={footer} /> ); }; export default AddProposedItem;