mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill
# Conflicts: # src/app/api/labs/route.ts
This commit is contained in:
commit
cc32e2adf7
40 files changed
+4403
-360
No files matched your search
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
BIOMARKER_CATALOG,
|
||||
BIOMARKER_PANELS,
|
||||
} from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { BiomarkerDto } from "./types";
|
||||
|
||||
const CONTEXT_MAX_LENGTH = 2000;
|
||||
|
||||
/** Parse a free-text decimal that may use a comma separator. */
|
||||
function parseDecimal(raw: string): number | null {
|
||||
const trimmed = raw.trim().replace(",", ".");
|
||||
if (trimmed === "") return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
interface BiomarkerFormProps {
|
||||
/** When set the form edits this marker; otherwise it defines a new one. */
|
||||
existing?: BiomarkerDto;
|
||||
onSuccess?: (saved: BiomarkerDto) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — define or edit a Biomarker catalog entry.
|
||||
*
|
||||
* A marker is the Labs feature's reusable definition: name, unit, reference
|
||||
* bounds (set ONCE, never re-entered per reading), and an optional context
|
||||
* note. When defining a new marker the user can seed the fields from the
|
||||
* common-panel catalog (a `<Select>` grouped by panel) or fill them by hand.
|
||||
* Either bound is optional (a marker may report only an upper bound, e.g.
|
||||
* LDL < 116).
|
||||
*/
|
||||
export function BiomarkerForm({
|
||||
existing,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: BiomarkerFormProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [name, setName] = useState(existing?.name ?? "");
|
||||
const [unit, setUnit] = useState(existing?.unit ?? "");
|
||||
const [lower, setLower] = useState(
|
||||
existing?.lowerBound != null ? String(existing.lowerBound) : "",
|
||||
);
|
||||
const [upper, setUpper] = useState(
|
||||
existing?.upperBound != null ? String(existing.upperBound) : "",
|
||||
);
|
||||
const [panel, setPanel] = useState(existing?.panel ?? "");
|
||||
const [context, setContext] = useState(existing?.context ?? "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function applySeed(slug: string) {
|
||||
const seed = BIOMARKER_CATALOG.find((s) => s.slug === slug);
|
||||
if (!seed) return;
|
||||
setName(t(`labs.catalog.${seed.slug}`));
|
||||
setUnit(seed.unit);
|
||||
setLower(seed.lowerBound != null ? String(seed.lowerBound) : "");
|
||||
setUpper(seed.upperBound != null ? String(seed.upperBound) : "");
|
||||
setPanel(t(`labs.catalog.panel.${seed.panel}`));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!name.trim() || !unit.trim()) {
|
||||
setError(t("labs.biomarker.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const low = parseDecimal(lower);
|
||||
const high = parseDecimal(upper);
|
||||
if (low !== null && high !== null && low > high) {
|
||||
setError(t("labs.biomarker.form.rangeOrderError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
unit: unit.trim(),
|
||||
...(low !== null ? { lowerBound: low } : {}),
|
||||
...(high !== null ? { upperBound: high } : {}),
|
||||
...(panel.trim() ? { panel: panel.trim() } : {}),
|
||||
...(context.trim() ? { context: context.trim() } : {}),
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<BiomarkerDto>(`/api/biomarkers/${existing.id}`, body)
|
||||
: await apiPost<BiomarkerDto>("/api/biomarkers", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("labs.biomarker.form.updatedToast")
|
||||
: t("labs.biomarker.form.savedToast"),
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : t("labs.biomarker.form.saveError");
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{!existing ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-seed">
|
||||
{t("labs.biomarker.form.seedLabel")}
|
||||
</Label>
|
||||
<Select onValueChange={applySeed}>
|
||||
<SelectTrigger id="biomarker-seed" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("labs.biomarker.form.seedPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIOMARKER_PANELS.map((panelKey) => {
|
||||
const items = BIOMARKER_CATALOG.filter(
|
||||
(s) => s.panel === panelKey,
|
||||
);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<SelectGroup key={panelKey}>
|
||||
<SelectLabel>
|
||||
{t(`labs.catalog.panel.${panelKey}`)}
|
||||
</SelectLabel>
|
||||
{items.map((s) => (
|
||||
<SelectItem key={s.slug} value={s.slug}>
|
||||
{t(`labs.catalog.${s.slug}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.biomarker.form.seedHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="biomarker-name">
|
||||
{t("labs.biomarker.form.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.namePlaceholder")}
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-unit">
|
||||
{t("labs.biomarker.form.unit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-panel">
|
||||
{t("labs.biomarker.form.panel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-panel"
|
||||
value={panel}
|
||||
onChange={(e) => setPanel(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.panelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-lower">
|
||||
{t("labs.biomarker.form.lowerBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-lower"
|
||||
inputMode="decimal"
|
||||
value={lower}
|
||||
onChange={(e) => setLower(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.lowerPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-upper">
|
||||
{t("labs.biomarker.form.upperBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-upper"
|
||||
inputMode="decimal"
|
||||
value={upper}
|
||||
onChange={(e) => setUpper(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.upperPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="biomarker-context">
|
||||
{t("labs.biomarker.form.context")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="biomarker-context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={t("labs.biomarker.form.contextPlaceholder")}
|
||||
maxLength={CONTEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.biomarker.form.rangeHint")}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("labs.biomarker.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager.
|
||||
*
|
||||
* Lives on the Labs page (not Settings) because the catalog IS the Labs
|
||||
* feature's primary object. Lists every defined marker with its unit +
|
||||
* reference range, opens a define-new / edit sheet, and wires delete (the
|
||||
* `onDelete: SetNull` FK keeps existing readings, just unlinks them).
|
||||
*/
|
||||
export function BiomarkerManager() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("labs.biomarker.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(marker: BiomarkerDto) {
|
||||
setEditing(marker);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function afterSave() {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
}
|
||||
|
||||
const markers = data?.biomarkers ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("labs.biomarker.managerDescription")}
|
||||
</p>
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.biomarker.define")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive py-6 text-center text-sm">
|
||||
{t("labs.biomarker.loadError")}
|
||||
</p>
|
||||
) : markers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FlaskConical className="size-6" />}
|
||||
title={t("labs.biomarker.emptyTitle")}
|
||||
description={t("labs.biomarker.emptyDescription")}
|
||||
action={
|
||||
<Button onClick={openNew}>{t("labs.biomarker.defineFirst")}</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{markers.map((marker) => {
|
||||
const range = formatReferenceRange(
|
||||
marker.lowerBound,
|
||||
marker.upperBound,
|
||||
formatLabValue,
|
||||
);
|
||||
return (
|
||||
<li key={marker.id}>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{marker.unit}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9"
|
||||
onClick={() => openEdit(marker)}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(marker.id)}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"labs.biomarker.deleteConfirmDescription",
|
||||
)}
|
||||
confirmLabel={t("labs.biomarker.delete")}
|
||||
className="size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
title={
|
||||
editing
|
||||
? t("labs.biomarker.editTitle")
|
||||
: t("labs.biomarker.defineTitle")
|
||||
}
|
||||
description={t("labs.biomarker.defineDescription")}
|
||||
>
|
||||
<BiomarkerForm
|
||||
existing={editing ?? undefined}
|
||||
onSuccess={afterSave}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import type { LabResultDto } from "./types";
|
||||
|
||||
/**
|
||||
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
|
||||
*
|
||||
* Replaces the hand-rolled 72×20 SVG sparkline with a proper Recharts
|
||||
* `ComposedChart`: a single line over time, the catalog reference window
|
||||
* painted as a shaded `ReferenceArea` band with dashed bound lines, range
|
||||
* tabs (30 / 90 / 365 / all), and the shared rich tooltip. The band uses a
|
||||
* MUTED primary fill — never a danger colour — honouring the no-alarming-
|
||||
* colour rule. Every reading carries the same y-axis unit because a chart is
|
||||
* one biomarker only.
|
||||
*/
|
||||
|
||||
const RANGE_DAYS = [
|
||||
{ key: "30", days: 30 },
|
||||
{ key: "90", days: 90 },
|
||||
{ key: "365", days: 365 },
|
||||
{ key: "all", days: 0 },
|
||||
] as const;
|
||||
|
||||
type RangeKey = (typeof RANGE_DAYS)[number]["key"];
|
||||
|
||||
interface ChartPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function LabBiomarkerChart({
|
||||
readings,
|
||||
unit,
|
||||
lowerBound,
|
||||
upperBound,
|
||||
}: {
|
||||
/** Readings for this biomarker, any order (sorted internally). */
|
||||
readings: LabResultDto[];
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [range, setRange] = useState<RangeKey>("365");
|
||||
// Capture "now" once at mount so the recency filter stays pure across
|
||||
// re-renders (an inline `Date.now()` in render is impure).
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
const points = useMemo<ChartPoint[]>(() => {
|
||||
const sorted = [...readings].sort(
|
||||
(a, b) =>
|
||||
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0;
|
||||
const filtered =
|
||||
days > 0
|
||||
? sorted.filter((r) => {
|
||||
const ageMs = nowMs - new Date(r.takenAt).getTime();
|
||||
return ageMs <= days * 24 * 60 * 60 * 1000;
|
||||
})
|
||||
: sorted;
|
||||
return filtered.map((r) => ({
|
||||
timestamp: new Date(r.takenAt).getTime(),
|
||||
value: r.value,
|
||||
}));
|
||||
}, [readings, range, nowMs]);
|
||||
|
||||
// Y domain wraps both the data and the reference window so the band is
|
||||
// always visible even when every reading sits inside it.
|
||||
const yDomain = useMemo<[number, number]>(() => {
|
||||
const values = points.map((p) => p.value);
|
||||
const candidates = [...values];
|
||||
if (lowerBound != null) candidates.push(lowerBound);
|
||||
if (upperBound != null) candidates.push(upperBound);
|
||||
if (candidates.length === 0) return [0, 1];
|
||||
const min = Math.min(...candidates);
|
||||
const max = Math.max(...candidates);
|
||||
const pad = (max - min || Math.abs(max) || 1) * 0.1;
|
||||
return [min - pad, max + pad];
|
||||
}, [points, lowerBound, upperBound]);
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
{RANGE_DAYS.map((r) => (
|
||||
<Button
|
||||
key={r.key}
|
||||
size="sm"
|
||||
variant={range === r.key ? "secondary" : "ghost"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setRange(r.key)}
|
||||
>
|
||||
{t(`labs.chart.range.${r.key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{points.length === 0 ? (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("labs.chart.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ height: CHART_HEIGHT_PX }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={44}
|
||||
tickFormatter={(v: number) => formatLabValue(v)}
|
||||
/>
|
||||
{/* Reference window — muted band + dashed bounds. No alarm
|
||||
colour: the in/out verdict is shown by the neutral badge,
|
||||
not by painting the chart red. */}
|
||||
{lowerBound != null && upperBound != null ? (
|
||||
<ReferenceArea
|
||||
y1={lowerBound}
|
||||
y2={upperBound}
|
||||
fill={primary}
|
||||
fillOpacity={0.1}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
{lowerBound != null ? (
|
||||
<ReferenceLine
|
||||
y={lowerBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
{upperBound != null ? (
|
||||
<ReferenceLine
|
||||
y={upperBound}
|
||||
stroke={primary}
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: ChartPoint }>
|
||||
| undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("labs.chart.valueLabel"),
|
||||
value: `${formatLabValue(point.value)} ${unit}`,
|
||||
color: primary,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDateShort(new Date(point.timestamp))}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { LabBiomarkerChart } from "./lab-biomarker-chart";
|
||||
import { LabForm } from "./lab-form";
|
||||
import { LabHistoryList } from "./lab-history-list";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
|
||||
* dashboard-style chart with the reference band, and the editable reading
|
||||
* history. "Add value" pre-selects this biomarker so the user never re-picks
|
||||
* it. Mirrors the measurement-detail layout.
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const { data: marker, isError: markerError } = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
});
|
||||
|
||||
const {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.labResultsList({
|
||||
biomarkerId,
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
queryFn: () =>
|
||||
apiGet<LabResultListResponse>(
|
||||
`/api/labs?biomarkerId=${encodeURIComponent(biomarkerId)}&limit=500&sortDir=desc`,
|
||||
),
|
||||
});
|
||||
|
||||
const readings: LabResultDto[] = list?.results ?? [];
|
||||
const latest =
|
||||
readings.length > 0
|
||||
? [...readings].sort(
|
||||
(a, b) =>
|
||||
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
)[0]
|
||||
: null;
|
||||
|
||||
function afterAdd() {
|
||||
setAddOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
}
|
||||
|
||||
if (markerError || listError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const latestStatus = latest
|
||||
? classifyReferenceRange(
|
||||
latest.value,
|
||||
marker?.lowerBound ?? null,
|
||||
marker?.upperBound ?? null,
|
||||
)
|
||||
: "unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{marker?.name ?? <Skeleton className="h-7 w-32" />}
|
||||
</h1>
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{marker.unit}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button onClick={() => setAddOpen(true)} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{formatLabValue(latest.value)}{" "}
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
{marker?.unit ?? latest.unit}
|
||||
</span>
|
||||
</span>
|
||||
<ReferenceRangeBadge status={latestStatus} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{marker?.context ? (
|
||||
<p className="text-muted-foreground text-sm">{marker.context}</p>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-60 w-full" />
|
||||
) : readings.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FlaskConical className="size-6" />}
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
description={t("labs.detail.emptyDescription")}
|
||||
action={
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
{t("labs.addResult")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{readings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<LabHistoryList readings={readings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title={t("labs.addResult")}
|
||||
description={t("labs.addDescription")}
|
||||
>
|
||||
<LabForm
|
||||
lockedBiomarkerId={biomarkerId}
|
||||
onSuccess={afterAdd}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+171
-124
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -8,13 +9,30 @@ import { Button } from "@/components/ui/button";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
BiomarkerListResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
const DEFINE_NEW = "__define_new__";
|
||||
|
||||
function defaultTakenAtValue() {
|
||||
const now = new Date();
|
||||
@@ -32,59 +50,83 @@ function parseDecimal(raw: string): number | null {
|
||||
}
|
||||
|
||||
interface LabFormProps {
|
||||
/** When set, the biomarker is pre-selected and locked (add-from-detail). */
|
||||
lockedBiomarkerId?: string;
|
||||
onSuccess?: (created: LabResultDto) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 — manual lab-result entry.
|
||||
* v1.18.1 — structured lab-result entry.
|
||||
*
|
||||
* Progressive disclosure: the four essentials (analyte, value, unit, date)
|
||||
* are always visible; the optional reference range, panel grouping, and
|
||||
* note sit below under a clearly-labelled "optional" heading so a quick
|
||||
* single-value entry stays a four-field affair.
|
||||
* The error-prone free-text path is gone: the user PICKS a biomarker from the
|
||||
* catalog, then enters only the value against its known unit + reference range
|
||||
* (resolved server-side). A "+ define new" row opens the marker-definition
|
||||
* sheet inline and returns with it selected. Panel / unit / range are NOT
|
||||
* re-entered per reading — they live on the biomarker. Only the per-reading
|
||||
* note stays optional here.
|
||||
*/
|
||||
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
|
||||
export function LabForm({
|
||||
lockedBiomarkerId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: LabFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [analyte, setAnalyte] = useState("");
|
||||
const { data: catalog, isLoading: catalogLoading } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
|
||||
const [biomarkerId, setBiomarkerId] = useState<string>(
|
||||
lockedBiomarkerId ?? "",
|
||||
);
|
||||
const [value, setValue] = useState("");
|
||||
const [unit, setUnit] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
const [panel, setPanel] = useState("");
|
||||
const [refLow, setRefLow] = useState("");
|
||||
const [refHigh, setRefHigh] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [defineOpen, setDefineOpen] = useState(false);
|
||||
|
||||
const markers = catalog?.biomarkers ?? [];
|
||||
const selected = markers.find((m) => m.id === biomarkerId);
|
||||
|
||||
function handleSelect(next: string) {
|
||||
if (next === DEFINE_NEW) {
|
||||
setDefineOpen(true);
|
||||
return;
|
||||
}
|
||||
setBiomarkerId(next);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function afterDefine(created: BiomarkerDto) {
|
||||
setDefineOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
setBiomarkerId(created.id);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const numericValue = parseDecimal(value);
|
||||
if (!analyte.trim() || numericValue === null || !unit.trim()) {
|
||||
setError(t("labs.form.requiredError"));
|
||||
if (!biomarkerId) {
|
||||
setError(t("labs.form.pickBiomarkerError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const low = parseDecimal(refLow);
|
||||
const high = parseDecimal(refHigh);
|
||||
if (low !== null && high !== null && low > high) {
|
||||
setError(t("labs.form.rangeOrderError"));
|
||||
if (numericValue === null) {
|
||||
setError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
analyte: analyte.trim(),
|
||||
biomarkerId,
|
||||
value: numericValue,
|
||||
unit: unit.trim(),
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(panel.trim() ? { panel: panel.trim() } : {}),
|
||||
...(low !== null ? { referenceLow: low } : {}),
|
||||
...(high !== null ? { referenceHigh: high } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
toast.success(t("labs.form.savedToast"));
|
||||
@@ -98,94 +140,87 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="lab-analyte">{t("labs.form.analyte")}</Label>
|
||||
<Input
|
||||
id="lab-analyte"
|
||||
value={analyte}
|
||||
onChange={(e) => setAnalyte(e.target.value)}
|
||||
placeholder={t("labs.form.analytePlaceholder")}
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-value">{t("labs.form.value")}</Label>
|
||||
<Input
|
||||
id="lab-value"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-unit">{t("labs.form.unit")}</Label>
|
||||
<Input
|
||||
id="lab-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder={t("labs.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
|
||||
<DateTimeInput
|
||||
id="lab-takenAt"
|
||||
value={takenAt}
|
||||
onChange={(e) => setTakenAt(e.target.value)}
|
||||
max={defaultTakenAtValue()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const referenceText = selected
|
||||
? formatReferenceRange(
|
||||
selected.lowerBound,
|
||||
selected.upperBound,
|
||||
formatLabValue,
|
||||
)
|
||||
: "";
|
||||
|
||||
<fieldset className="space-y-4 rounded-lg border border-dashed p-4">
|
||||
<legend className="text-muted-foreground px-1 text-xs font-medium">
|
||||
{t("labs.form.optionalGroup")}
|
||||
</legend>
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-panel">{t("labs.form.panel")}</Label>
|
||||
<Input
|
||||
id="lab-panel"
|
||||
value={panel}
|
||||
onChange={(e) => setPanel(e.target.value)}
|
||||
placeholder={t("labs.form.panelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
|
||||
<Select
|
||||
value={biomarkerId || undefined}
|
||||
onValueChange={handleSelect}
|
||||
disabled={!!lockedBiomarkerId || catalogLoading}
|
||||
>
|
||||
<SelectTrigger id="lab-biomarker" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("labs.form.biomarkerPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{markers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} · {m.unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!lockedBiomarkerId ? (
|
||||
<SelectItem value={DEFINE_NEW}>
|
||||
{t("labs.form.defineNew")}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{markers.length === 0 && !catalogLoading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.form.noBiomarkersHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-refLow">{t("labs.form.referenceLow")}</Label>
|
||||
<Label htmlFor="lab-value">
|
||||
{t("labs.form.value")}
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<Input
|
||||
id="lab-refLow"
|
||||
id="lab-value"
|
||||
inputMode="decimal"
|
||||
value={refLow}
|
||||
onChange={(e) => setRefLow(e.target.value)}
|
||||
placeholder={t("labs.form.referenceLowPlaceholder")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-refHigh">{t("labs.form.referenceHigh")}</Label>
|
||||
<Input
|
||||
id="lab-refHigh"
|
||||
inputMode="decimal"
|
||||
value={refHigh}
|
||||
onChange={(e) => setRefHigh(e.target.value)}
|
||||
placeholder={t("labs.form.referenceHighPlaceholder")}
|
||||
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
|
||||
<DateTimeInput
|
||||
id="lab-takenAt"
|
||||
value={takenAt}
|
||||
onChange={(e) => setTakenAt(e.target.value)}
|
||||
max={defaultTakenAtValue()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.form.referenceHint")}
|
||||
</p>
|
||||
|
||||
{selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
|
||||
<Textarea
|
||||
@@ -197,32 +232,44 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{t("labs.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("labs.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={defineOpen}
|
||||
onOpenChange={setDefineOpen}
|
||||
title={t("labs.biomarker.defineTitle")}
|
||||
description={t("labs.biomarker.defineDescription")}
|
||||
>
|
||||
<BiomarkerForm
|
||||
onSuccess={afterDefine}
|
||||
onCancel={() => setDefineOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type { LabResultDetailDto, LabResultDto } from "./types";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
function toDateTimeLocal(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const offset = d.getTimezoneOffset();
|
||||
const local = new Date(d.getTime() - offset * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number | null {
|
||||
const trimmed = raw.trim().replace(",", ".");
|
||||
if (trimmed === "") return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — reverse-chronological reading history for one biomarker, with
|
||||
* full edit / amend / delete (L5). Edit opens a sheet that loads the
|
||||
* decrypted note from the single-resource GET; delete soft-deletes with an
|
||||
* Undo toast that restores via `/api/labs/restore` (the measurement pattern).
|
||||
*/
|
||||
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editTakenAt, setEditTakenAt] = useState("");
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
try {
|
||||
await apiPost("/api/labs/restore", { ids: [id] });
|
||||
invalidate();
|
||||
toast.success(t("labs.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("labs.restoreError"));
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
invalidate();
|
||||
toast.success(t("labs.deletedToast"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => void restore(id),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("labs.deleteError")),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
id: string;
|
||||
value: number;
|
||||
takenAt: string;
|
||||
note: string | null;
|
||||
}) =>
|
||||
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
|
||||
value: body.value,
|
||||
takenAt: body.takenAt,
|
||||
note: body.note,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t("labs.editedToast"));
|
||||
closeEdit();
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(
|
||||
err instanceof ApiError ? err.message : t("labs.editError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function openEdit(reading: LabResultDto) {
|
||||
setEditingId(reading.id);
|
||||
setEditValue(String(reading.value));
|
||||
setEditTakenAt(toDateTimeLocal(reading.takenAt));
|
||||
setEditNote("");
|
||||
setEditError(null);
|
||||
// Load the decrypted note (list rows withhold it).
|
||||
setEditLoading(true);
|
||||
try {
|
||||
const detail = await apiGet<LabResultDetailDto>(
|
||||
`/api/labs/${reading.id}`,
|
||||
);
|
||||
setEditNote(detail.note ?? "");
|
||||
} catch {
|
||||
/* leave note blank on a load miss */
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
if (updateMutation.isPending) return;
|
||||
setEditingId(null);
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function submitEdit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingId) return;
|
||||
const numericValue = parseDecimal(editValue);
|
||||
if (numericValue === null) {
|
||||
setEditError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
const takenDate = new Date(editTakenAt);
|
||||
if (Number.isNaN(takenDate.getTime())) {
|
||||
setEditError(t("labs.editTimestampError"));
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
value: numericValue,
|
||||
takenAt: takenDate.toISOString(),
|
||||
note: editNote.trim() ? editNote.trim() : null,
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = [...readings].sort(
|
||||
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="divide-border divide-y">
|
||||
{ordered.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(r.value)} {r.unit}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={editingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeEdit();
|
||||
}}
|
||||
title={t("labs.editTitle")}
|
||||
description={t("labs.editDescription")}
|
||||
>
|
||||
<form onSubmit={submitEdit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-edit-value">{t("labs.form.value")}</Label>
|
||||
<Input
|
||||
id="lab-edit-value"
|
||||
inputMode="decimal"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-edit-takenAt">
|
||||
{t("labs.form.takenAt")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="lab-edit-takenAt"
|
||||
value={editTakenAt}
|
||||
onChange={(e) => setEditTakenAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-edit-note">{t("labs.form.note")}</Label>
|
||||
<Textarea
|
||||
id="lab-edit-note"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={2}
|
||||
disabled={editLoading}
|
||||
/>
|
||||
</div>
|
||||
{editError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{editError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={closeEdit}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, FlaskConical } from "lucide-react";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -26,34 +25,44 @@ import { LabTrendSparkline } from "./lab-trend-sparkline";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type { LabResultDto, LabResultListResponse } from "./types";
|
||||
|
||||
interface AnalyteGroup {
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
|
||||
key: string;
|
||||
/** Link target when the group is catalog-linked; null for legacy rows. */
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
unit: string;
|
||||
/** All readings for this analyte, oldest → newest. */
|
||||
readings: LabResultDto[];
|
||||
latest: LabResultDto;
|
||||
}
|
||||
|
||||
/** Reduce the flat result list into per-analyte groups (case-insensitive). */
|
||||
function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
|
||||
/**
|
||||
* Group readings by their linked biomarker (or, for legacy un-linked rows,
|
||||
* case-insensitively by analyte). A catalog-linked group deep-links to its
|
||||
* detail chart; legacy groups render inert until the backfill links them.
|
||||
*/
|
||||
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
const byKey = new Map<string, LabResultDto[]>();
|
||||
for (const r of results) {
|
||||
const key = r.analyte.toLowerCase();
|
||||
const existing = byKey.get(key);
|
||||
if (existing) existing.push(r);
|
||||
const key = r.biomarkerId
|
||||
? `bm:${r.biomarkerId}`
|
||||
: `analyte:${r.analyte.toLowerCase()}`;
|
||||
const bucket = byKey.get(key);
|
||||
if (bucket) bucket.push(r);
|
||||
else byKey.set(key, [r]);
|
||||
}
|
||||
const groups: AnalyteGroup[] = [];
|
||||
for (const readings of byKey.values()) {
|
||||
// Ascending by takenAt so the sparkline reads left→right in time and
|
||||
// the last element is the latest.
|
||||
|
||||
const groups: MarkerGroup[] = [];
|
||||
for (const [key, readings] of byKey.entries()) {
|
||||
const ordered = [...readings].sort(
|
||||
(a, b) =>
|
||||
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const latest = ordered[ordered.length - 1];
|
||||
groups.push({
|
||||
key,
|
||||
biomarkerId: latest.biomarkerId,
|
||||
analyte: latest.analyte,
|
||||
panel: latest.panel,
|
||||
unit: latest.unit,
|
||||
@@ -61,7 +70,6 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
|
||||
latest,
|
||||
});
|
||||
}
|
||||
// Group order: most-recently-updated analyte first.
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
new Date(b.latest.takenAt).getTime() -
|
||||
@@ -69,16 +77,11 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
// Trim trailing zeros for whole numbers; keep up to 2 decimals otherwise.
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
||||
}
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const listKey = queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
@@ -89,25 +92,12 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: async () => {
|
||||
const res = await apiGet<LabResultListResponse>(
|
||||
"/api/labs?limit=500&sortDir=desc",
|
||||
);
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("labs.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.deleteError")),
|
||||
queryFn: () =>
|
||||
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
|
||||
});
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupByAnalyte(data?.results ?? []),
|
||||
() => groupReadings(data?.results ?? []),
|
||||
[data?.results],
|
||||
);
|
||||
|
||||
@@ -154,60 +144,76 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.analyte.toLowerCase()}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{group.panel}
|
||||
</span>
|
||||
) : null}
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-3">
|
||||
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(group.latest.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteLatest")}
|
||||
className="size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatValue(group.latest.value)} {group.latest.unit}
|
||||
</span>
|
||||
{group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{formatReferenceRange(
|
||||
group.latest.referenceLow,
|
||||
group.latest.referenceHigh,
|
||||
formatValue,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs">
|
||||
{formatDate(group.latest.takenAt)}
|
||||
</span>
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
count: group.readings.length,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{groups.map((group) => {
|
||||
const body = (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{group.panel}
|
||||
</span>
|
||||
) : null}
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(group.latest.value)} {group.latest.unit}
|
||||
</span>
|
||||
{group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{formatReferenceRange(
|
||||
group.latest.referenceLow,
|
||||
group.latest.referenceHigh,
|
||||
formatLabValue,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs">
|
||||
{formatDate(group.latest.takenAt)}
|
||||
</span>
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
count: group.readings.length,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
{group.biomarkerId ? (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
|
||||
// Catalog-linked groups deep-link to the per-biomarker detail chart.
|
||||
// Legacy un-linked groups render as a plain card until the backfill
|
||||
// links them (or the user re-adds via the structured path).
|
||||
return group.biomarkerId ? (
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="hover:bg-muted/40 transition-colors">{body}</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<Card key={group.key}>{body}</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
|
||||
/** A lab-result row as the list / create endpoints serialise it. */
|
||||
export interface LabResultDto {
|
||||
id: string;
|
||||
/** v1.18.1 — link to the catalog marker (null for legacy free-text rows). */
|
||||
biomarkerId: string | null;
|
||||
panel: string | null;
|
||||
analyte: string;
|
||||
value: number;
|
||||
@@ -17,7 +19,30 @@ export interface LabResultDto {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The single-resource detail DTO — carries the decrypted note. */
|
||||
export interface LabResultDetailDto extends Omit<LabResultDto, "hasNote"> {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface LabResultListResponse {
|
||||
results: LabResultDto[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
/** A user-scoped Biomarker catalog entry as the API serialises it. */
|
||||
export interface BiomarkerDto {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
hasContext: boolean;
|
||||
context: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BiomarkerListResponse {
|
||||
biomarkers: BiomarkerDto[];
|
||||
}
|
||||
Reference in new issue
Block a user