From ebfebe1fe30eb0ce041005ff5d5441dc23a130cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 12:35:32 +0200
Subject: [PATCH] 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.
---
messages/de.json | 39 +++-
messages/en.json | 39 +++-
messages/es.json | 39 +++-
messages/fr.json | 39 +++-
messages/it.json | 39 +++-
messages/pl.json | 39 +++-
src/app/labs/page.tsx | 37 ++-
src/components/labs/ocr-review-dialog.tsx | 261 ++++++++++++++++++++++
src/components/labs/ocr-review-types.ts | 49 ++++
src/components/labs/ocr-row-editor.tsx | 207 +++++++++++++++++
src/components/labs/use-ocr-extract.ts | 79 +++++++
src/lib/query-keys/labs.ts | 4 +
12 files changed, 864 insertions(+), 7 deletions(-)
create mode 100644 src/components/labs/ocr-review-dialog.tsx
create mode 100644 src/components/labs/ocr-review-types.ts
create mode 100644 src/components/labs/ocr-row-editor.tsx
create mode 100644 src/components/labs/use-ocr-extract.ts
diff --git a/messages/de.json b/messages/de.json
index b00f8c9fb..40d7ddbd8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index c8691a6b4..54241ad76 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index 1d3941800..4b8561ed3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index f68ca0281..71ec8f395 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index b3eff405c..712b5c259 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 81cdac560..832a4d5d2 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 6abc781fb..5dd27f3ac 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -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(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() {
+ {/* 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 ? (
+
+ ) : 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() {
/>
+ {ocrCapability.data?.available ? (
+ {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.labResults(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.biomarkers(),
+ });
+ }}
+ />
+ ) : null}
+
setDialogOpen(true)} />
);
diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx
new file mode 100644
index 000000000..3edac70b7
--- /dev/null
+++ b/src/components/labs/ocr-review-dialog.tsx
@@ -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(null);
+ const [stage, setStage] = useState("pick");
+ const [rows, setRows] = useState([]);
+
+ 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 (
+
+
+
+
+ ) : undefined
+ }
+ >
+ {stage === "pick" ? (
+
+ {
+ const file = e.target.files?.[0];
+ // Reset so re-picking the same file fires `change` again.
+ e.target.value = "";
+ if (file) onFilePicked(file);
+ }}
+ />
+
+