Merge pull request #1058 from BernhardBuckel/fix/ocr-review-date-validation

fix(labs): block a lab-scan save while a selected reading is incomplete
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-01 00:43:50 +02:00
commit 3d6b052a05
12 files changed
+457 -38

No files matched your search

+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "Nichts zu speichern",
"noRows": "In diesem Befund konnten keine Werte gefunden werden.",
"nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
"validationSummary": "Betroffene Werte: {count}. Ergänze die markierten Felder oder wähle diese Werte ab.",
"errorDate": "Bitte ein Datum angeben.",
"errorAnalyte": "Bitte einen Namen angeben.",
"errorValue": "Bitte einen Wert angeben.",
"errorResult": "Bitte ein Ergebnis angeben.",
"errorUnit": "Bitte eine Einheit angeben.",
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
"providerUnsupported": "Dein KI-Anbieter kann keine Bilder lesen. Konfiguriere ein Modell mit Bildverständnis.",
"consentRequired": "Für das Scannen eines Befunds ist die Einwilligung zur KI-Verarbeitung erforderlich.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "Nothing to save",
"noRows": "No readings could be found in this report.",
"nothingToSave": "Select at least one reading to save.",
"validationSummary": "Readings needing attention: {count}. Complete the marked fields or deselect those readings.",
"errorDate": "Add the sample date.",
"errorAnalyte": "Add a biomarker name.",
"errorValue": "Add a value.",
"errorResult": "Add a result.",
"errorUnit": "Add a unit.",
"saveFailed": "Couldn't save the readings. Please try again.",
"providerUnsupported": "Your AI provider can't read images. Configure a vision-capable model.",
"consentRequired": "AI processing consent is required to scan a report.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "No hay nada que guardar",
"noRows": "No se encontraron valores en este informe.",
"nothingToSave": "Selecciona al menos un valor para guardar.",
"validationSummary": "Lecturas que requieren atención: {count}. Completa los campos marcados o anula la selección de esas lecturas.",
"errorDate": "Añade la fecha de la muestra.",
"errorAnalyte": "Añade el nombre del biomarcador.",
"errorValue": "Añade un valor.",
"errorResult": "Añade un resultado.",
"errorUnit": "Añade una unidad.",
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
"providerUnsupported": "Tu proveedor de IA no puede leer imágenes. Configura un modelo con visión.",
"consentRequired": "Se requiere consentimiento de procesamiento de IA para escanear un informe.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "Rien à enregistrer",
"noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
"nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
"validationSummary": "Valeurs à compléter : {count}. Complétez les champs signalés ou désélectionnez ces valeurs.",
"errorDate": "Indiquez la date du prélèvement.",
"errorAnalyte": "Indiquez le nom du biomarqueur.",
"errorValue": "Indiquez une valeur.",
"errorResult": "Indiquez un résultat.",
"errorUnit": "Indiquez une unité.",
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
"providerUnsupported": "Votre fournisseur d'IA ne peut pas lire les images. Configurez un modèle capable de vision.",
"consentRequired": "Le consentement au traitement par IA est requis pour scanner un compte rendu.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "Nulla da salvare",
"noRows": "Nessun valore trovato in questo referto.",
"nothingToSave": "Seleziona almeno un valore da salvare.",
"validationSummary": "Valori da completare: {count}. Completa i campi evidenziati o deseleziona quei valori.",
"errorDate": "Inserisci la data del prelievo.",
"errorAnalyte": "Inserisci il nome del biomarcatore.",
"errorValue": "Inserisci un valore.",
"errorResult": "Inserisci un risultato.",
"errorUnit": "Inserisci un'unità.",
"saveFailed": "Impossibile salvare i valori. Riprova.",
"providerUnsupported": "Il tuo provider IA non può leggere le immagini. Configura un modello con visione.",
"consentRequired": "Per scansionare un referto è richiesto il consenso al trattamento tramite IA.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "저장할 항목이 없어요",
"noRows": "이 결과지에서 측정값을 찾지 못했어요.",
"nothingToSave": "저장할 측정값을 하나 이상 선택해 주세요.",
"validationSummary": "확인이 필요한 수치: {count}개. 표시된 항목을 채우거나 해당 수치의 선택을 해제하세요.",
"errorDate": "채취 날짜를 입력하세요.",
"errorAnalyte": "바이오마커 이름을 입력하세요.",
"errorValue": "값을 입력하세요.",
"errorResult": "결과를 입력하세요.",
"errorUnit": "단위를 입력하세요.",
"saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
"providerUnsupported": "AI 제공자가 이미지를 읽지 못해요. 이미지를 읽는 모델로 설정해 주세요.",
"consentRequired": "결과지를 스캔하려면 AI 처리 동의가 필요해요.",
+6
View File
@@ -9013,6 +9013,12 @@
"savedEmptyToast": "Nie ma nic do zapisania",
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
"validationSummary": "Wyniki wymagające uwagi: {count}. Uzupełnij oznaczone pola lub odznacz te wyniki.",
"errorDate": "Podaj datę pobrania próbki.",
"errorAnalyte": "Podaj nazwę biomarkera.",
"errorValue": "Podaj wartość.",
"errorResult": "Podaj wynik.",
"errorUnit": "Podaj jednostkę.",
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
"providerUnsupported": "Twój dostawca AI nie może odczytywać obrazów. Skonfiguruj model z obsługą obrazu.",
"consentRequired": "Do zeskanowania wyniku wymagana jest zgoda na przetwarzanie przez AI.",
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import type { OcrReviewRow } from "../ocr-review-types";
import {
collectRowErrors,
planSave,
toCommitRow,
validateReviewRow,
} from "../ocr-review-validation";
function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
return {
key: "LDL-0",
analyte: "LDL",
value: 2.4,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 3,
referenceText: null,
takenAt: "2026-06-10",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
duplicateOf: null,
confirmed: true,
...overrides,
};
}
describe("validateReviewRow", () => {
it.each([
["a complete numeric row", {}, {}],
[
"a complete qualitative row",
{ value: null, valueText: "negative", unit: null },
{},
],
["a missing date", { takenAt: null }, { date: true }],
["an unparseable date", { takenAt: "not-a-day" }, { date: true }],
["a blank analyte", { analyte: " " }, { analyte: true }],
["a missing value", { value: null }, { value: true }],
["a non-finite value", { value: Number.NaN }, { value: true }],
["a blank unit", { unit: " " }, { unit: true }],
["a missing unit", { unit: null }, { unit: true }],
[
"a blank qualitative result",
{ value: null, valueText: " ", unit: null },
{ valueText: true },
],
[
"several problems at once",
{ takenAt: null, unit: "", analyte: "" },
{ date: true, unit: true, analyte: true },
],
] as const)("%s", (_name, overrides, expected) => {
expect(validateReviewRow(row(overrides))).toEqual(expected);
});
it("does not ask a qualitative row for a unit", () => {
expect(
validateReviewRow(row({ value: null, valueText: "trace", unit: null })),
).toEqual({});
});
});
describe("toCommitRow agrees with validateReviewRow", () => {
const cases: Partial<OcrReviewRow>[] = [
{},
{ takenAt: null },
{ takenAt: "nope" },
{ analyte: "" },
{ value: null },
{ unit: "" },
{ value: null, valueText: "negative", unit: null },
{ value: null, valueText: "", unit: null },
];
it.each(cases)("row %j", (overrides) => {
const r = row(overrides);
const blocked = Object.keys(validateReviewRow(r)).length > 0;
expect(toCommitRow(r) === null).toBe(blocked);
});
it("anchors the date at noon UTC", () => {
expect(toCommitRow(row())?.takenAt).toBe("2026-06-10T12:00:00.000Z");
});
});
describe("collectRowErrors", () => {
it("ignores rows that are not selected", () => {
const rows = [row({ key: "a", takenAt: null, confirmed: false })];
expect(collectRowErrors(rows).size).toBe(0);
});
it("keys the problems by row", () => {
const rows = [row({ key: "ok" }), row({ key: "bad", takenAt: null })];
expect([...collectRowErrors(rows).entries()]).toEqual([
["bad", { date: true }],
]);
});
});
describe("planSave", () => {
it("reports nothing selected only when no row is selected", () => {
expect(planSave([])).toEqual({ kind: "nothing-selected" });
expect(planSave([row({ confirmed: false })])).toEqual({
kind: "nothing-selected",
});
});
it("blocks the whole save when a selected row has no date, even if the others are fine", () => {
const plan = planSave([
row({ key: "ok" }),
row({ key: "bad", takenAt: null }),
]);
expect(plan).toEqual({ kind: "blocked" });
});
it("is not reported as 'nothing selected' when the only selected row lacks a date", () => {
expect(planSave([row({ takenAt: null })]).kind).toBe("blocked");
});
it("ignores an unselected invalid row and saves the selected ones", () => {
const plan = planSave([
row({ key: "ok" }),
row({ key: "skipped", takenAt: null, confirmed: false }),
]);
expect(plan.kind).toBe("ready");
if (plan.kind === "ready") expect(plan.payload).toHaveLength(1);
});
it("returns every selected row once all of them are complete", () => {
const plan = planSave([
row({ key: "a" }),
row({ key: "b", analyte: "HDL" }),
]);
expect(plan.kind).toBe("ready");
if (plan.kind === "ready") {
expect(plan.payload.map((r) => r.analyte)).toEqual(["LDL", "HDL"]);
}
});
});
@@ -0,0 +1,84 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import type { OcrReviewRow } from "../ocr-review-types";
import { OcrRowEditor } from "../ocr-row-editor";
const baseRow: OcrReviewRow = {
key: "LDL-0",
analyte: "LDL",
value: 2.4,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 3,
referenceText: null,
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
duplicateOf: null,
confirmed: true,
};
function render(
errors: Parameters<typeof OcrRowEditor>[0]["errors"],
locale: "en" | "de" = "en",
row: OcrReviewRow = baseRow,
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrRowEditor row={row} onChange={() => {}} errors={errors} />
</I18nProvider>,
);
}
describe("<OcrRowEditor> required-field marking", () => {
it("shows no error state before a save was attempted", () => {
const html = render(undefined);
expect(html).not.toContain('aria-invalid="true"');
expect(html).not.toContain('role="alert"');
});
it("marks the missing date, states why, and links the message to the field", () => {
const html = render({ date: true });
const invalid = html.match(/<input[^>]*aria-invalid="true"[^>]*>/g) ?? [];
expect(invalid).toHaveLength(1);
const describedBy = invalid[0]?.match(/aria-describedby="([^"]+)"/)?.[1];
expect(describedBy).toBeTruthy();
expect(html).toContain(`id="${describedBy}"`);
expect(html).toContain("Add the sample date.");
expect(html).toContain('role="alert"');
// The bordered wrapper is what the eye sees.
expect(html).toContain("border-destructive");
});
it("marks only the fields that are wrong", () => {
const html = render({ unit: true });
expect(html.match(/aria-invalid="true"/g)).toHaveLength(1);
expect(html).toContain("Add a unit.");
expect(html).not.toContain("Add the sample date.");
});
it("marks a blank qualitative result", () => {
const html = render({ valueText: true }, "en", {
...baseRow,
value: null,
valueText: "",
unit: null,
});
expect(html).toContain("Add a result.");
expect(html.match(/aria-invalid="true"/g)).toHaveLength(1);
});
it("carries a visible required marker on the fields a row needs", () => {
const html = render(undefined);
// Value, unit and date on a numeric row.
expect(html.match(/text-destructive"[^>]*> \*</g)).toHaveLength(3);
});
it("speaks German too", () => {
expect(render({ date: true }, "de")).toContain("Bitte ein Datum angeben.");
});
});
+43 -38
View File
@@ -10,9 +10,9 @@
* row editor.
*/
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -24,11 +24,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import { collectRowErrors, planSave } from "./ocr-review-validation";
import {
useOcrCommit,
useOcrExtract,
useOcrTextExtract,
type OcrCommitRowInput,
} from "./use-ocr-extract";
type Stage = "pick" | "review";
@@ -53,35 +53,6 @@ export function handleFilePickerChange(
if (file) onFilePicked(file);
}
/** Map a confirmed review row to the commit payload, or null when invalid. */
function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
const analyte = row.analyte.trim();
if (!analyte || !row.takenAt) return null;
// A calendar day → an ISO instant at noon UTC, avoiding a TZ day-shift.
const takenAt = new Date(`${row.takenAt}T12:00:00.000Z`);
if (Number.isNaN(takenAt.getTime())) return null;
const isQualitative = row.valueText !== null && row.valueText !== undefined;
if (isQualitative) {
const valueText = (row.valueText ?? "").trim();
if (!valueText) return null;
return { analyte, valueText, takenAt: takenAt.toISOString() };
}
if (row.value === null || !Number.isFinite(row.value)) return null;
const unit = (row.unit ?? "").trim();
if (!unit) return null;
return {
analyte,
value: row.value,
unit,
takenAt: takenAt.toISOString(),
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
};
}
/**
* Translate an extract failure into a friendly, error-code-aware message.
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
@@ -163,6 +134,12 @@ export function OcrReviewDialog({
* else: the panel commits with it unset exactly as before.
*/
const [visitId, setVisitId] = useState<string | null>(null);
// Errors stay hidden until a save is attempted: a row is not "wrong" just
// because the person has not filled it in yet. Once shown they follow the
// edits, so fixing a field clears its mark at once.
const [showErrors, setShowErrors] = useState(false);
const [focusRequest, setFocusRequest] = useState(0);
const reviewListRef = useRef<HTMLDivElement>(null);
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
@@ -191,8 +168,22 @@ export function OcrReviewDialog({
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
}, [rows]);
/** Blocking fields per confirmed row; rows that are writable are absent. */
const rowErrors = useMemo(() => collectRowErrors(rows), [rows]);
// Move focus to the first marked field once the marks have rendered.
useEffect(() => {
if (focusRequest === 0) return;
const first = reviewListRef.current?.querySelector<HTMLElement>(
'[aria-invalid="true"]',
);
first?.focus();
first?.scrollIntoView({ block: "center" });
}, [focusRequest]);
function reset() {
setStage("pick");
setShowErrors(false);
setRows([]);
setPickedFile(null);
setVisitId(null);
@@ -223,14 +214,17 @@ export function OcrReviewDialog({
}
function onSave() {
const payload = rows
.filter((r) => r.confirmed)
.map(toCommitRow)
.filter((r): r is OcrCommitRowInput => r !== null);
if (payload.length === 0) {
const plan = planSave(rows);
if (plan.kind === "nothing-selected") {
toast.error(t("labs.ocr.nothingToSave"));
return;
}
if (plan.kind === "blocked") {
setShowErrors(true);
setFocusRequest((n) => n + 1);
return;
}
const { payload } = plan;
commit.mutate(
{ rows: payload, file: pickedFile, encounterId: visitId },
{
@@ -354,11 +348,21 @@ export function OcrReviewDialog({
</label>
</div>
) : (
<div className="space-y-3 py-2">
<div ref={reviewListRef} className="space-y-3 py-2">
<p className="text-muted-foreground text-sm">
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
{showErrors && rowErrors.size > 0 ? (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle aria-hidden className="mt-0.5 size-4 shrink-0" />
{t("labs.ocr.validationSummary", { count: rowErrors.size })}
</p>
) : null}
{/* Anchored on the draw date the scan read, so the offer is about
the panel rather than about today. */}
<EncounterSuggestionField
@@ -372,6 +376,7 @@ export function OcrReviewDialog({
<OcrRowEditor
key={row.key}
row={row}
errors={showErrors ? rowErrors.get(row.key) : undefined}
onChange={(next) =>
setRows((prev) =>
prev.map((r) => (r.key === next.key ? next : r)),
@@ -0,0 +1,104 @@
/**
* Per-row validation for the Lab-OCR review screen.
*
* A confirmed row can only be written when it carries everything the commit
* payload needs. `validateReviewRow` reports WHICH fields are missing so the
* review screen can mark them, and `toCommitRow` is the same rule applied to
* build the payload: it returns null exactly when `validateReviewRow` reports
* at least one field.
*/
import type { OcrReviewRow } from "./ocr-review-types";
import type { OcrCommitRowInput } from "./use-ocr-extract";
export type OcrRowField = "analyte" | "value" | "valueText" | "unit" | "date";
/** The fields of one row that block saving; empty when the row is writable. */
export type OcrRowErrors = Partial<Record<OcrRowField, true>>;
/** A calendar day → an ISO instant at noon UTC, avoiding a TZ day-shift. */
function dayAtNoonUtc(day: string | null): Date | null {
if (!day) return null;
const at = new Date(`${day}T12:00:00.000Z`);
return Number.isNaN(at.getTime()) ? null : at;
}
function isQualitativeRow(row: OcrReviewRow): boolean {
return row.valueText !== null && row.valueText !== undefined;
}
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
const errors: OcrRowErrors = {};
if (!row.analyte.trim()) errors.analyte = true;
if (!dayAtNoonUtc(row.takenAt)) errors.date = true;
if (isQualitativeRow(row)) {
if (!(row.valueText ?? "").trim()) errors.valueText = true;
} else {
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
if (!(row.unit ?? "").trim()) errors.unit = true;
}
return errors;
}
export function hasRowErrors(errors: OcrRowErrors): boolean {
return Object.keys(errors).length > 0;
}
/** Map a confirmed review row to the commit payload, or null when invalid. */
export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
const analyte = row.analyte.trim();
const takenAt = dayAtNoonUtc(row.takenAt);
if (!analyte || !takenAt) return null;
if (isQualitativeRow(row)) {
const valueText = (row.valueText ?? "").trim();
if (!valueText) return null;
return { analyte, valueText, takenAt: takenAt.toISOString() };
}
if (row.value === null || !Number.isFinite(row.value)) return null;
const unit = (row.unit ?? "").trim();
if (!unit) return null;
return {
analyte,
value: row.value,
unit,
takenAt: takenAt.toISOString(),
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
};
}
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
export function collectRowErrors(
rows: readonly OcrReviewRow[],
): Map<string, OcrRowErrors> {
const byKey = new Map<string, OcrRowErrors>();
for (const row of rows) {
if (!row.confirmed) continue;
const errors = validateReviewRow(row);
if (hasRowErrors(errors)) byKey.set(row.key, errors);
}
return byKey;
}
/**
* What pressing Save does. A save is all-or-nothing over the selected rows:
* one unwritable row blocks the whole save instead of being dropped, because a
* reading that is silently left out is a reading the person believes was kept.
*/
export type OcrSavePlan =
| { kind: "nothing-selected" }
| { kind: "blocked" }
| { kind: "ready"; payload: OcrCommitRowInput[] };
export function planSave(rows: readonly OcrReviewRow[]): OcrSavePlan {
const confirmed = rows.filter((row) => row.confirmed);
if (confirmed.length === 0) return { kind: "nothing-selected" };
if (collectRowErrors(confirmed).size > 0) return { kind: "blocked" };
const payload = confirmed
.map(toCommitRow)
.filter((row): row is OcrCommitRowInput => row !== null);
return { kind: "ready", payload };
}
+42
View File
@@ -23,6 +23,7 @@ import {
TriangleAlert,
} from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
@@ -31,19 +32,43 @@ import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import type { OcrRowErrors } from "./ocr-review-validation";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
/** The visible marker that a field must be filled before the row can save. */
function RequiredMark() {
return (
<span aria-hidden className="text-destructive">
{" *"}
</span>
);
}
export function OcrRowEditor({
row,
onChange,
errors,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
/** The blocking fields of this row, once a save has been attempted. */
errors?: OcrRowErrors;
}) {
const { t } = useTranslations();
const fieldId = useId();
const invalidProps = (field: keyof OcrRowErrors) =>
errors?.[field]
? {
"aria-invalid": true as const,
"aria-describedby": `${fieldId}-${field}-error`,
}
: {};
const fieldError = (field: keyof OcrRowErrors, messageKey: string) =>
errors?.[field] ? (
<FieldError id={`${fieldId}-${field}-error`} message={t(messageKey)} />
) : null;
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
@@ -98,8 +123,10 @@ export function OcrRowEditor({
value={row.analyte}
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
aria-label={t("labs.ocr.analyteLabel")}
{...invalidProps("analyte")}
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
<Badge variant="outline" className="text-muted-foreground">
@@ -133,18 +160,22 @@ export function OcrRowEditor({
<div className="col-span-2 space-y-1 sm:col-span-3">
<Label htmlFor={`${fieldId}-vt`} className="text-xs">
{t("labs.ocr.resultLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-vt`}
value={row.valueText ?? ""}
onChange={(e) => onChange({ ...row, valueText: e.target.value })}
{...invalidProps("valueText")}
/>
{fieldError("valueText", "labs.ocr.errorResult")}
</div>
) : (
<>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-val`} className="text-xs">
{t("labs.ocr.valueLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-val`}
@@ -161,29 +192,40 @@ export function OcrRowEditor({
: null,
});
}}
{...invalidProps("value")}
/>
{fieldError("value", "labs.ocr.errorValue")}
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-unit`} className="text-xs">
{t("labs.ocr.unitLabel")}
<RequiredMark />
</Label>
<Input
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
{...invalidProps("unit")}
/>
{fieldError("unit", "labs.ocr.errorUnit")}
</div>
</>
)}
<div className="space-y-1">
<Label htmlFor={`${fieldId}-date`} className="text-xs">
{t("labs.ocr.dateLabel")}
<RequiredMark />
</Label>
<DateField
id={`${fieldId}-date`}
value={row.takenAt ?? ""}
onChange={(value) => onChange({ ...row, takenAt: value || null })}
{...invalidProps("date")}
// The bordered wrapper is the visible control; the input inside
// it only carries the aria state.
className={errors?.date ? "border-destructive" : undefined}
/>
{fieldError("date", "labs.ocr.errorDate")}
</div>
</div>