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 `