mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(medications): restore intake-history table on the detail page
The v1 IntakeHistoryList was retired in v1.4.28 on the assumption it had no other consumer. The standalone /medications/[id]/history page lost its dose timeline as a side-effect — non-GLP-1 medications now render only the heading. This restores a slim, read-only v2. - Paginated 25/page via the existing limit/offset query params - Sortable by takenAt (default desc) and scheduledFor - Status and source badges; no inline edit or delete - Empty state with CTA back to the daily intake page - i18n keys across all six locales (DE + EN curated, the AI-initial locales mirror the EN copy per the v1.4.25 W9e maintainership policy)
This commit is contained in:
1 parent
28a829695d
commit
a17e7a46af
9 files changed
+621
No files matched your search
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "Externe API",
|
||||
"sourceReminder": "Telegram / Erinnerung",
|
||||
"sourceImport": "CSV-Import",
|
||||
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
|
||||
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
|
||||
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
|
||||
"intakeHistoryColTaken": "Eingenommen",
|
||||
"intakeHistoryColScheduled": "Geplant",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Quelle",
|
||||
"intakeHistoryStatusTaken": "Eingenommen",
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
"intakeHistorySortByScheduled": "Nach geplantem Zeitpunkt sortieren",
|
||||
"intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge",
|
||||
"intakeHistoryPrev": "Zurück",
|
||||
"intakeHistoryNext": "Weiter",
|
||||
"unitDrops": "Tropfen",
|
||||
"unitTablets": "Tablette(n)",
|
||||
"unitCapsules": "Kapsel(n)",
|
||||
|
||||
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
|
||||
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
|
||||
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
|
||||
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
|
||||
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -133,6 +134,14 @@ export default function IntakeHistoryPage({
|
||||
{medication?.treatmentClass === "GLP1" && (
|
||||
<TitrationSection medicationId={id} />
|
||||
)}
|
||||
|
||||
{/* v1.4.36 W4a — Lite intake-history table mounted for ALL
|
||||
medication kinds. v1.4.28 dropped the v1 component on the
|
||||
assumption it had no other consumer; the standalone history
|
||||
page lost its dose timeline as a side-effect. The v2 is
|
||||
read-only (no inline CRUD) — edit/delete still flow through
|
||||
the regular intake routes. */}
|
||||
{medication && <IntakeHistoryListV2 medicationId={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4a — IntakeHistoryListV2 SSR smoke tests.
|
||||
*
|
||||
* Mirrors the project convention: `renderToStaticMarkup` +
|
||||
* react-query data seeded via `QueryClient.setQueryData()`. Sort-toggle
|
||||
* is a useState interaction and cannot fire through SSR, so the
|
||||
* toggle test exercises the seeded sortDir/sortBy variants and pins
|
||||
* the rendered indicator markup.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type IntakeRow = {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function seedIntake(
|
||||
client: QueryClient,
|
||||
medId: string,
|
||||
rows: IntakeRow[],
|
||||
total: number = rows.length,
|
||||
opts: {
|
||||
sortBy?: "takenAt" | "scheduledFor";
|
||||
sortDir?: "asc" | "desc";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) {
|
||||
const sortBy = opts.sortBy ?? "takenAt";
|
||||
const sortDir = opts.sortDir ?? "desc";
|
||||
const limit = opts.limit ?? 25;
|
||||
const offset = opts.offset ?? 0;
|
||||
client.setQueryData(
|
||||
[
|
||||
"medications",
|
||||
medId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit, offset },
|
||||
],
|
||||
{ events: rows, meta: { total, limit, offset } },
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
client: QueryClient,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — empty state", () => {
|
||||
it("renders the empty-state title and CTA in English", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("No intakes recorded yet");
|
||||
expect(html).toContain("Open daily intake");
|
||||
expect(html).toContain('href="/medications"');
|
||||
});
|
||||
|
||||
it("renders the empty-state title and CTA in German", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Noch keine Einnahmen erfasst");
|
||||
expect(html).toContain("Zur Tagesübersicht");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — populated rows", () => {
|
||||
it("renders one row per intake event with status + source badges", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-14T08:00:00.000Z",
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
source: "REMINDER",
|
||||
createdAt: "2026-05-14T20:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("Taken");
|
||||
expect(html).toContain("Skipped");
|
||||
expect(html).toContain("Website");
|
||||
expect(html).toContain("Telegram / Reminder");
|
||||
});
|
||||
|
||||
it("renders the section title across both locales", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const enHtml = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(enHtml).toContain("Intake history");
|
||||
|
||||
const deClient = makeClient();
|
||||
seedIntake(deClient, "med-1", []);
|
||||
const deHtml = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
deClient,
|
||||
"de",
|
||||
);
|
||||
expect(deHtml).toContain("Einnahme-Verlauf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
it("renders a desc arrow on the active sort column", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
// Default sort = takenAt desc, so the "taken" header carries the
|
||||
// arrow-down icon and the "scheduled" header carries the neutral
|
||||
// dual-arrow icon.
|
||||
expect(html).toContain("arrow-down");
|
||||
expect(html).toContain("arrow-up-down");
|
||||
});
|
||||
|
||||
it("renders pagination controls only when more than one page", () => {
|
||||
const client = makeClient();
|
||||
// 50 total > pageSize 25 → pagination should render
|
||||
seedIntake(
|
||||
client,
|
||||
"med-2",
|
||||
[
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-2",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
50,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-2" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("Previous");
|
||||
expect(html).toContain("Next");
|
||||
});
|
||||
|
||||
it("hides pagination when total ≤ pageSize", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(
|
||||
client,
|
||||
"med-3",
|
||||
[
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-3",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-3" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain(">Previous<");
|
||||
expect(html).not.toContain(">Next<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, ArrowUp, ArrowDown, ArrowUpDown, History } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4a — Lite intake-history surface.
|
||||
*
|
||||
* The 886-LoC v1 was retired in v1.4.28 (commit 8c81af10) with the
|
||||
* note "no other consumer". The standalone medication-history page
|
||||
* lost its dose timeline as a side effect — Marc reported the page
|
||||
* now shows only the heading for non-GLP-1 meds. This v2 restores a
|
||||
* read-only timeline for ALL medication kinds without the inline CRUD
|
||||
* the v1 carried (edit / delete still happen through the regular
|
||||
* medication-intake routes elsewhere).
|
||||
*
|
||||
* Contract:
|
||||
* - Paginated 25/page via the existing `?limit=&offset=` query on
|
||||
* `GET /api/medications/[id]/intake`. The route already supports
|
||||
* `sortBy=takenAt|scheduledFor|createdAt|source` + `sortDir=asc|desc`.
|
||||
* - Sortable by `takenAt` (default) and `scheduledFor`. Click the
|
||||
* header to toggle the direction; switching column resets to desc.
|
||||
* - Source badge surfaces WEB / API / REMINDER / IMPORT using the
|
||||
* existing `medications.source*` keys.
|
||||
* - Empty state copy + CTA back to the daily intake page.
|
||||
* - No edit-in-row, no delete buttons.
|
||||
*/
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: IntakeSource;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IntakeListResponse {
|
||||
events: IntakeEvent[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
interface IntakeHistoryListV2Props {
|
||||
medicationId: string;
|
||||
/** Override the default page size — primarily for tests. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
type SortKey = "takenAt" | "scheduledFor";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export function IntakeHistoryListV2({
|
||||
medicationId,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: IntakeHistoryListV2Props) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
|
||||
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const offset = page * pageSize;
|
||||
|
||||
const { data, isLoading, isError } = useQuery<IntakeListResponse>({
|
||||
queryKey: [
|
||||
"medications",
|
||||
medicationId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit: pageSize, offset },
|
||||
],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: String(pageSize),
|
||||
offset: String(offset),
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake?${params.toString()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load intake history");
|
||||
return (await res.json()).data as IntakeListResponse;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const sourceLabels: Record<IntakeSource, string> = useMemo(
|
||||
() => ({
|
||||
WEB: t("medications.sourceWeb"),
|
||||
API: t("medications.sourceApi"),
|
||||
REMINDER: t("medications.sourceReminder"),
|
||||
IMPORT: t("medications.sourceImport"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const events = data?.events ?? [];
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (key === sortBy) {
|
||||
setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const sortIndicator = (key: SortKey) => {
|
||||
if (key !== sortBy) {
|
||||
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-50" />;
|
||||
}
|
||||
return sortDir === "desc" ? (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("medications.intakeHistory")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 md:pb-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
</p>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<History className="size-6" />}
|
||||
title={t("medications.intakeHistoryEmptyTitle")}
|
||||
description={t("medications.intakeHistoryEmptyDescription")}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/medications">
|
||||
{t("medications.intakeHistoryEmptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("takenAt")}
|
||||
className="inline-flex items-center font-medium hover:underline"
|
||||
aria-label={t("medications.intakeHistorySortByTaken")}
|
||||
>
|
||||
{t("medications.intakeHistoryColTaken")}
|
||||
{sortIndicator("takenAt")}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("scheduledFor")}
|
||||
className="inline-flex items-center font-medium hover:underline"
|
||||
aria-label={t("medications.intakeHistorySortByScheduled")}
|
||||
>
|
||||
{t("medications.intakeHistoryColScheduled")}
|
||||
{sortIndicator("scheduledFor")}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColStatus")}</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColSource")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-sm">
|
||||
{event.takenAt ? formatters.dateTime(event.takenAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatters.dateTime(event.scheduledFor)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={event.skipped ? "outline" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{event.skipped
|
||||
? t("medications.intakeHistoryStatusSkipped")
|
||||
: t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span>
|
||||
{t("medications.intakeHistoryPageInfo", {
|
||||
page: page + 1,
|
||||
total: totalPages,
|
||||
count: total,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
{t("medications.intakeHistoryPrev")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages - 1, p + 1))
|
||||
}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
{t("medications.intakeHistoryNext")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user