mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
4d0856c56f
commit
ebfebe1fe3
12 files changed
+864
-7
No files matched your search
+38
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user