From ac39b9ca34ec2345ea75bceb721ce2d47ee4d7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 19:10:18 +0200 Subject: [PATCH] feat(medications): promote the detail page to a tabbed shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make /medications/[id] the canonical full-page home for one medication: a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf · Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a "Vollständig bearbeiten" jump into the wizard for structural edits. Everyday levers move inline into the tabs. Übersicht surfaces the adherence bars, Erinnerung owns notifications + reminder grace, Bestand reads the pen/vial inventory (all medications, calm empty state), Verlauf renders the editable intake history, and Injektion (injectable routes only) carries the existing GLP-1 charts + side-effect log. The ledger, the per-dose window editor and the injection body-map land in the following waves. The legacy /medications/[id]/history route now redirects to ?tab=verlauf for bookmarks and deep links. Tab labels are i18n across six locales. --- messages/de.json | 40 ++ messages/en.json | 40 ++ messages/es.json | 40 ++ messages/fr.json | 40 ++ messages/it.json | 40 ++ messages/pl.json | 40 ++ src/app/medications/[id]/history/page.tsx | 151 +---- src/app/medications/[id]/page.tsx | 170 +----- .../detail-page-history-surface.test.ts | 53 +- .../detail/MedicationDetailTabs.tsx | 559 ++++++++++++++++++ .../sections/inventory-section.tsx | 130 ++++ src/lib/query-keys.ts | 7 + 12 files changed, 986 insertions(+), 324 deletions(-) create mode 100644 src/components/medications/detail/MedicationDetailTabs.tsx create mode 100644 src/components/medications/sections/inventory-section.tsx diff --git a/messages/de.json b/messages/de.json index 654e9d673..da95f3686 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1457,6 +1457,46 @@ "back": "Zurück zum Medikament", "subtitle": "Vollständiger Einnahmeverlauf", "plannedSuffix": "geplant" + }, + "tabs": { + "uebersicht": "Übersicht", + "zeitplan": "Zeitplan", + "erinnerung": "Erinnerung", + "bestand": "Bestand", + "verlauf": "Verlauf", + "injektion": "Injektion", + "erweitert": "Erweitert" + }, + "shell": { + "fullEdit": "Vollständig bearbeiten", + "loadFailed": "Dieses Medikament konnte nicht geladen werden.", + "tablistLabel": "Medikamenten-Detailbereiche" + }, + "uebersicht": { + "complianceTitle": "Therapietreue", + "complianceEmpty": "Noch keine Daten zur Therapietreue." + }, + "bestand": { + "title": "Bestand", + "summary": "{remaining} von {total} Dosen übrig", + "itemsTitle": "Pens / Ampullen", + "doses": "{remaining} / {total} Dosen", + "state": { + "ACTIVE": "Ungeöffnet", + "IN_USE": "In Gebrauch", + "EXPIRED": "Abgelaufen", + "USED_UP": "Aufgebraucht" + }, + "empty": "Kein Bestand erfasst.", + "emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen." + }, + "erweitert": { + "group": { + "lifecycle": "Lebenszyklus", + "externalApi": "Externe API", + "data": "Daten", + "danger": "Gefahrenzone" + } } }, "logInjectionSiteTitle": "Injektionsstelle erfassen", diff --git a/messages/en.json b/messages/en.json index d5bc6e7fe..bce49e826 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1457,6 +1457,46 @@ "back": "Back to medication", "subtitle": "Full intake history", "plannedSuffix": "planned" + }, + "tabs": { + "uebersicht": "Overview", + "zeitplan": "Schedule", + "erinnerung": "Reminders", + "bestand": "Supply", + "verlauf": "History", + "injektion": "Injection", + "erweitert": "Advanced" + }, + "shell": { + "fullEdit": "Edit fully", + "loadFailed": "This medication could not be loaded.", + "tablistLabel": "Medication detail sections" + }, + "uebersicht": { + "complianceTitle": "Adherence", + "complianceEmpty": "No adherence data yet." + }, + "bestand": { + "title": "Supply", + "summary": "{remaining} of {total} doses remaining", + "itemsTitle": "Pens / vials", + "doses": "{remaining} / {total} doses", + "state": { + "ACTIVE": "Unopened", + "IN_USE": "In use", + "EXPIRED": "Expired", + "USED_UP": "Used up" + }, + "empty": "No supply recorded.", + "emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply." + }, + "erweitert": { + "group": { + "lifecycle": "Lifecycle", + "externalApi": "External API", + "data": "Data", + "danger": "Danger zone" + } } }, "logInjectionSiteTitle": "Log injection site", diff --git a/messages/es.json b/messages/es.json index acfecf876..c91262b0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1457,6 +1457,46 @@ "back": "Volver al medicamento", "subtitle": "Historial completo de tomas", "plannedSuffix": "planificado" + }, + "tabs": { + "uebersicht": "Resumen", + "zeitplan": "Horario", + "erinnerung": "Recordatorios", + "bestand": "Existencias", + "verlauf": "Historial", + "injektion": "Inyección", + "erweitert": "Avanzado" + }, + "shell": { + "fullEdit": "Editar por completo", + "loadFailed": "No se pudo cargar este medicamento.", + "tablistLabel": "Secciones de detalle del medicamento" + }, + "uebersicht": { + "complianceTitle": "Adherencia", + "complianceEmpty": "Aún no hay datos de adherencia." + }, + "bestand": { + "title": "Existencias", + "summary": "{remaining} de {total} dosis restantes", + "itemsTitle": "Plumas / viales", + "doses": "{remaining} / {total} dosis", + "state": { + "ACTIVE": "Sin abrir", + "IN_USE": "En uso", + "EXPIRED": "Caducado", + "USED_UP": "Agotado" + }, + "empty": "No hay existencias registradas.", + "emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro." + }, + "erweitert": { + "group": { + "lifecycle": "Ciclo de vida", + "externalApi": "API externa", + "data": "Datos", + "danger": "Zona de peligro" + } } }, "logInjectionSiteTitle": "Registrar sitio de inyección", diff --git a/messages/fr.json b/messages/fr.json index 0c3954a1b..d7a71f79f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1457,6 +1457,46 @@ "back": "Retour au médicament", "subtitle": "Historique complet des prises", "plannedSuffix": "prévu" + }, + "tabs": { + "uebersicht": "Aperçu", + "zeitplan": "Horaire", + "erinnerung": "Rappels", + "bestand": "Stock", + "verlauf": "Historique", + "injektion": "Injection", + "erweitert": "Avancé" + }, + "shell": { + "fullEdit": "Modifier entièrement", + "loadFailed": "Ce médicament n'a pas pu être chargé.", + "tablistLabel": "Sections de détail du médicament" + }, + "uebersicht": { + "complianceTitle": "Observance", + "complianceEmpty": "Aucune donnée d'observance pour l'instant." + }, + "bestand": { + "title": "Stock", + "summary": "{remaining} doses restantes sur {total}", + "itemsTitle": "Stylos / flacons", + "doses": "{remaining} / {total} doses", + "state": { + "ACTIVE": "Non ouvert", + "IN_USE": "En cours", + "EXPIRED": "Expiré", + "USED_UP": "Épuisé" + }, + "empty": "Aucun stock enregistré.", + "emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie." + }, + "erweitert": { + "group": { + "lifecycle": "Cycle de vie", + "externalApi": "API externe", + "data": "Données", + "danger": "Zone de danger" + } } }, "logInjectionSiteTitle": "Enregistrer le site d'injection", diff --git a/messages/it.json b/messages/it.json index 093d200f5..e703f84cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1457,6 +1457,46 @@ "back": "Torna al farmaco", "subtitle": "Cronologia completa delle assunzioni", "plannedSuffix": "pianificato" + }, + "tabs": { + "uebersicht": "Panoramica", + "zeitplan": "Programma", + "erinnerung": "Promemoria", + "bestand": "Scorta", + "verlauf": "Cronologia", + "injektion": "Iniezione", + "erweitert": "Avanzate" + }, + "shell": { + "fullEdit": "Modifica completa", + "loadFailed": "Impossibile caricare questo farmaco.", + "tablistLabel": "Sezioni di dettaglio del farmaco" + }, + "uebersicht": { + "complianceTitle": "Aderenza", + "complianceEmpty": "Ancora nessun dato sull'aderenza." + }, + "bestand": { + "title": "Scorta", + "summary": "{remaining} dosi rimanenti su {total}", + "itemsTitle": "Penne / flaconi", + "doses": "{remaining} / {total} dosi", + "state": { + "ACTIVE": "Non aperto", + "IN_USE": "In uso", + "EXPIRED": "Scaduto", + "USED_UP": "Esaurito" + }, + "empty": "Nessuna scorta registrata.", + "emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia." + }, + "erweitert": { + "group": { + "lifecycle": "Ciclo di vita", + "externalApi": "API esterna", + "data": "Dati", + "danger": "Zona pericolosa" + } } }, "logInjectionSiteTitle": "Registra il sito di iniezione", diff --git a/messages/pl.json b/messages/pl.json index 4f43e1734..74c8e68f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1457,6 +1457,46 @@ "back": "Powrót do leku", "subtitle": "Pełna historia przyjęć", "plannedSuffix": "zaplanowane" + }, + "tabs": { + "uebersicht": "Przegląd", + "zeitplan": "Harmonogram", + "erinnerung": "Przypomnienia", + "bestand": "Zapas", + "verlauf": "Historia", + "injektion": "Iniekcja", + "erweitert": "Zaawansowane" + }, + "shell": { + "fullEdit": "Edytuj w pełni", + "loadFailed": "Nie udało się załadować tego leku.", + "tablistLabel": "Sekcje szczegółów leku" + }, + "uebersicht": { + "complianceTitle": "Przestrzeganie", + "complianceEmpty": "Brak danych o przestrzeganiu." + }, + "bestand": { + "title": "Zapas", + "summary": "Pozostało {remaining} z {total} dawek", + "itemsTitle": "Wstrzykiwacze / fiolki", + "doses": "{remaining} / {total} dawek", + "state": { + "ACTIVE": "Nieotwarte", + "IN_USE": "W użyciu", + "EXPIRED": "Przeterminowane", + "USED_UP": "Zużyte" + }, + "empty": "Nie zarejestrowano zapasu.", + "emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie." + }, + "erweitert": { + "group": { + "lifecycle": "Cykl życia", + "externalApi": "Zewnętrzne API", + "data": "Dane", + "danger": "Strefa zagrożenia" + } } }, "logInjectionSiteTitle": "Zarejestruj miejsce iniekcji", diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index a24e3df0a..948509a8d 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -1,153 +1,40 @@ "use client"; /** - * v1.7.0 — full intake-history view. + * v1.15.18 — legacy intake-history route. * - * Reached directly from the detail-page header's History button. This - * surface is intake-history ONLY: the estimated drug-level curve, the - * side-effect logbook, the schedule ("Rhythmus") editor and the - * titration ladder no longer dominate here. The only editable units - * are individual intakes (edit / delete / bulk-delete via the shared - * ``); CSV import is present but de-emphasised - * as a ghost action in the header. - * - * Sort defaults to `scheduledFor desc` so the order reads - * today → yesterday → … and skipped rows (`takenAt: null`) never float - * to the top (O-1). - * - * The estimated active-ingredient curve stays available as a - * default-CLOSED disclosure at the bottom for GLP-1 medications (O-2); - * it is genuine history context but opt-in, not the default read. + * The full intake history folded into the medication detail page's + * Verlauf tab. This route stays only as a deep-link redirect so existing + * bookmarks and any iOS deep-link keep landing on the right surface. */ -import { useEffect, useState, use } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; +import { use, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Loader2, Upload } from "lucide-react"; -import Link from "next/link"; +import { Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; -import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; -import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; -import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; - -export default function IntakeHistoryPage({ +export default function IntakeHistoryRedirectPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = use(params); - const { isAuthenticated, isLoading: authLoading } = useAuth(); const router = useRouter(); - const { t } = useTranslations(); - - const [importOpen, setImportOpen] = useState(false); useEffect(() => { - if (!authLoading && !isAuthenticated) { - router.push("/auth/login"); - } - }, [authLoading, isAuthenticated, router]); - - const { data: medication, isLoading: medLoading } = useQuery({ - queryKey: queryKeys.medicationDetail(id), - queryFn: async () => { - const res = await fetch(`/api/medications/${id}`); - if (!res.ok) throw new Error("Failed to fetch"); - const json = await res.json(); - return json.data as { - id: string; - name: string; - dose: string; - treatmentClass?: string; - notificationsEnabled?: boolean; - }; - }, - enabled: isAuthenticated, - }); - - if (authLoading || medLoading) { - return ( -
- -
- ); - } - - const isGlp1 = medication?.treatmentClass === "GLP1"; + router.replace(`/medications/${id}?tab=verlauf`); + }, [id, router]); return ( -
- {/* Back to the medication detail page — history is a drill-down - OF the medication, not a sibling of the list. */} - - - {medication && ( -
-
-

- {medication.name} -

-

{medication.dose}

-

- {t("medications.detail.history.subtitle")} -

-
- {/* De-emphasised import — ghost / muted, not a prominent CTA. */} - -
- )} - - {medication && ( - - )} - - {/* Estimated drug-level curve (the modelled active-ingredient - level) + dose-strength (titration) curve for GLP-1 medications. - Visible by default — the drug-level estimate is the one the - medication concerns, so it leads; the dose-strength curve plots - the user's own logged dose-change history below it. */} - {medication && isGlp1 && ( -
- - -
- )} - - {importOpen && ( - setImportOpen(false)} - /> - )} +
+
); } diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index c5935ff89..330c672bd 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -1,29 +1,18 @@ "use client"; /** - * v1.7.2 W3 — medication detail page as the history view. + * v1.15.18 — medication detail page as the full-page tabbed shell. * - * Supersedes the v1.5.6 composition that carried an Edit / History / - * Advanced action row and a "Rhythmus" cadence block. Editing and - * advanced settings are now reached from the medications-list card kebab - * only; the detail page is purely history-centric: - * - * back link → compact, NON-editable header (name / dose / status + - * plain-language cadence line) → intake-history table → GLP-1 - * drug-level / titration as a default-CLOSED disclosure. - * - * The side-effect logbook stays inline for GLP-1 medications (genuine - * history context). CSV import rides the intake-history section header. - * - * Reads: - * - * - `medicationDetail(id)` for the medication snapshot. - * - `medicationIntakeList(id, …)` for the header total. - * - * Mutations all cascade through `medicationDependentKeys`. + * Supersedes the v1.7.2 history-centric composition. The page now owns + * auth + the medication read and hands the snapshot to + * ``, which carries the tab strip (Übersicht · + * Zeitplan · Erinnerung · Bestand · Verlauf · Injektion* · Erweitert), + * `?tab=` URL state, the read-only hero and the "Vollständig bearbeiten" + * jump into the wizard. The former modal advanced-settings sheet and the + * separate `/history` route both fold into tabs here. */ -import { use, useEffect, useMemo, useState } from "react"; +import { use, useEffect } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -32,75 +21,13 @@ import { ArrowLeft, Loader2 } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; -import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; -import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; -import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview"; -import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; -import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; - -interface ScheduleSnapshot { - id: string; - windowStart: string; - windowEnd: string; - label: string | null; - dose: string | null; - daysOfWeek: string | null; - timesOfDay?: string[]; - rrule?: string | null; - rollingIntervalDays?: number | null; - reminderGraceMinutes?: number | null; -} - -interface MedicationDetailSnapshot { - id: string; - name: string; - dose: string; - category: string; - treatmentClass?: string; - deliveryForm?: string; - dosesPerUnit?: number | null; - active: boolean; - notificationsEnabled: boolean; - pausedAt: string | null; - startsOn?: string | null; - endsOn?: string | null; - oneShot?: boolean; - schedules: ScheduleSnapshot[]; -} - -function snapshotToWizardPayload( - med: MedicationDetailSnapshot, -): MedicationPayload { - return { - id: med.id, - name: med.name, - dose: med.dose, - category: med.category, - treatmentClass: med.treatmentClass, - deliveryForm: med.deliveryForm, - dosesPerUnit: med.dosesPerUnit ?? null, - notificationsEnabled: med.notificationsEnabled, - startsOn: med.startsOn ? new Date(med.startsOn) : null, - endsOn: med.endsOn ? new Date(med.endsOn) : null, - oneShot: med.oneShot ?? false, - schedules: med.schedules.map((s) => ({ - id: s.id, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - ...parseScheduleRecurrence(s.daysOfWeek), - timesOfDay: s.timesOfDay, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - })), - }; -} +import { + MedicationDetailTabs, + type MedicationDetailSnapshot, +} from "@/components/medications/detail/MedicationDetailTabs"; export default function MedicationDetailPage({ params, @@ -112,8 +39,6 @@ export default function MedicationDetailPage({ const router = useRouter(); const { t } = useTranslations(); - const [importOpen, setImportOpen] = useState(false); - useEffect(() => { if (!authLoading && !isAuthenticated) { router.push("/auth/login"); @@ -139,13 +64,6 @@ export default function MedicationDetailPage({ retry: false, }); - // v1.7.2 W3 — derive the wizard payload purely to feed the read-only - // cadence summary line (the editor itself lives on the card kebab). - const wizardPayload = useMemo( - () => (medication ? snapshotToWizardPayload(medication) : null), - [medication], - ); - if (authLoading || isLoading) { return (

- {t("medications.loadFailed")} + {t("medications.detail.shell.loadFailed")}

); } - const oneShot = medication.oneShot === true; - // `wizardPayload` is non-null here — the early returns above bail - // before this point whenever `medication` is undefined. - const payload = wizardPayload as MedicationPayload; - const isGlp1 = !oneShot && medication.treatmentClass === "GLP1"; - - return ( -
- - - {/* v1.7.2 W3 — compact, NON-editable header. Editing and advanced - settings are reached from the medications-list card kebab only; - the detail page is history-centric. The header is a read-only - summary: name / dose / status + plain-language cadence line. */} - - - {/* Intake history table — the primary surface. */} - - - {/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications - (genuine history context). */} - {isGlp1 && } - - {/* GLP-1 estimated drug-level curve (the modelled active-ingredient - level) + dose-strength (titration) curve. Visible by default for - GLP-1 medications — the drug-level estimate is the one the - medication concerns, so it leads; the dose-strength curve plots - the user's own logged dose-change history below it. */} - {isGlp1 && ( -
- - -
- )} -
- ); + return ; } diff --git a/src/app/medications/__tests__/detail-page-history-surface.test.ts b/src/app/medications/__tests__/detail-page-history-surface.test.ts index e2a127e34..ce95075fd 100644 --- a/src/app/medications/__tests__/detail-page-history-surface.test.ts +++ b/src/app/medications/__tests__/detail-page-history-surface.test.ts @@ -1,19 +1,15 @@ /** - * v1.7.2 W3 — the `/medications/[id]` detail page IS the history view. + * v1.15.18 — the `/medications/[id]` detail page is the tabbed shell. * - * Editing and advanced settings are reached from the medications-list - * card kebab only; the detail page is purely history-centric. Source- - * level guards (the page is a client component with `use()` + hooks, so - * a full render is heavier than the contract warrants): - * - no `TodaysDoseCard` import or render (lives only on the list page); - * - no `isToday` / `todayEvent` derivations (dead once the card is - * gone); - * - no `landingIntent` / `wizardIntent` / `openWizardWithIntent` - * plumbing; - * - the page no longer hosts the wizard, the `AdvancedSettingsSheet`, - * the `MedicationDetailHeader` action row, or an editable cadence - * ("Rhythmus") block — those moved to the card kebab. It renders the - * read-only `MedicationDetailSummary` + the intake-history table. + * The page owns auth + the medication read and delegates the rest to + * `` (the tab strip, `?tab=` URL state, the hero + * and the wizard / advanced affordances). Source-level guards (the page + * is a client component with `use()` + hooks, so a full render is + * heavier than the contract warrants): + * - it renders `MedicationDetailTabs` and nothing tab-specific itself; + * - it carries no `TodaysDoseCard` (lives only on the list page); + * - it hosts neither the wizard nor the modal advanced sheet directly + * (those live inside the tab component). */ import { readFileSync } from "node:fs"; @@ -25,35 +21,18 @@ const source = readFileSync( "utf8", ); -describe("medication detail page is the history view (W3)", () => { +describe("medication detail page is the tabbed shell (v1.15.18)", () => { + it("delegates to MedicationDetailTabs", () => { + expect(source).toContain("MedicationDetailTabs"); + }); + it("does not import or render TodaysDoseCard", () => { expect(source).not.toContain("TodaysDoseCard"); expect(source).not.toContain("todays-dose-card"); }); - it("drops the dead isToday / todayEvent derivations", () => { - expect(source).not.toContain("isToday"); - expect(source).not.toContain("todayEvent"); - }); - - it("drops the landingIntent plumbing", () => { - expect(source).not.toContain("landingIntent"); - expect(source).not.toContain("wizardIntent"); - expect(source).not.toContain("openWizardWithIntent"); - }); - - it("no longer hosts the wizard, advanced sheet, detail-header, or cadence row", () => { - // v1.7.2 W3 — editing + advanced moved to the medications-list card - // kebab; the detail page carries none of those affordances. + it("does not host the wizard or the modal advanced sheet directly", () => { expect(source).not.toContain("AdvancedSettingsSheet"); expect(source).not.toContain("MedicationWizardDialog"); - expect(source).not.toContain("MedicationDetailHeader"); - expect(source).not.toContain("CadenceSummaryRow"); - expect(source).not.toContain("hideEdit"); - }); - - it("renders the read-only summary header + the intake-history table", () => { - expect(source).toContain("MedicationDetailSummary"); - expect(source).toContain("IntakeHistoryPreview"); }); }); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx new file mode 100644 index 000000000..a1809c337 --- /dev/null +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -0,0 +1,559 @@ +"use client"; + +/** + * v1.15.18 — medication detail full-page tabbed shell. + * + * Promotes the detail page from a read-only history view to the + * canonical home for ONE medication. The modal `` + * is dissolved into the Erweitert tab (no more five-card stack in a + * sheet) and the separate `/history` route folds into the Verlauf tab + * (the route now redirects to `?tab=verlauf`). + * + * Tab state lives in the URL (`?tab=`), so a tab is deep-linkable, + * survives reload, and is reachable from the medications-list card kebab + * (Edit→zeitplan, History→verlauf, Advanced→erweitert). Slugs are ASCII / + * locale-independent; the visible labels are i18n. + * + * Only the active tab body mounts (Radix `Tabs` default) so inactive tabs + * do not fetch. The hero stays above the tabs as the always-visible + * read-only summary plus the "Vollständig bearbeiten" jump into the + * creation/edit wizard (the structural-edit tool — name / class / + * cadence kind / schedules). Everyday levers (reminders, supply, history) + * live inline in the tabs. + * + * Verlauf renders the existing editable intake history and Zeitplan the + * read-only cadence summary for now — the dose-history ledger and the + * per-dose window editor land in the next wave. Injektion is gated to + * injectable routes of administration. + */ + +import { useCallback, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ArrowLeft, Pencil } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; +import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; +import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; +import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; +import { InventorySection } from "@/components/medications/sections/inventory-section"; +import { NotificationsBody } from "@/components/medications/sections/notifications-section"; +import { + DrugCodingRow, + GraceRow, + PhasesRow, +} from "@/components/medications/sections/settings-section"; +import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row"; +import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row"; +import { + DangerZoneBody, + LifecycleManageBody, +} from "@/components/medications/sections/destructive-zone-section"; +import { SettingsGroup } from "@/components/medications/settings-group"; +import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet"; +import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; +import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; +import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; +import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; +import { + parseScheduleRecurrence, +} from "@/lib/medication-schedule"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; +import type { ComplianceDisplay } from "@/lib/analytics/compliance"; + +interface ScheduleSnapshot { + id: string; + windowStart: string; + windowEnd: string; + label: string | null; + dose: string | null; + daysOfWeek: string | null; + timesOfDay?: string[]; + rrule?: string | null; + rollingIntervalDays?: number | null; + reminderGraceMinutes?: number | null; +} + +export interface MedicationDetailSnapshot { + id: string; + name: string; + dose: string; + category: string; + treatmentClass?: string; + deliveryForm?: string; + dosesPerUnit?: number | null; + trackInjectionSites?: boolean; + allowedInjectionSites?: string[]; + active: boolean; + notificationsEnabled: boolean; + pausedAt: string | null; + startsOn?: string | null; + endsOn?: string | null; + oneShot?: boolean; + atcCode?: string | null; + rxNormCode?: string | null; + schedules: ScheduleSnapshot[]; +} + +// Slugs are ASCII / locale-independent so the URL stays stable across +// locales; the visible labels resolve through i18n. The list drives both +// the tab strip render and the `?tab=` round-trip validation. +const TAB_SLUGS = [ + "uebersicht", + "zeitplan", + "erinnerung", + "bestand", + "verlauf", + "injektion", + "erweitert", +] as const; +type TabSlug = (typeof TAB_SLUGS)[number]; + +function snapshotToWizardPayload( + med: MedicationDetailSnapshot, +): MedicationPayload { + return { + id: med.id, + name: med.name, + dose: med.dose, + category: med.category, + treatmentClass: med.treatmentClass, + deliveryForm: med.deliveryForm, + dosesPerUnit: med.dosesPerUnit ?? null, + trackInjectionSites: med.trackInjectionSites ?? false, + allowedInjectionSites: med.allowedInjectionSites ?? [], + notificationsEnabled: med.notificationsEnabled, + startsOn: med.startsOn ? new Date(med.startsOn) : null, + endsOn: med.endsOn ? new Date(med.endsOn) : null, + oneShot: med.oneShot ?? false, + schedules: med.schedules.map((s) => ({ + id: s.id, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + ...parseScheduleRecurrence(s.daysOfWeek), + timesOfDay: s.timesOfDay, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + })), + }; +} + +export function MedicationDetailTabs({ + medication, +}: { + medication: MedicationDetailSnapshot; +}) { + const { t } = useTranslations(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [editOpen, setEditOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [phaseSheetOpen, setPhaseSheetOpen] = useState(false); + + const id = medication.id; + const oneShot = medication.oneShot === true; + const isGlp1 = !oneShot && medication.treatmentClass === "GLP1"; + const isInjectable = medication.deliveryForm === "INJECTION"; + + const payload = useMemo( + () => snapshotToWizardPayload(medication), + [medication], + ); + + // Only the active tab body mounts, but the tab REGISTRY must agree with + // whatever `?tab=` carries — Injektion is absent for non-injectable + // medications, so a stale `?tab=injektion` deep-link falls back to the + // landing tab instead of rendering an empty surface. + const availableTabs = useMemo( + () => + TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable), + [isInjectable], + ); + + const requested = searchParams?.get("tab") as TabSlug | null; + const activeTab: TabSlug = + requested && availableTabs.includes(requested) ? requested : "uebersicht"; + + const onTabChange = useCallback( + (next: string) => { + const params = new URLSearchParams( + Array.from(searchParams?.entries() ?? []), + ); + params.set("tab", next); + // Shallow URL swap so the tab is deep-linkable + survives reload + // without a full navigation. `scroll: false` keeps the viewport + // pinned to the tab strip on switch. + router.replace(`/medications/${id}?${params.toString()}`, { + scroll: false, + }); + }, + [id, router, searchParams], + ); + + const { data: compliance } = useQuery({ + queryKey: queryKeys.medicationCompliance(id), + queryFn: async () => { + const res = await fetch(`/api/medications/${id}/compliance`); + if (!res.ok) return null; + return (await res.json()).data as { + compliance7?: { rate: number; streak: number }; + compliance30?: { rate: number }; + complianceDisplay?: ComplianceDisplay; + }; + }, + enabled: activeTab === "uebersicht", + staleTime: 30_000, + }); + + const display = compliance?.complianceDisplay; + const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; + const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; + const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; + const shortDays = display?.shortDays ?? 7; + const longDays = display?.longDays ?? 30; + + return ( +
+ + + {/* HERO — always-visible read-only summary + the structural-edit + jump into the wizard. Everyday levers live in the tabs below. */} +
+ + +
+ + + + {availableTabs.map((slug) => ( + + {t(`medications.detail.tabs.${slug}`)} + + ))} + + + {/* ÜBERSICHT — landing read view: summary echo + compliance. */} + + + {compliance ? ( + + ) : ( +

+ {t("medications.detail.uebersicht.complianceEmpty")} +

+ )} +
+
+ + {/* ZEITPLAN — read-only cadence view for now; the inline times + + per-dose window editor lands in the next wave. */} + + + + + {/* ERINNERUNG — notifications switch + reminder grace (the single + owner of `reminderGraceMinutes`). */} + + +
+ +
+
+ +
+
+
+ + {/* BESTAND — inventory readout for all meds. */} + + + + + {/* VERLAUF — the existing editable intake history (the dose-history + ledger replaces it in the next wave). CSV import rides the + header ghost. */} + + setImportOpen(true)} + className="min-h-11 sm:min-h-9" + data-slot="verlauf-import" + > + {t("medications.detail.intake.importButton")} + + } + > + + + + {importOpen && ( + setImportOpen(false)} + /> + )} + + + {/* INJEKTION — injectable routes only. Current GLP-1 charts + + side-effect log; the body-map + adherence detail land next + wave. */} + {isInjectable && ( + + {isGlp1 && } + {isGlp1 && ( +
+ + +
+ )} + {!isGlp1 && ( + + )} +
+ )} + + {/* ERWEITERT — the dissolved advanced-settings sheet, now grouped + un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */} + + +
+ +
+ {isGlp1 && ( +
+ setPhaseSheetOpen(true)} + /> +
+ )} +
+ + +
+ +
+
+ +
+
+ + +
+ setImportOpen(true)} + /> +
+
+ + + + {importOpen && ( + setImportOpen(false)} + /> + )} +
+
+ + {/* Structural edit — the wizard stays the name / class / cadence-kind + / schedule tool. Hydrates from the medication payload in edit + mode; closing it leaves the user on the same tab. */} + setEditOpen(false)} + /> + + {/* GLP-1 phase/titration editor — sibling-swapped from the Lifecycle + group so the modal stack never exceeds the tab + one sheet. */} + {isGlp1 && ( + + )} +
+ ); +} + +/** + * The danger zone's purge gate needs `intakeCount`. Fetch it lazily here + * (the Erweitert tab pays for it; the other tabs never do) rather than + * threading a count the page doesn't otherwise need. + */ +function ComplianceGatedDangerZone({ + medicationId, + medicationName, +}: { + medicationId: string; + medicationName: string; +}) { + const params = { + sortBy: "takenAt", + sortDir: "desc" as const, + limit: 1, + offset: 0, + status: "completed", + }; + const { data: intakeCount } = useQuery({ + queryKey: queryKeys.medicationIntakeList(medicationId, params), + queryFn: async () => { + const search = new URLSearchParams({ + sortBy: params.sortBy, + sortDir: params.sortDir, + limit: String(params.limit), + offset: String(params.offset), + status: params.status, + }); + const res = await fetch( + `/api/medications/${medicationId}/intake?${search.toString()}`, + ); + if (!res.ok) return 0; + const json = await res.json(); + return (json.data?.meta?.total ?? 0) as number; + }, + staleTime: 30_000, + }); + + const { t } = useTranslations(); + + return ( + +
+ +
+
+ ); +} diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx new file mode 100644 index 000000000..cc296344b --- /dev/null +++ b/src/components/medications/sections/inventory-section.tsx @@ -0,0 +1,130 @@ +"use client"; + +/** + * v1.15.18 — Bestand (supply) tab body. + * + * Read-only inventory readout for the medication detail page's Supply + * tab. Reads `GET /api/medications/[id]/inventory` (the same per-item + * list the pen/vial CRUD route serves) and renders a calm summary — the + * doses remaining across every non-terminal item plus a per-item list + * with its state badge. Shown for ALL medications (pill packs count too); + * the richer pen math is implicit in the per-item doses figures. + * + * No write affordance here yet — registering a pen still rides the + * dedicated inventory route; the tab is the read surface the spec asks + * for. An empty inventory shows a quiet empty state, not a spinner. + */ + +import { useQuery } from "@tanstack/react-query"; +import { Loader2, PackageOpen } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "@/components/ui/empty-state"; +import { SettingsGroup } from "@/components/medications/settings-group"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + +interface InventoryItem { + id: string; + state: InventoryState; + dosesTotal: number; + dosesRemaining: number; +} + +interface InventoryResponse { + items: InventoryItem[]; + meta?: { total: number }; +} + +const STATE_BADGE: Record = { + ACTIVE: "secondary", + IN_USE: "secondary", + EXPIRED: "destructive", + USED_UP: "outline", +}; + +export function InventorySection({ medicationId }: { medicationId: string }) { + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.medicationInventory(medicationId), + queryFn: async () => { + const res = await fetch(`/api/medications/${medicationId}/inventory`); + if (!res.ok) throw new Error("inventory_failed"); + return (await res.json()).data as InventoryResponse; + }, + staleTime: 30_000, + }); + + if (isLoading) { + return ( +
+
+ ); + } + + const items = Array.isArray(data?.items) ? data.items : []; + // Active supply: doses left across every non-terminal (not used-up) item. + const live = items.filter((i) => i.state !== "USED_UP"); + const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0); + const total = live.reduce((sum, i) => sum + i.dosesTotal, 0); + + if (items.length === 0) { + return ( + } + title={t("medications.detail.bestand.empty")} + description={t("medications.detail.bestand.emptyHelper")} + /> + ); + } + + return ( +
+ +
+

+ {t("medications.detail.bestand.summary", { remaining, total })} +

+
+
+ + + {items.map((item) => ( +
+ + {t("medications.detail.bestand.doses", { + remaining: item.dosesRemaining, + total: item.dosesTotal, + })} + + + {t(`medications.detail.bestand.state.${item.state}`)} + +
+ ))} +
+
+ ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 13614b656..98c0d8bec 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -265,6 +265,13 @@ export const queryKeys = { ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => ["medications", medicationId, "glp1-details"] as const, + /** + * v1.15.18 — per-medication pen/vial inventory list, consumed by the + * detail page's Bestand (supply) tab. Rides under the `["medications"]` + * prefix so an intake mutation that decrements a pen invalidates it. + */ + medicationInventory: (medicationId: string) => + ["medications", medicationId, "inventory"] as const, medicationIntakeDrugLevelChart: (medicationId: string) => ["medications", medicationId, "intake", "drug-level-chart"] as const, /**