mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
12 files changed
+457
-38
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -9013,6 +9013,12 @@
|
||||
"savedEmptyToast": "저장할 항목이 없어요",
|
||||
"noRows": "이 결과지에서 측정값을 찾지 못했어요.",
|
||||
"nothingToSave": "저장할 측정값을 하나 이상 선택해 주세요.",
|
||||
"validationSummary": "확인이 필요한 수치: {count}개. 표시된 항목을 채우거나 해당 수치의 선택을 해제하세요.",
|
||||
"errorDate": "채취 날짜를 입력하세요.",
|
||||
"errorAnalyte": "바이오마커 이름을 입력하세요.",
|
||||
"errorValue": "값을 입력하세요.",
|
||||
"errorResult": "결과를 입력하세요.",
|
||||
"errorUnit": "단위를 입력하세요.",
|
||||
"saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"providerUnsupported": "AI 제공자가 이미지를 읽지 못해요. 이미지를 읽는 모델로 설정해 주세요.",
|
||||
"consentRequired": "결과지를 스캔하려면 AI 처리 동의가 필요해요.",
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user