"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,
Ruler,
TriangleAlert,
} from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
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";
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
/** Below this per-field confidence the field is flagged for a second look. */
const CONFIDENCE_THRESHOLD = 0.6;
/** The visible marker that a field must be filled before the row can save. */
function RequiredMark() {
return (
{" *"}
);
}
export function OcrRowEditor({
row,
onChange,
errors,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
/** The blocking fields of this row, once a save has been attempted. */
errors?: OcrRowErrors;
}) {
const { t } = useTranslations();
const fieldId = useId();
const invalidProps = (field: keyof OcrRowErrors) =>
errors?.[field]
? {
"aria-invalid": true as const,
"aria-describedby": `${fieldId}-${field}-error`,
}
: {};
const fieldError = (
field: keyof OcrRowErrors,
messageKey: string,
params?: Record,
) =>
errors?.[field] ? (
) : null;
// Stated before Save, not only after a failed attempt: a reading in another
// unit than its marker's cannot be written, and the person should see why
// the row is unticked (or will not save) while they are still reading it.
const unitDiffers = readingUnitDiffers(row);
const isQualitative = row.valueText !== null && row.valueText !== undefined;
const lowValueConfidence =
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
// The model self-scores each field it transcribed. Surface a single calm
// per-row flag when ANY tracked field came back below the threshold so a
// shaky read is visible at a glance — including on qualitative rows, which
// have no value-confidence footnote of their own. Informative, not alarming.
const lowRowConfidence =
row.confidence.analyte < CONFIDENCE_THRESHOLD ||
row.confidence.unit < CONFIDENCE_THRESHOLD ||
(isQualitative
? row.confidence.value < CONFIDENCE_THRESHOLD
: lowValueConfidence);
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
// from THIS extracted row. The range is therefore load-bearing and worth a
// second look. Surface the range confidence prominently for new numeric
// markers, and call it out explicitly when the model was unsure (or set no
// bounds at all) so the user verifies the range it will be judged against.
const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new";
const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD;
const hasBounds = row.referenceLow !== null || row.referenceHigh !== null;
const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds);
return (
// `@container` makes the row follow the width of the dialog, not the
// window: in a narrow dialog (or on a phone) it stacks as before; once the
// dialog is wide enough it splits into the reading on the left and its
// fields on the right, so a long report reads as a table, not a column.
{/* The checkbox is the PRIMARY per-row confirm on a touch-first OCR
review screen, so it needs a ≥44px hit area on coarse pointers. The
wrapping label supplies the touch target (and forwards the click to
the control) without enlarging the 20px glyph or bloating the row on
desktop, where the negative margins collapse the padding back. */}
{row.biomarkerMatch === "existing" ? (
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
{t("labs.ocr.linksExisting", { name: row.analyte })}
) : (
{t("labs.ocr.newBiomarker")}
)}
{row.duplicateOf ? (
{t("labs.ocr.duplicateWarning")}
) : null}
{unitDiffers ? (
{t("labs.ocr.unitDiffersBadge", {
markerUnit: row.markerUnit ?? "",
unit: (row.unit ?? "").trim(),
})}
) : null}
{lowRowConfidence ? (
{t("labs.ocr.lowConfidenceBadge")}
) : null}
{/* The fields are a container of their own, so how many columns they
get follows the width they actually have: the whole row when
stacked, the right-hand column once the row splits. */}
onChange({ ...row, takenAt: value || null })
}
{...invalidProps("date")}
// The bordered wrapper is the visible control; the input inside
// it only carries the aria state.
className={errors?.date ? "border-destructive" : undefined}
/>
{fieldError("date", "labs.ocr.errorDate")}
{/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below
becomes the marker's catalog range. Make that consequence visible, and
flag low range-confidence / a missing range so the user verifies it
before saving. A calm `secondary` badge, not an alarm. */}
{isNewNumericMarker ? (
) : null}
{/* The window as the report printed it. Editable, because the human is
reviewing a transcription: the two bounds above are the derived
reading of this string, and correcting the string is how a range the
parser could not read ("bis 5,0", "negativ") still reaches the row
instead of being dropped. */}
{!isQualitative ? (