feat(labs): add Lab-OCR scan and review screen

Add the Scan a report entry to the Labs page, gated on the capability probe.
The review dialog uploads a photo or PDF, then shows each proposed reading for
per-row confirm, edit, or discard with duplicate, new-biomarker, and
low-confidence hints in the labs design language. Only confirmed rows commit.
Add the OCR query key and the six-locale strings.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:35:32 +02:00
1 parent 4d0856c56f
commit ebfebe1fe3
12 files changed
+864 -7

No files matched your search

+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt"
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
"ocr": {
"scanButton": "Befund scannen",
"uploadHint": "Foto oder PDF eines Laborbefunds hochladen",
"acceptWithPdf": "JPEG, PNG, WebP oder PDF",
"acceptImageOnly": "JPEG, PNG oder WebP",
"extracting": "Befund wird gelesen…",
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
"confirmRow": "Diesen Wert übernehmen",
"analyteLabel": "Bezeichnung",
"resultLabel": "Ergebnis",
"valueLabel": "Wert",
"unitLabel": "Einheit",
"dateLabel": "Datum",
"refLowLabel": "Referenz unten",
"refHighLabel": "Referenz oben",
"newBiomarker": "Neuer Biomarker — wird angelegt",
"linksExisting": "Verknüpft mit vorhandenem {name}",
"duplicateWarning": "Ein passender Wert existiert bereits",
"lowConfidence": "Bitte diesen Wert überprüfen",
"valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
"saveSelected": "{count} ausgewählte speichern",
"discardAll": "Alle verwerfen",
"savedToast": "{count} Laborwerte gespeichert",
"noRows": "In diesem Befund konnten keine Werte gefunden werden.",
"nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
"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.",
"rateLimited": "Zu viele Scans. Bitte später erneut versuchen.",
"budgetExceeded": "Dein KI-Nutzungsbudget für heute ist erreicht.",
"fileTooLarge": "Datei ist zu groß (max. 12 MB).",
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
"extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
}
},
"modules": {
"cycle": {
+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
"showingLatestOf": "Showing the latest {shown} of {total} readings"
"showingLatestOf": "Showing the latest {shown} of {total} readings",
"ocr": {
"scanButton": "Scan a report",
"uploadHint": "Upload a photo or PDF of a lab report",
"acceptWithPdf": "JPEG, PNG, WebP, or PDF",
"acceptImageOnly": "JPEG, PNG, or WebP",
"extracting": "Reading your report…",
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
"confirmRow": "Include this reading",
"analyteLabel": "Test name",
"resultLabel": "Result",
"valueLabel": "Value",
"unitLabel": "Unit",
"dateLabel": "Date",
"refLowLabel": "Reference low",
"refHighLabel": "Reference high",
"newBiomarker": "New biomarker — will be created",
"linksExisting": "Links to existing {name}",
"duplicateWarning": "A matching reading already exists",
"lowConfidence": "Please double-check this value",
"valueUnreadable": "Value unreadable — enter it manually",
"saveSelected": "Save {count} selected",
"discardAll": "Discard all",
"savedToast": "{count} lab results saved",
"noRows": "No readings could be found in this report.",
"nothingToSave": "Select at least one reading to save.",
"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.",
"rateLimited": "Too many scans. Try again later.",
"budgetExceeded": "Your AI usage budget for today is reached.",
"fileTooLarge": "File is too large (max 12 MB).",
"fileType": "Upload a JPEG, PNG, WebP, or PDF.",
"extractFailed": "Couldn't read the report. Try a clearer photo.",
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
}
},
"modules": {
"cycle": {
+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}"
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
"ocr": {
"scanButton": "Escanear un informe",
"uploadHint": "Sube una foto o un PDF de un informe de laboratorio",
"acceptWithPdf": "JPEG, PNG, WebP o PDF",
"acceptImageOnly": "JPEG, PNG o WebP",
"extracting": "Leyendo tu informe…",
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
"confirmRow": "Incluir este valor",
"analyteLabel": "Nombre de la prueba",
"resultLabel": "Resultado",
"valueLabel": "Valor",
"unitLabel": "Unidad",
"dateLabel": "Fecha",
"refLowLabel": "Referencia inferior",
"refHighLabel": "Referencia superior",
"newBiomarker": "Nuevo biomarcador — se creará",
"linksExisting": "Se vincula a {name} existente",
"duplicateWarning": "Ya existe un valor coincidente",
"lowConfidence": "Verifica este valor",
"valueUnreadable": "Valor ilegible — introdúcelo manualmente",
"saveSelected": "Guardar {count} seleccionados",
"discardAll": "Descartar todo",
"savedToast": "{count} resultados de laboratorio guardados",
"noRows": "No se encontraron valores en este informe.",
"nothingToSave": "Selecciona al menos un valor para guardar.",
"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.",
"rateLimited": "Demasiados escaneos. Inténtalo más tarde.",
"budgetExceeded": "Has alcanzado tu presupuesto de uso de IA de hoy.",
"fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
"extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
}
},
"modules": {
"cycle": {
+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}"
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
"ocr": {
"scanButton": "Scanner un compte rendu",
"uploadHint": "Importez une photo ou un PDF d'un compte rendu de laboratoire",
"acceptWithPdf": "JPEG, PNG, WebP ou PDF",
"acceptImageOnly": "JPEG, PNG ou WebP",
"extracting": "Lecture de votre compte rendu…",
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
"confirmRow": "Inclure cette valeur",
"analyteLabel": "Nom de l'analyse",
"resultLabel": "Résultat",
"valueLabel": "Valeur",
"unitLabel": "Unité",
"dateLabel": "Date",
"refLowLabel": "Référence basse",
"refHighLabel": "Référence haute",
"newBiomarker": "Nouveau biomarqueur — sera créé",
"linksExisting": "Lié au {name} existant",
"duplicateWarning": "Une valeur correspondante existe déjà",
"lowConfidence": "Veuillez vérifier cette valeur",
"valueUnreadable": "Valeur illisible — saisissez-la manuellement",
"saveSelected": "Enregistrer {count} sélectionnées",
"discardAll": "Tout abandonner",
"savedToast": "{count} résultats de laboratoire enregistrés",
"noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
"nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
"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.",
"rateLimited": "Trop de scans. Réessayez plus tard.",
"budgetExceeded": "Votre budget d'utilisation de l'IA pour aujourd'hui est atteint.",
"fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
"extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
}
},
"modules": {
"cycle": {
+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}"
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
"ocr": {
"scanButton": "Scansiona un referto",
"uploadHint": "Carica una foto o un PDF di un referto di laboratorio",
"acceptWithPdf": "JPEG, PNG, WebP o PDF",
"acceptImageOnly": "JPEG, PNG o WebP",
"extracting": "Lettura del referto…",
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
"confirmRow": "Includi questo valore",
"analyteLabel": "Nome dell'esame",
"resultLabel": "Risultato",
"valueLabel": "Valore",
"unitLabel": "Unità",
"dateLabel": "Data",
"refLowLabel": "Riferimento inferiore",
"refHighLabel": "Riferimento superiore",
"newBiomarker": "Nuovo biomarcatore — verrà creato",
"linksExisting": "Collegato a {name} esistente",
"duplicateWarning": "Esiste già un valore corrispondente",
"lowConfidence": "Controlla questo valore",
"valueUnreadable": "Valore illeggibile — inseriscilo manualmente",
"saveSelected": "Salva {count} selezionati",
"discardAll": "Scarta tutto",
"savedToast": "{count} risultati di laboratorio salvati",
"noRows": "Nessun valore trovato in questo referto.",
"nothingToSave": "Seleziona almeno un valore da salvare.",
"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.",
"rateLimited": "Troppe scansioni. Riprova più tardi.",
"budgetExceeded": "Hai raggiunto il budget di utilizzo IA di oggi.",
"fileTooLarge": "Il file è troppo grande (max 12 MB).",
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
"extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
}
},
"modules": {
"cycle": {
+38 -1
View File
@@ -6637,7 +6637,44 @@
}
},
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników"
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
"ocr": {
"scanButton": "Zeskanuj wynik",
"uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego",
"acceptWithPdf": "JPEG, PNG, WebP lub PDF",
"acceptImageOnly": "JPEG, PNG lub WebP",
"extracting": "Odczytywanie wyniku…",
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
"confirmRow": "Uwzględnij ten wynik",
"analyteLabel": "Nazwa badania",
"resultLabel": "Wynik",
"valueLabel": "Wartość",
"unitLabel": "Jednostka",
"dateLabel": "Data",
"refLowLabel": "Dolna granica",
"refHighLabel": "Górna granica",
"newBiomarker": "Nowy biomarker — zostanie utworzony",
"linksExisting": "Łączy z istniejącym {name}",
"duplicateWarning": "Pasujący wynik już istnieje",
"lowConfidence": "Sprawdź tę wartość",
"valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie",
"saveSelected": "Zapisz {count} wybranych",
"discardAll": "Odrzuć wszystko",
"savedToast": "Zapisano {count} wyników laboratoryjnych",
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
"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.",
"rateLimited": "Zbyt wiele skanów. Spróbuj ponownie później.",
"budgetExceeded": "Osiągnięto dzienny limit użycia AI.",
"fileTooLarge": "Plik jest za duży (maks. 12 MB).",
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
}
},
"modules": {
"cycle": {
+36 -1
View File
@@ -4,10 +4,12 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, Wrench } from "lucide-react";
import { Loader2, Plus, ScanLine, Wrench } from "lucide-react";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
import { useOcrCapability } from "@/components/labs/use-ocr-extract";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -27,6 +29,10 @@ export default function LabsPage() {
// Sticky-footer slot element for the add-result sheet (filled by the
// ResponsiveSheet `footer` ref so the form portals its action row there).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
// v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows
// when the user's configured AI provider can read images (capability probe).
const [scanOpen, setScanOpen] = useState(false);
const ocrCapability = useOcrCapability(isAuthenticated);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -67,6 +73,19 @@ export default function LabsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.9 — "Scan a report" Lab-OCR entry. Shown only when the
capability probe reports a vision-capable provider is configured,
so the surface stays dark for codex-only / text-only-model users. */}
{ocrCapability.data?.available ? (
<Button
variant="outline"
onClick={() => setScanOpen(true)}
className="min-h-11 sm:min-h-9"
>
<ScanLine className="h-4 w-4" />
{t("labs.ocr.scanButton")}
</Button>
) : null}
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
@@ -116,6 +135,22 @@ export default function LabsPage() {
/>
</ResponsiveSheet>
{ocrCapability.data?.available ? (
<OcrReviewDialog
open={scanOpen}
onOpenChange={setScanOpen}
pdfSupported={ocrCapability.data.pdfSupported}
onCommitted={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkers(),
});
}}
/>
) : null}
<LabList onAddFirst={() => setDialogOpen(true)} />
</div>
);
+261
View File
@@ -0,0 +1,261 @@
"use client";
/**
* v1.18.9 — the Lab-OCR "Scan a report" dialog.
*
* Flow: pick a photo / PDF → extract (vision provider) → MANDATORY human review
* (per-row confirm/edit/discard, duplicate + new-biomarker + low-confidence
* hints) → commit only the confirmed rows. Nothing writes until the user
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
* row editor.
*/
import { useMemo, useRef, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import {
useOcrCommit,
useOcrExtract,
type OcrCommitRowInput,
} from "./use-ocr-extract";
type Stage = "pick" | "review";
const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf";
/** 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 } : {}),
};
}
/** Translate an extract failure into a friendly, error-code-aware message. */
function extractErrorMessage(err: unknown, t: (key: string) => string): string {
if (err instanceof ApiError) {
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
switch (code) {
case "labs.ocr.providerUnsupported":
return t("labs.ocr.providerUnsupported");
case "labs.ocr.rateLimited":
return t("labs.ocr.rateLimited");
case "labs.ocr.budgetExceeded":
return t("labs.ocr.budgetExceeded");
case "labs.ocr.fileTooLarge":
return t("labs.ocr.fileTooLarge");
case "labs.ocr.fileType":
return t("labs.ocr.fileType");
case "labs.ocr.pdfNeedsAnthropic":
return t("labs.ocr.pdfNeedsAnthropic");
default:
break;
}
if (err.status === 403) return t("labs.ocr.consentRequired");
}
return t("labs.ocr.extractFailed");
}
export function OcrReviewDialog({
open,
onOpenChange,
pdfSupported,
onCommitted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
pdfSupported: boolean;
onCommitted: () => void;
}) {
const { t } = useTranslations();
const inputRef = useRef<HTMLInputElement | null>(null);
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
const extract = useOcrExtract();
const commit = useOcrCommit();
const confirmedCount = useMemo(
() => rows.filter((r) => r.confirmed).length,
[rows],
);
function reset() {
setStage("pick");
setRows([]);
extract.reset();
commit.reset();
}
function handleClose(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
function onFilePicked(file: File) {
extract.mutate(file, {
onSuccess: (data) => {
const seeded = seedReviewRows(data.rows, data.reportDate);
if (seeded.length === 0) {
toast.error(t("labs.ocr.noRows"));
return;
}
setRows(seeded);
setStage("review");
},
onError: (err) => toast.error(extractErrorMessage(err, t)),
});
}
function onSave() {
const payload = rows
.filter((r) => r.confirmed)
.map(toCommitRow)
.filter((r): r is OcrCommitRowInput => r !== null);
if (payload.length === 0) {
toast.error(t("labs.ocr.nothingToSave"));
return;
}
commit.mutate(payload, {
onSuccess: (result) => {
toast.success(
t("labs.ocr.savedToast", { count: result.inserted.length }),
);
onCommitted();
handleClose(false);
},
onError: () => toast.error(t("labs.ocr.saveFailed")),
});
}
const busy = extract.isPending || commit.isPending;
return (
<ResponsiveSheet
open={open}
onOpenChange={handleClose}
title={t("labs.ocr.reviewTitle")}
description={
stage === "pick" ? t("labs.ocr.uploadHint") : t("labs.ocr.reviewHint")
}
footer={
stage === "review" ? (
<div className="flex w-full justify-between gap-2">
<Button
variant="ghost"
onClick={() => handleClose(false)}
disabled={busy}
className="min-h-11 sm:min-h-9"
>
{t("labs.ocr.discardAll")}
</Button>
<Button
onClick={onSave}
disabled={busy || confirmedCount === 0}
className="min-h-11 sm:min-h-9"
>
{commit.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.ocr.saveSelected", { count: confirmedCount })}
</Button>
</div>
) : undefined
}
>
{stage === "pick" ? (
<div className="space-y-4 py-2">
<input
ref={inputRef}
type="file"
accept={pdfSupported ? ACCEPT : "image/jpeg,image/png,image/webp"}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
// Reset so re-picking the same file fires `change` again.
e.target.value = "";
if (file) onFilePicked(file);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={extract.isPending}
className="border-muted-foreground/25 hover:bg-muted/50 focus-visible:ring-ring flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-6 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
>
{extract.isPending ? (
<>
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("labs.ocr.extracting")}
</span>
</>
) : (
<>
<ScanLine
className="text-muted-foreground h-8 w-8"
aria-hidden
/>
<span className="text-sm font-medium">
{t("labs.ocr.scanButton")}
</span>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Upload className="h-3.5 w-3.5" aria-hidden />
{pdfSupported
? t("labs.ocr.acceptWithPdf")
: t("labs.ocr.acceptImageOnly")}
</span>
</>
)}
</button>
</div>
) : (
<div className="space-y-3 py-2">
<p className="text-muted-foreground text-sm">
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
{rows.map((row) => (
<OcrRowEditor
key={row.key}
row={row}
onChange={(next) =>
setRows((prev) =>
prev.map((r) => (r.key === next.key ? next : r)),
)
}
/>
))}
</div>
)}
</ResponsiveSheet>
);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* v1.18.9 — the client-side review-row shape for the Lab-OCR screen.
*
* A proposed extraction row plus the user's per-row edit + confirm state. The
* extract DTO seeds it; the review screen mutates it; only confirmed rows are
* mapped back to the commit payload.
*/
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
export interface OcrReviewRow {
/** Stable client key for the list (the analyte+index at seed time). */
key: string;
analyte: string;
value: number | null;
valueText: string | null;
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/** ISO calendar day (YYYY-MM-DD) or null. */
takenAt: string | null;
confidence: OcrExtractedRowDto["confidence"];
biomarkerMatch: "new" | "existing";
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
confirmed: boolean;
}
/** Seed the review rows from the extract response. */
export function seedReviewRows(
rows: OcrExtractedRowDto[],
fallbackDate: string | null,
): OcrReviewRow[] {
return rows.map((row, index) => ({
key: `${row.analyte}-${index}`,
analyte: row.analyte,
value: row.value,
valueText: row.valueText,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt ?? fallbackDate,
confidence: row.confidence,
biomarkerMatch: row.biomarkerMatch,
duplicateOf: row.duplicateOf,
// A flagged duplicate starts unchecked; everything else starts confirmed.
confirmed: row.duplicateOf === null,
}));
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
/**
* v1.18.9 — a single proposed Lab-OCR row on the review screen.
*
* Mandatory human review: each row is shown for per-row confirm/edit/discard.
* The checkbox confirms; the inline fields edit analyte / value-or-valueText /
* unit / reference range / date. Server-computed hints surface as calm badges:
* a new-vs-existing-biomarker hint, a duplicate warning (the row defaults to
* unchecked when flagged), and a low-confidence flag per the model's self-score.
*
* The no-alarming-colour ethos holds: an out-of-range or duplicate row is not
* painted red — these are informative `secondary`/`outline` badges, same weight
* as the in-range state.
*/
import { useId } from "react";
import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
export function OcrRowEditor({
row,
onChange,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
}) {
const { t } = useTranslations();
const fieldId = useId();
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex items-start gap-3">
<Checkbox
id={`${fieldId}-confirm`}
checked={row.confirmed}
onCheckedChange={(checked) =>
onChange({ ...row, confirmed: checked === true })
}
className="mt-1 min-h-5 min-w-5"
aria-label={t("labs.ocr.confirmRow")}
/>
<div className="min-w-0 flex-1 space-y-1">
<Input
value={row.analyte}
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
aria-label={t("labs.ocr.analyteLabel")}
className="font-medium"
/>
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
<Badge variant="outline" className="text-muted-foreground">
<Link2 aria-hidden />
{t("labs.ocr.linksExisting", { name: row.analyte })}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{isQualitative ? (
<div className="col-span-2 space-y-1 sm:col-span-3">
<Label htmlFor={`${fieldId}-vt`} className="text-xs">
{t("labs.ocr.resultLabel")}
</Label>
<Input
id={`${fieldId}-vt`}
value={row.valueText ?? ""}
onChange={(e) => onChange({ ...row, valueText: e.target.value })}
/>
</div>
) : (
<>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-val`} className="text-xs">
{t("labs.ocr.valueLabel")}
</Label>
<Input
id={`${fieldId}-val`}
inputMode="decimal"
value={row.value === null ? "" : String(row.value)}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
value:
parsed !== null && Number.isFinite(parsed)
? parsed
: null,
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-unit`} className="text-xs">
{t("labs.ocr.unitLabel")}
</Label>
<Input
id={`${fieldId}-unit`}
value={row.unit ?? ""}
onChange={(e) => onChange({ ...row, unit: e.target.value })}
/>
</div>
</>
)}
<div className="space-y-1">
<Label htmlFor={`${fieldId}-date`} className="text-xs">
{t("labs.ocr.dateLabel")}
</Label>
<Input
id={`${fieldId}-date`}
type="date"
value={row.takenAt ?? ""}
onChange={(e) =>
onChange({ ...row, takenAt: e.target.value || null })
}
/>
</div>
</div>
{!isQualitative ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor={`${fieldId}-lo`} className="text-xs">
{t("labs.ocr.refLowLabel")}
</Label>
<Input
id={`${fieldId}-lo`}
inputMode="decimal"
value={row.referenceLow === null ? "" : String(row.referenceLow)}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceLow:
parsed !== null && Number.isFinite(parsed) ? parsed : null,
});
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${fieldId}-hi`} className="text-xs">
{t("labs.ocr.refHighLabel")}
</Label>
<Input
id={`${fieldId}-hi`}
inputMode="decimal"
value={
row.referenceHigh === null ? "" : String(row.referenceHigh)
}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === "" ? null : Number(raw);
onChange({
...row,
referenceHigh:
parsed !== null && Number.isFinite(parsed) ? parsed : null,
});
}}
/>
</div>
</div>
) : null}
{valueUnreadable ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.valueUnreadable")}
</p>
) : lowValueConfidence ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
{t("labs.ocr.lowConfidence")}
</p>
) : null}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
/**
* v1.18.9 — client hooks for the Lab-OCR ingestion flow.
*
* - `useOcrCapability()` — the cheap capability probe that decides whether the
* "Scan a report" affordance shows.
* - `useOcrExtract()` — uploads the photo / PDF and returns the proposed rows.
* A vision call is slow, so it opts out of the default 15 s fetch timeout in
* favour of a generous 90 s window.
* - `useOcrCommit()` — writes the user-confirmed rows and invalidates the
* labs + biomarker query keys.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type {
OcrCapabilityDto,
OcrCommitResponseDto,
OcrExtractResponseDto,
} from "@/lib/validations/labs-ocr";
import type { LabResultDto } from "./types";
/** Per-row payload the commit endpoint accepts (the human-confirmed shape). */
export interface OcrCommitRowInput {
analyte: string;
panel?: string;
value?: number;
valueText?: string;
unit?: string;
referenceLow?: number;
referenceHigh?: number;
takenAt: string;
}
export interface OcrCommitResult extends OcrCommitResponseDto {
inserted: LabResultDto[];
}
/** Capability probe — refetched when the scan dialog opens. */
export function useOcrCapability(enabled: boolean) {
return useQuery<OcrCapabilityDto>({
queryKey: queryKeys.ocrCapability(),
queryFn: () => apiFetch<OcrCapabilityDto>("/api/labs/ocr/capability"),
enabled,
staleTime: 60_000,
});
}
/** Upload + extract. Resolves with the proposed rows for the review screen. */
export function useOcrExtract() {
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: (file: File) => {
const form = new FormData();
form.append("file", file);
return apiFetch<OcrExtractResponseDto>("/api/labs/ocr/extract", {
method: "POST",
body: form,
// Vision extraction is slow; a generous ceiling beats the 15 s default.
signal: AbortSignal.timeout(90_000),
});
},
});
}
/** Commit the confirmed rows and invalidate the dependent reads. */
export function useOcrCommit() {
const queryClient = useQueryClient();
return useMutation<OcrCommitResult, Error, OcrCommitRowInput[]>({
mutationFn: (rows: OcrCommitRowInput[]) =>
apiPost<OcrCommitResult>("/api/labs/ocr/commit", { rows }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
},
});
}
+4
View File
@@ -33,4 +33,8 @@ export const labKeys = {
// the manager; both invalidate `biomarkers()` after a mutation.
biomarkers: () => ["biomarkers"] as const,
biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const,
// v1.18.9 — Lab-OCR capability probe (drives whether the "Scan a report"
// affordance shows). Cheap GET; the dialog refetches it on open.
ocrCapability: () => ["lab-ocr", "capability"] as const,
};