mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Mirror the metric sub-page layout on the biomarker detail
Bring the lab biomarker detail in line with the Insights metric pages: - Lead with the static catalog description directly beneath the heading, the explainer-caption position the metric pages use. - Drop the early big-value + reference-badge block that did not match the assessment-card styling. - Split numeric and qualitative markers: numeric keeps the stat strip and chart, qualitative shows only the latest result text (no strip, no chart, no range badge). The assessment card now renders for any marker with readings and self-suppresses for qualitative-only markers. - Move the full reading history off the detail page onto a dedicated /labs/[biomarkerId]/values route, reached by a top-right list control, matching the metric pages' show-all-values affordance.
This commit is contained in:
1 parent
651b02bc1b
commit
ad43eaeff0
9 files changed
+301
-109
No files matched your search
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Wert wiederhergestellt",
|
||||
"restoreError": "Wert konnte nicht wiederhergestellt werden",
|
||||
"detail": {
|
||||
"history": "Verlauf",
|
||||
"emptyTitle": "Noch keine Werte",
|
||||
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
|
||||
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.",
|
||||
"valuesTitle": "{marker} — alle Werte",
|
||||
"valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.",
|
||||
"backToMarker": "Zurück zu {marker}",
|
||||
"latestResult": "Letztes Ergebnis"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Keine Werte in diesem Zeitraum.",
|
||||
|
||||
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Reading restored",
|
||||
"restoreError": "Could not restore the reading",
|
||||
"detail": {
|
||||
"history": "History",
|
||||
"emptyTitle": "No readings yet",
|
||||
"emptyDescription": "Add a value to start tracking this biomarker over time."
|
||||
"emptyDescription": "Add a value to start tracking this biomarker over time.",
|
||||
"valuesTitle": "{marker} — all readings",
|
||||
"valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.",
|
||||
"backToMarker": "Back to {marker}",
|
||||
"latestResult": "Latest result"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "No readings in this period.",
|
||||
|
||||
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Medición restaurada",
|
||||
"restoreError": "No se pudo restaurar la medición",
|
||||
"detail": {
|
||||
"history": "Historial",
|
||||
"emptyTitle": "Aún no hay mediciones",
|
||||
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
|
||||
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.",
|
||||
"valuesTitle": "{marker} — todas las lecturas",
|
||||
"valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.",
|
||||
"backToMarker": "Volver a {marker}",
|
||||
"latestResult": "Último resultado"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Sin mediciones en este periodo.",
|
||||
|
||||
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Mesure restaurée",
|
||||
"restoreError": "Impossible de restaurer la mesure",
|
||||
"detail": {
|
||||
"history": "Historique",
|
||||
"emptyTitle": "Aucune mesure pour l'instant",
|
||||
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
|
||||
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.",
|
||||
"valuesTitle": "{marker} — toutes les valeurs",
|
||||
"valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.",
|
||||
"backToMarker": "Retour à {marker}",
|
||||
"latestResult": "Dernier résultat"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Aucune mesure sur cette période.",
|
||||
|
||||
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Misurazione ripristinata",
|
||||
"restoreError": "Impossibile ripristinare la misurazione",
|
||||
"detail": {
|
||||
"history": "Cronologia",
|
||||
"emptyTitle": "Ancora nessuna misurazione",
|
||||
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
|
||||
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.",
|
||||
"valuesTitle": "{marker} — tutte le letture",
|
||||
"valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.",
|
||||
"backToMarker": "Torna a {marker}",
|
||||
"latestResult": "Ultimo risultato"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Nessuna misurazione in questo periodo.",
|
||||
|
||||
+5
-2
@@ -7061,9 +7061,12 @@
|
||||
"restoredToast": "Pomiar przywrócony",
|
||||
"restoreError": "Nie udało się przywrócić pomiaru",
|
||||
"detail": {
|
||||
"history": "Historia",
|
||||
"emptyTitle": "Brak pomiarów",
|
||||
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
|
||||
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.",
|
||||
"valuesTitle": "{marker} — wszystkie odczyty",
|
||||
"valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.",
|
||||
"backToMarker": "Powrót do {marker}",
|
||||
"latestResult": "Ostatni wynik"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Brak pomiarów w tym okresie.",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.1 — `/labs/[biomarkerId]/values`.
|
||||
*
|
||||
* The "show all readings" sub-page for one biomarker, mirroring the metric
|
||||
* sub-pages' `/insights/values/[type]`. The detail page keeps the
|
||||
* numbers-first spine (description → stat strip → chart → assessment) and
|
||||
* links here for the raw, editable reading feed. Owns auth + the back link to
|
||||
* the biomarker detail, and hands the marker id to `<LabValuesList>`.
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { LabValuesList } from "@/components/labs/lab-values-list";
|
||||
import type { BiomarkerDto } from "@/components/labs/types";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabBiomarkerValuesPage({
|
||||
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]);
|
||||
|
||||
const { data: marker } = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
enabled: isAuthenticated && biomarkerId.length > 0,
|
||||
});
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markerName = marker?.name ?? "";
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("labs.detail.valuesTitle", { marker: markerName })}
|
||||
description={t("labs.detail.valuesDescription")}
|
||||
backLink={
|
||||
<BackLink
|
||||
href={`/labs/${biomarkerId}`}
|
||||
label={t("labs.detail.backToMarker", { marker: markerName })}
|
||||
dataSlot="lab-values-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LabValuesList biomarkerId={biomarkerId} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
@@ -8,7 +9,13 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
FlaskConical,
|
||||
ListOrdered,
|
||||
Pencil,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
@@ -26,9 +33,8 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -39,8 +45,6 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import { LabForm } from "./lab-form";
|
||||
import { LabHistoryList } from "./lab-history-list";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
LabResultDto,
|
||||
@@ -121,9 +125,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
initialPageParam: 0,
|
||||
@@ -143,9 +144,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
() => list?.pages.flatMap((p) => p.results) ?? [],
|
||||
[list],
|
||||
);
|
||||
const total = list?.pages[0]?.meta.total ?? 0;
|
||||
// More rows exist on the server than are currently loaded.
|
||||
const truncated = hasNextPage ?? false;
|
||||
const latest =
|
||||
readings.length > 0
|
||||
? [...readings].sort(
|
||||
@@ -164,6 +162,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
() => readings.filter((r) => r.value !== null),
|
||||
[readings],
|
||||
);
|
||||
// A marker is qualitative when it has readings but none of them carry a
|
||||
// numeric value — a stat strip / chart make no sense, so the page shows the
|
||||
// latest result text + assessment + the history (via the values sub-page).
|
||||
const isQualitative = readings.length > 0 && numericReadings.length === 0;
|
||||
const summary = useMemo<DataSummary | null>(
|
||||
() =>
|
||||
numericReadings.length > 0
|
||||
@@ -178,7 +180,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
);
|
||||
const mounted = useMounted();
|
||||
const { data: assessment, isLoading: assessmentLoading } =
|
||||
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
|
||||
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
|
||||
|
||||
function afterAdd() {
|
||||
setAddOpen(false);
|
||||
@@ -245,16 +247,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// A qualitative latest reading (numeric value null) has no range verdict.
|
||||
const latestStatus =
|
||||
latest && latest.value !== null
|
||||
? classifyReferenceRange(
|
||||
latest.value,
|
||||
marker?.lowerBound ?? null,
|
||||
marker?.upperBound ?? null,
|
||||
)
|
||||
: "unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -270,6 +262,30 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.25.1 — "show all readings" rides the header cluster as an icon
|
||||
button, mirroring the metric sub-pages' `<SubPageShell>` control.
|
||||
The full reading feed lives on `/labs/[biomarkerId]/values`; the
|
||||
detail page keeps the numbers-first spine. */}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -313,25 +329,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{latest.value !== null ? (
|
||||
<>
|
||||
{formatLabValue(latest.value)}{" "}
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
{marker?.unit ?? latest.unit}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Qualitative latest reading — show the result text, no unit.
|
||||
(latest.valueText ?? "")
|
||||
)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={latestStatus} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* v1.25.1 — the static description leads the page, directly beneath the
|
||||
heading, mirroring the metric sub-pages' explainer caption. Resolves
|
||||
the catalog slug from the marker name, falling back to the user's own
|
||||
`context`. */}
|
||||
{(() => {
|
||||
const slug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
@@ -346,21 +347,15 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
|
||||
numeric readings, mirroring the metric sub-pages. Self-gating:
|
||||
renders nothing for a qualitative-only or empty marker. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-60 w-full" />
|
||||
) : readings.length === 0 ? (
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : readings.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<EmptyState
|
||||
icon={<FlaskConical className="size-6" />}
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
@@ -371,55 +366,47 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{readings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
|
||||
{truncated ? (
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isQualitative ? (
|
||||
// Qualitative marker — no numeric strip / chart. Show the latest
|
||||
// result text on its own simple line, no unit, no range badge.
|
||||
<Card>
|
||||
<CardContent className="space-y-1 pt-6">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", {
|
||||
shown: readings.length,
|
||||
total,
|
||||
})}
|
||||
{t("labs.detail.latestResult")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-2xl font-bold">{latest?.valueText ?? ""}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<LabHistoryList readings={readings} />
|
||||
<CardContent className="pt-6">
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("common.loading")
|
||||
: t("labs.loadMoreReadings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI assessment — the spine's closing block, mirroring the metric
|
||||
sub-pages (intro → stat strip → chart → history → assessment). The
|
||||
card self-suppresses when the operator disabled status cards, and
|
||||
the hook is gated on the marker having numeric readings. */}
|
||||
{numericReadings.length > 0 ? (
|
||||
sub-pages (intro → stat strip → chart → assessment). The card
|
||||
self-suppresses for a qualitative-only marker (the read-only route
|
||||
returns `insufficient` without calling a provider). */}
|
||||
{readings.length > 0 ? (
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<FlaskConical className="h-5 w-5" />}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { LabHistoryList } from "./lab-history-list";
|
||||
import type { LabResultDto, LabResultListResponse } from "./types";
|
||||
|
||||
// Page size for the offset-paginated reading feed. The server caps a single
|
||||
// read at 500 (`listLabResultsSchema`); this stays well under that and keeps
|
||||
// the first paint light, with "Load more" pulling subsequent pages.
|
||||
const READINGS_PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* v1.25.1 — the full reading history for one biomarker, lifted off the detail
|
||||
* page onto its own `/labs/[biomarkerId]/values` route (mirroring the metric
|
||||
* sub-pages' `/insights/values/<type>` split). The detail page keeps the
|
||||
* numbers-first spine; this is the raw, editable reading feed with "Load more".
|
||||
* Reuses the same offset-paginated infinite query key as the detail page so the
|
||||
* two share cache.
|
||||
*/
|
||||
export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
apiGet<LabResultListResponse>(
|
||||
`/api/labs?biomarkerId=${encodeURIComponent(
|
||||
biomarkerId,
|
||||
)}&limit=${READINGS_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`,
|
||||
),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const next = lastPage.meta.offset + lastPage.meta.limit;
|
||||
return next < lastPage.meta.total ? next : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const readings: LabResultDto[] = useMemo(
|
||||
() => list?.pages.flatMap((p) => p.results) ?? [],
|
||||
[list],
|
||||
);
|
||||
const total = list?.pages[0]?.meta.total ?? 0;
|
||||
const truncated = hasNextPage ?? false;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-60 w-full" />;
|
||||
}
|
||||
|
||||
if (readings.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("labs.detail.emptyTitle")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", {
|
||||
shown: readings.length,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<LabHistoryList readings={readings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("common.loading")
|
||||
: t("labs.loadMoreReadings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user