feat(labs): compact editable biomarker list with hide

Rebuild the biomarker manager as compact rows (name + unit inline,
reference range / panel on a muted sub-line) with a hide toggle, an
edit button, and a delete, replacing the one-card-per-marker layout.
Hidden markers collect in a separate restorable section. Filter hidden
markers out of the lab-entry picker, keeping the currently selected one
visible so an existing reading still resolves its label.

Touch targets meet the 44px mobile floor (size-11 sm:size-9).
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 12:48:54 +02:00
1 parent e097e57c84
commit 3ad59cde98
4 files changed
+134 -66

No files matched your search

+8 -1
View File
@@ -6720,7 +6720,14 @@
"savedToast": "Biomarker gespeichert",
"updatedToast": "Biomarker aktualisiert",
"saveError": "Biomarker konnte nicht gespeichert werden"
}
},
"hide": "Ausblenden",
"unhide": "Wiederherstellen",
"hiddenToast": "Biomarker ausgeblendet",
"unhiddenToast": "Biomarker wiederhergestellt",
"hideError": "Biomarker konnte nicht aktualisiert werden",
"allHidden": "Alle Biomarker sind ausgeblendet.",
"hiddenSection": "Ausgeblendet"
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
+8 -1
View File
@@ -6720,7 +6720,14 @@
"savedToast": "Biomarker saved",
"updatedToast": "Biomarker updated",
"saveError": "Could not save the biomarker"
}
},
"hide": "Hide",
"unhide": "Restore",
"hiddenToast": "Biomarker hidden",
"unhiddenToast": "Biomarker restored",
"hideError": "Could not update the biomarker",
"allHidden": "Every biomarker is hidden.",
"hiddenSection": "Hidden"
},
"catalog": {
"total-cholesterol": "Total cholesterol",
+112 -62
View File
@@ -2,16 +2,15 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { Eye, EyeOff, 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 { apiDelete, apiGet, apiPut } 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";
@@ -21,12 +20,18 @@ import { BiomarkerForm } from "./biomarker-form";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
/**
* v1.18.1 — the Biomarker catalog manager.
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
*
* 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).
* feature's primary object. Each marker renders as a single compact row
* (name + unit inline, reference range / panel on a muted sub-line) with a
* hide toggle, an edit button (reusing the define/edit sheet), and a delete
* (the `onDelete: SetNull` FK keeps existing readings, just unlinks them).
*
* "Hide" is a soft remove: a marker the user no longer needs leaves the
* active list and the lab-entry pickers but keeps its readings + canonical
* unit/range. Hidden markers collect in a separate section where they can be
* restored or deleted outright.
*/
export function BiomarkerManager() {
const { t } = useTranslations();
@@ -50,6 +55,20 @@ export function BiomarkerManager() {
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
const hiddenMutation = useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
apiPut(`/api/biomarkers/${id}`, { hidden }),
onSuccess: (_data, { hidden }) => {
toast.success(
hidden
? t("labs.biomarker.hiddenToast")
: t("labs.biomarker.unhiddenToast"),
);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
},
onError: () => toast.error(t("labs.biomarker.hideError")),
});
function openNew() {
setEditing(null);
setFormOpen(true);
@@ -68,6 +87,73 @@ export function BiomarkerManager() {
}
const markers = data?.biomarkers ?? [];
const visible = markers.filter((m) => !m.hidden);
const hidden = markers.filter((m) => m.hidden);
function renderRow(marker: BiomarkerDto) {
const range = formatReferenceRange(
marker.lowerBound,
marker.upperBound,
formatLabValue,
);
return (
<li
key={marker.id}
data-hidden={marker.hidden ? "true" : undefined}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 data-[hidden=true]:opacity-70"
>
<div className="min-w-0 flex-1">
<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>
<Button
size="icon"
variant="ghost"
className="size-11 sm:size-9"
onClick={() =>
hiddenMutation.mutate({ id: marker.id, hidden: !marker.hidden })
}
disabled={hiddenMutation.isPending}
aria-label={
marker.hidden
? t("labs.biomarker.unhide")
: t("labs.biomarker.hide")
}
>
{marker.hidden ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="size-11 sm: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-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</li>
);
}
return (
<div className="space-y-4">
@@ -84,7 +170,7 @@ export function BiomarkerManager() {
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full" />
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
@@ -101,60 +187,24 @@ export function BiomarkerManager() {
}
/>
) : (
<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>
<div className="space-y-4">
{visible.length > 0 ? (
<ul className="space-y-2">{visible.map(renderRow)}</ul>
) : (
<p className="text-muted-foreground py-2 text-sm">
{t("labs.biomarker.allHidden")}
</p>
)}
{hidden.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("labs.biomarker.hiddenSection")}
</p>
<ul className="space-y-2">{hidden.map(renderRow)}</ul>
</div>
) : null}
</div>
)}
<ResponsiveSheet
+6 -2
View File
@@ -110,8 +110,12 @@ export function LabForm({
null,
);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
const allMarkers = catalog?.biomarkers ?? [];
const selected = allMarkers.find((m) => m.id === biomarkerId);
// v1.22 — hidden markers drop from the picker, but keep the currently
// selected one visible (e.g. editing a reading whose marker was later
// hidden) so the Select still resolves its label.
const markers = allMarkers.filter((m) => !m.hidden || m.id === biomarkerId);
function handleSelect(next: string) {
if (next === DEFINE_NEW) {