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")} + /> +
+
+ +