From 04c95e5ba1ba7d93febf22b9ad5526f180e406ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:06:08 +0200 Subject: [PATCH] feat(labs): structured entry, per-biomarker chart, and edit/delete UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Labs surface to match the rest of the app: - Add a biomarker manager on the Labs page (define/edit/delete markers, seed from a common panel) — the catalog lives with Labs, not Settings. - Replace the free-text add form with a structured one: pick a biomarker, enter the value against its known unit/range; '+ define new' inline. - New /labs/[biomarkerId] detail page with a proper dashboard-style chart (Recharts line + shaded reference band + dashed bounds, range tabs, tooltip) replacing the 72x20 sparkline, plus the reading history with full edit/amend/delete and a delete-Undo toast. - List cards group by linked marker and deep-link to the detail chart. The reference band uses a muted token fill, never an alarm colour; the neutral ReferenceRangeBadge is reused for the in/out-of-range verdict. --- src/app/labs/[biomarkerId]/page.tsx | 51 ++++ src/app/labs/page.tsx | 41 ++- src/components/labs/biomarker-form.tsx | 281 ++++++++++++++++++ src/components/labs/biomarker-manager.tsx | 177 +++++++++++ src/components/labs/lab-biomarker-chart.tsx | 224 ++++++++++++++ src/components/labs/lab-biomarker-detail.tsx | 181 ++++++++++++ src/components/labs/lab-form.tsx | 295 +++++++++++-------- src/components/labs/lab-history-list.tsx | 271 +++++++++++++++++ src/components/labs/lab-list.tsx | 196 ++++++------ src/components/labs/types.ts | 25 ++ 10 files changed, 1514 insertions(+), 228 deletions(-) create mode 100644 src/app/labs/[biomarkerId]/page.tsx create mode 100644 src/components/labs/biomarker-form.tsx create mode 100644 src/components/labs/biomarker-manager.tsx create mode 100644 src/components/labs/lab-biomarker-chart.tsx create mode 100644 src/components/labs/lab-biomarker-detail.tsx create mode 100644 src/components/labs/lab-history-list.tsx diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx new file mode 100644 index 000000000..29189ca39 --- /dev/null +++ b/src/app/labs/[biomarkerId]/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`). + * + * Owns auth + the back link and hands the marker id to + * ``, which carries the heading, the dashboard-style + * chart with the reference band, and the editable reading history. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function LabBiomarkerDetailPage({ + params, +}: { + params: Promise<{ biomarkerId: string }>; +}) { + const { biomarkerId } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 2030d0510..d0b685f1c 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Plus } from "lucide-react"; +import { FlaskConical, Loader2, Plus } from "lucide-react"; +import { BiomarkerManager } from "@/components/labs/biomarker-manager"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { Button } from "@/components/ui/button"; @@ -23,6 +24,7 @@ export default function LabsPage() { const queryClient = useQueryClient(); const { t } = useTranslations(); const [dialogOpen, setDialogOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -30,7 +32,7 @@ export default function LabsPage() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: dialogOpen || manageOpen, }); useEffect(() => { @@ -59,13 +61,25 @@ export default function LabsPage() { {t("labs.subtitle")}

- +
+ + +
+ + + + setDialogOpen(true)} /> ); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx new file mode 100644 index 000000000..e814ef717 --- /dev/null +++ b/src/components/labs/biomarker-form.tsx @@ -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 ` + + + + + {BIOMARKER_PANELS.map((panelKey) => { + const items = BIOMARKER_CATALOG.filter( + (s) => s.panel === panelKey, + ); + if (items.length === 0) return null; + return ( + + + {t(`labs.catalog.panel.${panelKey}`)} + + {items.map((s) => ( + + {t(`labs.catalog.${s.slug}`)} + + ))} + + ); + })} + + +

+ {t("labs.biomarker.form.seedHint")} +

+ + ) : null} + +
+
+ + setName(e.target.value)} + placeholder={t("labs.biomarker.form.namePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.biomarker.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setPanel(e.target.value)} + placeholder={t("labs.biomarker.form.panelPlaceholder")} + maxLength={120} + /> +
+
+ + setLower(e.target.value)} + placeholder={t("labs.biomarker.form.lowerPlaceholder")} + /> +
+
+ + setUpper(e.target.value)} + placeholder={t("labs.biomarker.form.upperPlaceholder")} + /> +
+
+ +