"use client"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Segmented } from "@/components/ui/segmented"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { isUnreadableRange, parseReferenceRange, } from "@/lib/labs/parse-reference-range"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { BiomarkerForm } from "./biomarker-form"; import { useLabNumber } from "./use-lab-format"; import type { BiomarkerDto, BiomarkerListResponse, LabResultDto, } from "./types"; const NOTE_MAX_LENGTH = 2000; const DEFINE_NEW = "__define_new__"; function defaultTakenAtValue() { const now = new Date(); const offset = now.getTimezoneOffset(); const local = new Date(now.getTime() - offset * 60 * 1000); return local.toISOString().slice(0, 16); } /** Parse a free-text decimal that may use a comma separator. */ function parseDecimal(raw: string): number | null { const trimmed = raw.trim().replace(",", "."); if (trimmed === "") return null; const n = Number(trimmed); return Number.isFinite(n) ? n : null; } /** The per-reading fields "Save & add another" decides to keep or clear. */ export interface LabEntryDraft { biomarkerId: string; value: string; valueText: string; takenAt: string; note: string; sourceRange: string; visitId: string | null; } /** * The form after "Save & add another": the reading is cleared, everything that * describes the report it came from stays. The draw date and the visit are * shared by every value on one report, and the biomarker stays because a run * of entries is as often one marker over several dates as several markers on * one date; re-picking it each time is what the button exists to avoid. * * Clearing the biomarker here used to drop the picker out of its controlled * state. It kept showing the marker it last held while the form value was * empty, so the next save refused with "Pick a biomarker first" under a * visibly picked marker. */ export function nextEntryAfterKeepOpen(draft: LabEntryDraft): LabEntryDraft { return { ...draft, value: "", valueText: "", note: "", // The next reading has its own printed window. sourceRange: "", }; } interface LabFormProps { /** When set, the biomarker is pre-selected and locked (add-from-detail). */ lockedBiomarkerId?: string; onSuccess?: (created: LabResultDto) => void; onCancel?: () => void; /** * v1.30.1 — "Save & add another": the sheet stays open, the * reading is cleared for the next entry, and `takenAt` is PRESERVED (a * real lab report is 10-20 analytes sharing one blood-draw date, usually * in the past — re-picking that date for every row was the pain point). * Optional and additive: the second button only renders when the caller * wires this up, so a consumer that never passes it keeps the single-Save * form unchanged. The caller's only job is invalidating the list read — * the form itself, not the caller, owns the "stay open + reset" behaviour. */ onSavedKeepOpen?: (created: LabResultDto) => void; /** * When mounted inside a `` the caller passes the sheet's * footer slot element here. The Cancel / Save action row is portalled into * it so the bottom-sheet branch can sticky-pin it above the keyboard; the * Save button stays tied to the `
` via the HTML `form` attribute so * submit-on-Enter and portalled-click both still submit. */ footerSlot?: HTMLElement | null; } /** * v1.18.1 — structured lab-result entry. * * The error-prone free-text path is gone: the user PICKS a biomarker from the * catalog, then enters only the value against its known unit + reference range * (resolved server-side). A "+ define new" row opens the marker-definition * sheet inline and returns with it selected. Panel / unit / range are NOT * re-entered per reading — they live on the biomarker. Only the per-reading * note stays optional here. */ export function LabForm({ lockedBiomarkerId, onSuccess, onCancel, onSavedKeepOpen, footerSlot, }: LabFormProps) { const { t } = useTranslations(); const labNumber = useLabNumber(); const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const formId = useId(); const { data: catalog, isLoading: catalogLoading } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), }); const [biomarkerId, setBiomarkerId] = useState( lockedBiomarkerId ?? "", ); // v1.18.9 — numeric vs qualitative result mode. Numeric mode enters a number // against the marker's unit/range; qualitative mode enters a result text // ("negativ" / "positiv" / "grenzwertig" / free text) and hides unit/range. const [resultType, setResultType] = useState<"numeric" | "qualitative">( "numeric", ); const [value, setValue] = useState(""); const [valueText, setValueText] = useState(""); const [takenAt, setTakenAt] = useState(defaultTakenAtValue); /** * The visit this reading came out of, when the suggestion offered one. * * Deliberately NOT cleared by the keep-open reset below: a real lab report * shares one draw date and one visit across every analyte, so the next row * off the same report keeps both rather than asking again. */ const [visitId, setVisitId] = useState(null); // The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion // asks the server in ISO-8601, so it is converted once here rather than in // the shared field, which must not know about this form's input format. const takenAtAnchor = useMemo(() => { const parsed = new Date(takenAt); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); }, [takenAt]); const [note, setNote] = useState(""); // The window the paper report printed for THIS reading. Optional and folded // away by default: most entries are typed against a catalog marker and never // need it, and a field nobody fills is a field in the way. One text input, // because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0". // The bounds are derived from what is typed; what is typed is kept verbatim. const [sourceRangeOpen, setSourceRangeOpen] = useState(false); const [sourceRange, setSourceRange] = useState(""); // v1.30.1 — which submit button is in flight, so the OTHER one doesn't // also show a spinner. `!== null` replaces the old plain `submitting` // boolean for every disabled check below. const [pendingAction, setPendingAction] = useState< "save" | "saveAndAddAnother" | null >(null); const submitting = pendingAction !== null; const [error, setError] = useState(null); const [defineOpen, setDefineOpen] = useState(false); const [defineFooterEl, setDefineFooterEl] = useState( null, ); const allMarkers = catalog?.biomarkers ?? []; const selected = allMarkers.find((m) => m.id === biomarkerId); // v1.22 — hidden markers drop from the picker, but keep the currently // selected one visible (e.g. editing a reading whose marker was later // hidden) so the Select still resolves its label. const markers = allMarkers.filter((m) => !m.hidden || m.id === biomarkerId); function handleSelect(next: string) { if (next === DEFINE_NEW) { setDefineOpen(true); return; } setBiomarkerId(next); setError(null); } function afterDefine(created: BiomarkerDto) { setDefineOpen(false); queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); setBiomarkerId(created.id); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); // v1.30.1 — both Save and Save & add another are `type="submit"` // buttons tied to this one `` via the HTML `form` attribute (they // render portalled into the sheet's footer, outside the `` tag // itself); `submitter` distinguishes which one fired this submit. const submitter = (e.nativeEvent as SubmitEvent) .submitter as HTMLButtonElement | null; const keepOpen = onSavedKeepOpen != null && submitter?.dataset.action === "save-and-add-another"; if (!biomarkerId) { setError(t("labs.form.pickBiomarkerError")); return; } const isQualitative = resultType === "qualitative"; let numericValue: number | null = null; let qualitativeValue: string | null = null; if (isQualitative) { const trimmed = valueText.trim(); if (trimmed === "") { setError(t("labs.form.requiredError")); return; } qualitativeValue = trimmed; } else { numericValue = parseDecimal(value); if (numericValue === null) { setError(t("labs.form.requiredError")); return; } } setPendingAction(keepOpen ? "saveAndAddAnother" : "save"); try { // Parse client-side so the user sees the derived window before saving, // and send BOTH: the bounds where they could be read, the text always. const printed = isQualitative ? null : parseReferenceRange(sourceRange, selected?.unit); const created = await apiPost("/api/labs", { biomarkerId, ...(isQualitative ? { valueText: qualitativeValue } : { value: numericValue }), ...(printed ? { ...(printed.low !== null ? { sourceReferenceLow: printed.low } : {}), ...(printed.high !== null ? { sourceReferenceHigh: printed.high } : {}), sourceReferenceText: printed.text, } : {}), takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), ...(visitId ? { encounterId: visitId } : {}), }); toast.success( t("labs.form.savedToast"), recordName ? { description: t("recordSharing.toast.savedTo", { name: recordName, }), } : undefined, ); if (keepOpen) { const next = nextEntryAfterKeepOpen({ biomarkerId, value, valueText, takenAt, note, sourceRange, visitId, }); setBiomarkerId(next.biomarkerId); setValue(next.value); setValueText(next.valueText); setTakenAt(next.takenAt); setNote(next.note); setSourceRange(next.sourceRange); setVisitId(next.visitId); setError(null); onSavedKeepOpen?.(created); // The next entry starts at the value, so focus goes there and keeps // the flow keyboard/screen-reader friendly across repeated saves. document .getElementById( resultType === "numeric" ? "lab-value" : "lab-valueText", ) ?.focus(); } else { onSuccess?.(created); } } catch (err) { setError(localizedApiError(err, t, "labs.form.saveError")); } finally { setPendingAction(null); } } const referenceText = selected ? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber) : ""; // What the typed string resolved to, shown back so the user can see whether // a window was read out of it before the reading is saved. When none was, // the hint says the text is kept as written rather than staying silent. const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); const sourceRangeHint = parsedSourceRange ? isUnreadableRange(parsedSourceRange) ? t("labs.form.sourceRangeUnparsed") : `${formatReferenceRange( parsedSourceRange.low, parsedSourceRange.high, labNumber, )} ${selected?.unit ?? ""}`.trim() : ""; const footerNode = ( <> {onCancel ? ( ) : null} {/* v1.30.1 — additive: only renders when the caller wires up `onSavedKeepOpen`. `data-action` is how `handleSubmit` tells this button apart from the plain Save below via `event.submitter`. */} {onSavedKeepOpen ? ( ) : null} ); return ( <>
{/* Always controlled: `""` shows the placeholder. Passing `undefined` for an empty pick switched the picker to its own internal state, which kept displaying the last marker while the form held none. */} {markers.length === 0 && !catalogLoading ? (

{t("labs.form.noBiomarkersHint")}

) : null}
{/* One column at every width, as in the measurement form. A second column halved the draw date's cell, and `DateTimeField` sets its date and time halves side by side from `sm:` up, so the date itself was squeezed until it could only be set through the calendar (#1214). The column is `grid-cols-1`, not the implicit `auto` column: an `auto` track grows to the widest cell's min-content, and the date and time inputs' intrinsic widths follow the font, so with wider metrics the track outgrew the dialog body and the value input ran into its padding. */}
{resultType === "numeric" ? ( <> setValue(e.target.value)} placeholder="0.0" required /> ) : ( <> setValueText(e.target.value)} placeholder={t("labs.form.qualitativePlaceholder")} maxLength={120} required /> )}
{/* Anchored on the draw date, not on now: backdating a reading moves the offer with it. */} {resultType === "numeric" && selected && referenceText ? (

{t("labs.referenceLabel")} {referenceText} {selected.unit}

) : null} {resultType === "numeric" ? (
{sourceRangeOpen ? ( <> setSourceRange(e.target.value)} placeholder={t("labs.form.sourceRangePlaceholder")} maxLength={120} autoFocus />

{sourceRangeHint || t("labs.form.sourceRangeHint")}

) : ( )}
) : null}