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

+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,
};