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
+ {t("labs.ocr.foundCount", { count: rows.length })} +
+ {rows.map((row) => ( +
+
+