mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.1: biomarker detail mirrors the Insights metric sub-page
This commit is contained in:
commit
0109fbbc4c
9 files changed
+301
-109
No files matched your search
+5
-2
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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