mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
e097e57c84
commit
3ad59cde98
4 files changed
+134
-66
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user