Merge branch 'v143/labs' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:06:51 +02:00
commit 74fa4e10b6
96 files changed
+6992 -626

No files matched your search

@@ -0,0 +1,116 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManageDomain: () => true }),
}));
import { BiomarkerAliases } from "../biomarker-aliases";
import { BiomarkerMergeSheet } from "../biomarker-merge-sheet";
import type { BiomarkerDto } from "../types";
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
title,
footer,
}: {
children: React.ReactNode;
title: string;
footer?: React.ReactNode;
}) => (
<div>
<h2>{title}</h2>
{children}
{footer}
</div>
),
}));
function wrap(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const marker = (overrides: Partial<BiomarkerDto>): BiomarkerDto => ({
id: "bm-1",
name: "Alkaline Phosphatase",
unit: "U/L",
lowerBound: null,
upperBound: null,
panel: null,
analyteKey: null,
hasContext: false,
context: null,
hidden: false,
aliases: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
describe("<BiomarkerAliases>", () => {
it("shows the other spellings as chips, removable for a manager", () => {
const html = wrap(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P", "AP"] })}
canEdit
/>,
);
expect(html).toContain("Also written as");
expect(html).toContain("Alk.P");
expect(html).toContain('aria-label="Remove AP"');
expect(html).toContain("Another name for this biomarker");
});
it("is read-only without the manage right, and absent when there is nothing to show", () => {
const readOnly = wrap(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P"] })}
canEdit={false}
/>,
);
expect(readOnly).toContain("Alk.P");
expect(readOnly).not.toContain("Remove Alk.P");
expect(readOnly).not.toContain("<form");
expect(wrap(<BiomarkerAliases marker={marker({})} canEdit={false} />)).toBe(
"",
);
});
it("speaks German too", () => {
expect(
wrap(<BiomarkerAliases marker={marker({})} canEdit />, "de"),
).toContain("Auch geschrieben als");
});
});
describe("<BiomarkerMergeSheet>", () => {
it("opens on the pick step with every marker and no preview yet", () => {
const html = wrap(
<BiomarkerMergeSheet
open
onOpenChange={() => {}}
markers={[
marker({ id: "a", name: "Alkaline Phosphatase" }),
marker({ id: "b", name: "Alk.P" }),
]}
/>,
);
expect(html).toContain("Merge biomarkers");
expect(html).toContain('data-slot="biomarker-merge-select"');
expect(html).toContain("Alk.P");
expect(html).toContain("Select at least two biomarkers.");
// The preview button waits for a selection.
expect(html).toMatch(
/<button[^>]*data-slot="biomarker-merge-preview"[^>]*disabled=""/,
);
expect(html).not.toContain('data-slot="biomarker-merge-confirm"');
});
});
@@ -26,7 +26,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPut: vi.fn(),
@@ -0,0 +1,139 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
import {
chooseMarker,
seedReviewRows,
type OcrReviewRow,
} from "../ocr-review-types";
import { toCommitRow } from "../ocr-review-validation";
import { OcrRowEditor } from "../ocr-row-editor";
const dto: OcrExtractedRowDto = {
analyte: "Bilirubin ges.",
value: 0.8,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-01",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: null,
matchKind: null,
suggestions: [
{
biomarkerId: "bm-bili",
name: "Bilirubin (Total)",
unit: "µmol/L",
matchKind: "similar",
},
],
};
const lookup = (id: string) =>
id === "bm-bili"
? { name: "Bilirubin (Total)", analyteKey: "bilirubin-total" }
: id === "bm-alp"
? { name: "Alkaline Phosphatase", analyteKey: null }
: null;
function render(row: OcrReviewRow, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrRowEditor row={row} onChange={() => {}} markerLookup={lookup} />
</I18nProvider>,
);
}
describe("the scan review's marker choice (D1223)", () => {
it("seeds the match and the suggestions, with nothing chosen", () => {
const [row] = seedReviewRows([dto], null);
expect(row).toMatchObject({
biomarkerId: null,
matchKind: null,
chosenBiomarkerId: null,
suggestions: dto.suggestions,
});
});
it("reads a scan from before the fields existed as no match", () => {
const legacy = { ...dto } as Partial<OcrExtractedRowDto>;
delete legacy.biomarkerId;
delete legacy.matchKind;
delete legacy.suggestions;
const [row] = seedReviewRows([legacy as OcrExtractedRowDto], null);
expect(row.suggestions).toEqual([]);
expect(row.biomarkerId).toBeNull();
});
it("offers the suggestions and a new marker, new by default", () => {
const [row] = seedReviewRows([dto], null);
const html = render(row);
expect(html).toContain('data-slot="ocr-row-marker-choice"');
expect(html).toContain("Bilirubin (Total) (µmol/L)");
expect(html).toContain("A new biomarker");
expect(html).toMatch(/<option value="" selected="">/);
});
it("names the matched marker and how it was found", () => {
const [row] = seedReviewRows(
[
{
...dto,
analyte: "Alk.P",
biomarkerId: "bm-alp",
matchKind: "catalog",
suggestions: [],
biomarkerMatch: "existing",
markerUnit: "U/L",
},
],
null,
);
const html = render(row);
expect(html).toContain("Links to existing Alkaline Phosphatase");
expect(html).toContain("recognised as a common abbreviation");
expect(html).not.toContain('data-slot="ocr-row-marker-choice"');
expect(render(row, "de")).toContain("als übliche Abkürzung erkannt");
});
it("files a chosen row under the marker, judged in its unit", () => {
const [row] = seedReviewRows([dto], null);
const chosen = chooseMarker(
row,
dto.suggestions[0],
(id) => lookup(id)?.analyteKey ?? null,
);
expect(chosen).toMatchObject({
chosenBiomarkerId: "bm-bili",
biomarkerMatch: "existing",
markerUnit: "µmol/L",
markerAnalyteKey: "bilirubin-total",
});
expect(toCommitRow(chosen)).toMatchObject({ biomarkerId: "bm-bili" });
const back = chooseMarker(chosen, null, () => null);
expect(back).toMatchObject({
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
});
expect(toCommitRow(back)).not.toHaveProperty("biomarkerId");
});
it("sends no marker for an automatic match", () => {
const [row] = seedReviewRows(
[{ ...dto, biomarkerId: "bm-alp", matchKind: "alias", suggestions: [] }],
null,
);
expect(toCommitRow(row)).not.toHaveProperty("biomarkerId");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -76,17 +77,20 @@ function render(
consentRequired?: boolean;
} = {},
): string {
// The dialog reads the account's markers once rows are on screen (v1.43).
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
@@ -28,6 +28,10 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
...overrides,
};
@@ -205,6 +209,9 @@ describe("a reading in another unit than its marker's", () => {
markerUnit,
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
});
const [same, other, fresh] = seedReviewRows(
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
@@ -271,6 +278,9 @@ describe("a reading in another unit that can be converted", () => {
markerUnit: "mg/dL",
markerAnalyteKey: "fasting-glucose",
duplicateOf: null,
biomarkerId: "bm-glucose",
matchKind: "name" as const,
suggestions: [],
};
const [converts, blocked] = seedReviewRows(
[dto, { ...dto, markerAnalyteKey: null }],
@@ -21,6 +21,10 @@ const baseRow: OcrReviewRow = {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
};
+154
View File
@@ -0,0 +1,154 @@
"use client";
/**
* v1.43 (D1223) — the other spellings a marker answers to.
*
* Shown as chips on the marker; with the labs manage right they can be added
* and removed. Every change sends the whole set (`PUT /api/biomarkers/{id}`
* with `aliases`), which is how the server reads it. A spelling another marker
* already answers to is refused with `labs.biomarker.alias.taken`; the
* sentence names the spelling so the person knows which one to drop.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TagChip } from "@/components/ui/tag-chip";
import { ApiError, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto } from "./types";
/** Longest alias the server stores. */
const ALIAS_MAX = 120;
export function BiomarkerAliases({
marker,
canEdit,
}: {
marker: Pick<BiomarkerDto, "id" | "name" | "aliases">;
canEdit: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const aliases = marker.aliases ?? [];
const save = useMutation({
mutationFn: (next: string[]) =>
apiPut<BiomarkerDto>(`/api/biomarkers/${marker.id}`, { aliases: next }),
onSuccess: () => {
setError(null);
setDraft("");
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(marker.id),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
},
onError: (err) => {
const alias =
err instanceof ApiError &&
err.meta?.errorCode === "labs.biomarker.alias.taken" &&
typeof err.meta.alias === "string"
? err.meta.alias
: null;
setError(
alias
? t("labs.aliases.taken", { alias })
: t("labs.aliases.saveError"),
);
},
});
if (aliases.length === 0 && !canEdit) return null;
const add = () => {
const name = draft.trim();
if (!name) return;
if (aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
setDraft("");
return;
}
save.mutate([...aliases, name]);
};
return (
<div className="space-y-2" data-slot="biomarker-aliases">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("labs.aliases.title")}
</p>
{aliases.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{aliases.map((alias) => (
<li key={alias}>
<TagChip className="inline-flex items-center gap-1">
{alias}
{canEdit ? (
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
onClick={() =>
save.mutate(aliases.filter((a) => a !== alias))
}
disabled={save.isPending}
aria-label={t("labs.aliases.remove", { alias })}
>
<X className="size-3" aria-hidden />
</button>
) : null}
</TagChip>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.aliases.empty", { name: marker.name })}
</p>
)}
{canEdit ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
add();
}}
>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={ALIAS_MAX}
placeholder={t("labs.aliases.placeholder")}
aria-label={t("labs.aliases.addLabel")}
className="min-w-0 flex-1"
/>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={save.isPending || draft.trim() === ""}
>
{save.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Plus className="size-4" aria-hidden />
)}
{t("labs.aliases.add")}
</Button>
</form>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
+38 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
import { Eye, EyeOff, FlaskConical, Merge, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -18,7 +18,10 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { BiomarkerForm } from "./biomarker-form";
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
@@ -49,6 +52,10 @@ export function BiomarkerManager() {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
// v1.43 (D1223) — merging is a manage-level act, like edit and delete.
const { canManageDomain } = useRecordCapabilities();
const canMerge = canManageDomain("labs");
const [mergeOpen, setMergeOpen] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -125,6 +132,16 @@ export function BiomarkerManager() {
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
{marker.aliases && marker.aliases.length > 0 ? (
<p
className="text-muted-foreground truncate text-xs"
data-slot="biomarker-row-aliases"
>
{t("labs.aliases.alsoWritten", {
aliases: marker.aliases.join(", "),
})}
</p>
) : null}
</div>
<Button
size="icon"
@@ -216,6 +233,18 @@ export function BiomarkerManager() {
state's own button, so the card never offers the same action twice. */}
{!isLoading && !isError && markers.length > 0 ? (
<SettingsCardActions>
{canMerge && markers.length >= 2 ? (
<Button
size="sm"
variant="outline"
onClick={() => setMergeOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-open"
>
<Merge className="h-4 w-4" />
{t("labs.merge.open")}
</Button>
) : null}
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
@@ -223,6 +252,14 @@ export function BiomarkerManager() {
</SettingsCardActions>
) : null}
{canMerge ? (
<BiomarkerMergeSheet
open={mergeOpen}
onOpenChange={setMergeOpen}
markers={markers}
/>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
@@ -0,0 +1,284 @@
"use client";
/**
* v1.43 (D1223) — merge markers that are one analyte written two ways.
*
* Three steps in one sheet: pick the markers, pick the one that stays, read
* the preview, then confirm. The preview is the server's own dry run
* (`POST /api/biomarkers/{id}/merge` with `dryRun: true`): how many readings
* move, which are converted into the kept marker's unit, which names become
* its aliases, and any marker whose unit cannot be converted, which blocks the
* merge. A merge cannot be undone, and the sheet says so before the button.
* Nothing is computed here; every number is the server's.
*/
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { TagChip } from "@/components/ui/tag-chip";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
/** The most sources one merge takes, matching the server. */
const MAX_SOURCES = 10;
export function BiomarkerMergeSheet({
open,
onOpenChange,
markers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
markers: readonly BiomarkerDto[];
}) {
const { t, tCount } = useTranslations();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const [targetId, setTargetId] = useState<string | null>(null);
const [preview, setPreview] = useState<BiomarkerMergeResultDto | null>(null);
const target = targetId && selected.includes(targetId) ? targetId : null;
const keepId = target ?? selected[0] ?? null;
const sourceIds = useMemo(
() => selected.filter((id) => id !== keepId),
[selected, keepId],
);
const byId = useMemo(() => new Map(markers.map((m) => [m.id, m])), [markers]);
const reset = () => {
setSelected([]);
setTargetId(null);
setPreview(null);
};
const run = useMutation({
mutationFn: (dryRun: boolean) =>
apiPost<BiomarkerMergeResultDto>(`/api/biomarkers/${keepId}/merge`, {
sourceIds,
dryRun,
}),
onSuccess: (result) => {
if (result.dryRun) {
setPreview(result);
return;
}
toastWrittenOutcome(
"success",
tCount("labs.merge.doneToast", result.movedReadings, {
name: result.target.name,
}),
);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
reset();
onOpenChange(false);
},
onError: () => toast.error(t("labs.merge.error")),
});
const toggle = (id: string, checked: boolean) => {
setPreview(null);
setSelected((prev) =>
checked
? prev.length > MAX_SOURCES
? prev
: [...prev, id]
: prev.filter((x) => x !== id),
);
};
const refused = preview?.sources.filter((s) => s.conversion === "refused");
const canPreview = keepId !== null && sourceIds.length > 0;
return (
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
title={t("labs.merge.title")}
description={t("labs.merge.description")}
footer={
<div className="flex w-full flex-wrap justify-end gap-2">
{preview ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setPreview(null)}
disabled={run.isPending}
>
{t("labs.merge.back")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-confirm"
onClick={() => run.mutate(false)}
disabled={run.isPending || (refused?.length ?? 0) > 0}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.confirm")}
</Button>
</>
) : (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-preview"
onClick={() => run.mutate(true)}
disabled={!canPreview || run.isPending}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.showPreview")}
</Button>
)}
</div>
}
>
{preview ? (
<div className="space-y-4" data-slot="biomarker-merge-preview-body">
<p className="text-sm">
{tCount("labs.merge.previewSummary", preview.movedReadings, {
name: preview.target.name,
unit: preview.target.unit,
})}
</p>
<ul className="space-y-2">
{preview.sources.map((source) => (
<li
key={source.id}
className="border-border space-y-0.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{source.name}{" "}
<span className="text-muted-foreground text-xs font-normal">
{source.unit}
</span>
</p>
<p
className={
source.conversion === "refused"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{source.conversion === "refused"
? t("labs.merge.refused", {
unit: source.unit,
targetUnit: preview.target.unit,
})
: source.conversion === "converted"
? tCount("labs.merge.converted", source.readingCount, {
unit: source.unit,
targetUnit: preview.target.unit,
})
: tCount("labs.merge.moves", source.readingCount)}
</p>
</li>
))}
</ul>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
{(refused?.length ?? 0) > 0 ? (
<p role="alert" className="text-destructive text-sm">
{t("labs.merge.blocked")}
</p>
) : (
<p className="text-sm font-medium">{t("labs.merge.noUndo")}</p>
)}
</div>
) : (
<div className="space-y-4" data-slot="biomarker-merge-select">
<ul className="space-y-1">
{markers.map((marker) => {
const id = `merge-pick-${marker.id}`;
const checked = selected.includes(marker.id);
return (
<li key={marker.id}>
<label
htmlFor={id}
className="hover:bg-muted/50 flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 py-1.5"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(state) =>
toggle(marker.id, state === true)
}
disabled={!checked && selected.length > MAX_SOURCES}
/>
<span className="min-w-0 flex-1 truncate text-sm">
{marker.name}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{marker.unit}
</span>
</label>
</li>
);
})}
</ul>
{selected.length >= 2 ? (
<div className="space-y-1.5">
<Label htmlFor="merge-keep">{t("labs.merge.keepLabel")}</Label>
<NativeSelect
id="merge-keep"
value={keepId ?? ""}
onChange={(event) => setTargetId(event.target.value)}
>
{selected.map((id) => (
<option key={id} value={id}>
{byId.get(id)?.name ?? id}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("labs.merge.keepHint")}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.merge.pickHint")}
</p>
)}
</div>
)}
</ResponsiveSheet>
);
}
+26 -2
View File
@@ -34,7 +34,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { ApiError, apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
@@ -52,6 +52,7 @@ import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import type {
@@ -158,12 +159,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const {
data: marker,
isError: markerError,
error: markerFailure,
refetch: refetchMarker,
} = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
// v1.43 (D1223) — a marker merged into another answers 404 with the
// marker it went into; a bookmark or a stale screen follows it there.
const mergedInto =
markerFailure instanceof ApiError &&
markerFailure.status === 404 &&
typeof markerFailure.meta?.mergedInto === "string"
? markerFailure.meta.mergedInto
: null;
useEffect(() => {
if (!mergedInto) return;
queryClient.removeQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
router.replace(`/labs/${mergedInto}`);
}, [mergedInto, biomarkerId, queryClient, router]);
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
@@ -305,7 +323,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
// collapsing to a bare line with no context and no way back.
if (markerError || listError) {
// A merged marker is on its way to its target (see above), not failed.
if ((markerError && !mergedInto) || listError) {
return (
<div className="space-y-6">
<PageHeader
@@ -409,6 +428,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</p>
) : null}
{/* v1.43 (D1223) — the other spellings this marker answers to. */}
{marker ? (
<BiomarkerAliases marker={marker} canEdit={canManageLabs} />
) : null}
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
to lab-result documents rather than the exact scan behind one value.
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* v1.43 (D1223) — which marker a reviewed lab value goes to.
*
* Shared by the scan review and the document review. The server resolves a
* printed name to one of the account's markers (its name, an alias, or a
* catalog abbreviation such as "T.Bili") and, when nothing resolves, offers up
* to three markers to choose from. This renders that resolution: the marker a
* value was matched to, or a choice between the suggestions and a new marker.
* A choice is remembered by the server as another spelling of the marker, so
* the next report from the same lab matches on its own.
*
* Nothing is computed here: the match, its kind and the suggestions all come
* from the review DTO.
*/
import { useId } from "react";
import { FilePlus2, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
export interface MarkerSuggestionDto {
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}
/** The value for "a new marker" in the choice. */
const NEW_MARKER = "";
export function MarkerMatchField({
matchedName,
matchKind,
suggestions,
chosenId,
onChoose,
slot,
}: {
/** The marker the server matched, by name; null when nothing matched. */
matchedName: string | null;
matchKind: "name" | "alias" | "catalog" | null;
suggestions: readonly MarkerSuggestionDto[];
/** The suggestion the person picked, or null for a new marker. */
chosenId: string | null;
onChoose: (suggestion: MarkerSuggestionDto | null) => void;
/** `data-slot` prefix for tests. */
slot: string;
}) {
const { t } = useTranslations();
const selectId = useId();
if (matchedName !== null) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: matchedName })}
data-slot={`${slot}-matched`}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: matchedName })}
</span>
</Badge>
{matchKind === "alias" || matchKind === "catalog" ? (
<span className="text-muted-foreground text-xs">
{matchKind === "alias"
? t("labs.match.viaAlias")
: t("labs.match.viaCatalog")}
</span>
) : null}
</div>
);
}
if (suggestions.length === 0) {
return (
<Badge
variant="outline"
className="text-muted-foreground"
data-slot={`${slot}-new`}
>
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
);
}
return (
<div className="space-y-1.5" data-slot={`${slot}-choice`}>
<Label htmlFor={selectId} className="text-xs">
{t("labs.match.chooseLabel")}
</Label>
<NativeSelect
id={selectId}
value={chosenId ?? NEW_MARKER}
onChange={(event) =>
onChoose(
suggestions.find((s) => s.biomarkerId === event.target.value) ??
null,
)
}
>
{suggestions.map((suggestion) => (
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
{t("labs.match.suggestionOption", {
name: suggestion.name,
unit: suggestion.unit,
})}
</option>
))}
<option value={NEW_MARKER}>{t("labs.match.newMarkerOption")}</option>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{chosenId ? t("labs.match.rememberHint") : t("labs.match.chooseHint")}
</p>
</div>
);
}
+24 -1
View File
@@ -18,12 +18,16 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { useQuery } from "@tanstack/react-query";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
import type { BiomarkerListResponse } from "./types";
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import { collectRowErrors, planSave } from "./ocr-review-validation";
import {
@@ -176,6 +180,24 @@ export function OcrReviewDialog({
const textExtract = useOcrTextExtract();
const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
// v1.43 — the account's markers, so a matched row names its marker and a
// chosen suggestion brings the analyte it converts with. Only once rows are
// on screen; the review works without it (names fall back to the printed
// analyte).
const markers = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
enabled: rows.length > 0,
});
const markerLookup = useMemo(() => {
const byId = new Map(
(markers.data?.biomarkers ?? []).map((m) => [
m.id,
{ name: m.name, analyteKey: m.analyteKey },
]),
);
return (id: string) => byId.get(id) ?? null;
}, [markers.data]);
// Text mode never accepts PDFs (tesseract.js can't read them).
const allowPdf = mode === "vision" && pdfSupported;
@@ -439,6 +461,7 @@ export function OcrReviewDialog({
<OcrRowEditor
key={row.key}
row={row}
markerLookup={markerLookup}
errors={showErrors ? rowErrors.get(row.key) : undefined}
onChange={(next) =>
setRows((prev) =>
+43
View File
@@ -34,6 +34,16 @@ export interface OcrReviewRow {
markerAnalyteKey: string | null;
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the server matched the name to (its name, an
* alias, or a catalog abbreviation), and how; null when nothing matched.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched. */
suggestions: OcrExtractedRowDto["suggestions"];
/** The suggestion the person picked; null keeps the automatic outcome. */
chosenBiomarkerId: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
confirmed: boolean;
}
@@ -58,6 +68,11 @@ export function seedReviewRows(
markerUnit: row.markerUnit,
markerAnalyteKey: row.markerAnalyteKey,
duplicateOf: row.duplicateOf,
// A scan read before v1.43 carries none of the three.
biomarkerId: row.biomarkerId ?? null,
matchKind: row.matchKind ?? null,
suggestions: row.suggestions ?? [],
chosenBiomarkerId: null,
// A flagged duplicate starts unchecked, and so does a reading in another
// unit than its marker's that cannot be converted (it carries a badge
// saying why, and ticking it marks the unit field until it is fixed); a
@@ -74,3 +89,31 @@ export function seedReviewRows(
}),
}));
}
/**
* Point a row at a suggestion the person picked, or back at a new marker. The
* marker's unit and analyte come with the choice, so the unit check and the
* conversion badge judge the row against the marker it will be filed under.
*/
export function chooseMarker(
row: OcrReviewRow,
choice: { biomarkerId: string; unit: string } | null,
analyteKeyOf: (biomarkerId: string) => string | null,
): OcrReviewRow {
if (choice === null) {
return {
...row,
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
};
}
return {
...row,
chosenBiomarkerId: choice.biomarkerId,
biomarkerMatch: "existing",
markerUnit: choice.unit,
markerAnalyteKey: analyteKeyOf(choice.biomarkerId),
};
}
+12 -1
View File
@@ -129,7 +129,12 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (isQualitativeRow(row)) {
const valueText = (row.valueText ?? "").trim();
if (!valueText) return null;
return { analyte, valueText, takenAt: takenAt.toISOString() };
return {
analyte,
valueText,
takenAt: takenAt.toISOString(),
...chosen(row),
};
}
if (row.value === null || !Number.isFinite(row.value)) return null;
@@ -144,9 +149,15 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
...chosen(row),
};
}
/** The marker the person picked, which the commit then files the row under. */
function chosen(row: OcrReviewRow): { biomarkerId?: string } {
return row.chosenBiomarkerId ? { biomarkerId: row.chosenBiomarkerId } : {};
}
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
export function collectRowErrors(
rows: readonly OcrReviewRow[],
+34 -27
View File
@@ -15,13 +15,7 @@
*/
import { useId } from "react";
import {
AlertCircle,
FilePlus2,
Link2,
Ruler,
TriangleAlert,
} from "lucide-react";
import { AlertCircle, Ruler, TriangleAlert } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +25,8 @@ 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 { MarkerMatchField } from "./marker-match-field";
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
import {
readingConversion,
readingUnitBlocked,
@@ -55,9 +50,18 @@ export function OcrRowEditor({
row,
onChange,
errors,
markerLookup = () => null,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
/**
* The account's marker by id, for the matched marker's name and the
* analyte a chosen suggestion converts with. Absent: names fall back to the
* printed analyte.
*/
markerLookup?: (
biomarkerId: string,
) => { name: string; analyteKey: string | null } | null;
/** The blocking fields of this row, once a save has been attempted. */
errors?: OcrRowErrors;
}) {
@@ -151,26 +155,29 @@ export function OcrRowEditor({
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
{/* v1.43 (D1223) — the marker this value goes to: the server's
match, or a choice between its suggestions and a new one. */}
<MarkerMatchField
matchedName={
row.biomarkerId !== null
? (markerLookup(row.biomarkerId)?.name ?? row.analyte)
: null
}
matchKind={row.matchKind}
suggestions={row.suggestions}
chosenId={row.chosenBiomarkerId}
onChoose={(suggestion) =>
onChange(
chooseMarker(
row,
suggestion,
(id) => markerLookup(id)?.analyteKey ?? null,
),
)
}
slot="ocr-row-marker"
/>
<div className="flex flex-wrap items-center gap-1.5">
{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.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
+23
View File
@@ -95,6 +95,11 @@ export interface BiomarkerDto {
analyteKey: string | null;
hasContext: boolean;
context: string | null;
/**
* v1.43 — other spellings that resolve to this marker, sorted, without its
* own name. Absent from an older server.
*/
aliases?: string[];
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
hidden: boolean;
createdAt: string;
@@ -104,3 +109,21 @@ export interface BiomarkerDto {
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
export interface BiomarkerMergeResultDto {
dryRun: boolean;
target: BiomarkerDto;
sources: Array<{
id: string;
name: string;
unit: string;
readingCount: number;
conversion: "none" | "converted" | "refused";
refusal: "unknown_analyte" | "unknown_unit" | "not_finite" | null;
}>;
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
mergeId: string | null;
}
+5
View File
@@ -51,6 +51,11 @@ export interface OcrCommitRowInput {
referenceHigh?: number;
referenceText?: string;
takenAt: string;
/**
* v1.43 — the marker the person chose on the review screen; the server then
* remembers the analyte as its alias. Absent for an automatic match.
*/
biomarkerId?: string;
}
export interface OcrCommitResult extends OcrCommitResponseDto {