mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): promote the detail page to a tabbed shell
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.
This commit is contained in:
1 parent
6398cdece5
commit
ac39b9ca34
12 files changed
+986
-324
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
* `<IntakeHistoryEditable>`); 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 (
|
||||
<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 isGlp1 = medication?.treatmentClass === "GLP1";
|
||||
router.replace(`/medications/${id}?tab=verlauf`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back to the medication detail page — history is a drill-down
|
||||
OF the medication, not a sibling of the list. */}
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href={`/medications/${id}`}>
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.detail.history.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{medication && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{medication.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{medication.dose}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.history.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* De-emphasised import — ghost / muted, not a prominent CTA. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="text-muted-foreground min-h-11 sm:min-h-9"
|
||||
data-slot="history-import-trigger"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{medication && (
|
||||
<IntakeHistoryEditable
|
||||
medicationId={id}
|
||||
pageSize={25}
|
||||
defaultSortBy="scheduledFor"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="space-y-6" data-slot="history-drug-level-section">
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={id}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
* `<MedicationDetailTabs>`, 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<MedicationPayload | null>(
|
||||
() => (medication ? snapshotToWizardPayload(medication) : null),
|
||||
[medication],
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -178,70 +96,12 @@ export default function MedicationDetailPage({
|
||||
data-slot="medication-detail-error-card"
|
||||
>
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
{t("medications.detail.shell.loadFailed")}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* 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. */}
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
|
||||
{/* Intake history table — the primary surface. */}
|
||||
<IntakeHistoryPreview
|
||||
medicationId={id}
|
||||
importOpen={importOpen}
|
||||
onImportOpenChange={setImportOpen}
|
||||
/>
|
||||
|
||||
{/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications
|
||||
(genuine history context). */}
|
||||
{isGlp1 && <SideEffectsSection medicationId={id} />}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="space-y-6" data-slot="detail-drug-level-section">
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <MedicationDetailTabs medication={medication} />;
|
||||
}
|
||||
@@ -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
|
||||
* `<MedicationDetailTabs>` (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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<AdvancedSettingsSheet>`
|
||||
* 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=<slug>`), 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<TabSlug[]>(
|
||||
() =>
|
||||
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 (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* HERO — always-visible read-only summary + the structural-edit
|
||||
jump into the wizard. Everyday levers live in the tabs below. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="medication-detail-full-edit"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("medications.detail.shell.fullEdit")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange}
|
||||
data-slot="medication-detail-tabs"
|
||||
>
|
||||
<TabsList
|
||||
className="w-full justify-start"
|
||||
aria-label={t("medications.detail.shell.tablistLabel")}
|
||||
>
|
||||
{availableTabs.map((slug) => (
|
||||
<TabsTrigger
|
||||
key={slug}
|
||||
value={slug}
|
||||
data-slot={`medication-tab-${slug}`}
|
||||
>
|
||||
{t(`medications.detail.tabs.${slug}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* ÜBERSICHT — landing read view: summary echo + compliance. */}
|
||||
<TabsContent value="uebersicht" className="space-y-6 pt-2">
|
||||
<MedicationDetailSection
|
||||
titleId="medication-uebersicht-compliance-heading"
|
||||
title={t("medications.detail.uebersicht.complianceTitle")}
|
||||
dataSlot="medication-uebersicht-compliance"
|
||||
>
|
||||
{compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.uebersicht.complianceEmpty")}
|
||||
</p>
|
||||
)}
|
||||
</MedicationDetailSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* ZEITPLAN — read-only cadence view for now; the inline times +
|
||||
per-dose window editor lands in the next wave. */}
|
||||
<TabsContent value="zeitplan" className="space-y-6 pt-2">
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ERINNERUNG — notifications switch + reminder grace (the single
|
||||
owner of `reminderGraceMinutes`). */}
|
||||
<TabsContent value="erinnerung" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.reminders")}
|
||||
dataSlot="erinnerung-group"
|
||||
>
|
||||
<div className="py-3">
|
||||
<NotificationsBody
|
||||
medicationId={id}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<GraceRow
|
||||
medicationId={id}
|
||||
reminderGraceMinutes={
|
||||
medication.schedules[0]?.reminderGraceMinutes ?? null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</TabsContent>
|
||||
|
||||
{/* BESTAND — inventory readout for all meds. */}
|
||||
<TabsContent value="bestand" className="space-y-4 pt-2">
|
||||
<InventorySection medicationId={id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* VERLAUF — the existing editable intake history (the dose-history
|
||||
ledger replaces it in the next wave). CSV import rides the
|
||||
header ghost. */}
|
||||
<TabsContent value="verlauf" className="space-y-6 pt-2">
|
||||
<MedicationDetailSection
|
||||
titleId="medication-verlauf-heading"
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-verlauf-section"
|
||||
headerExtras={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="verlauf-import"
|
||||
>
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<IntakeHistoryEditable
|
||||
medicationId={id}
|
||||
pageSize={25}
|
||||
defaultSortBy="scheduledFor"
|
||||
/>
|
||||
</MedicationDetailSection>
|
||||
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={id}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* INJEKTION — injectable routes only. Current GLP-1 charts +
|
||||
side-effect log; the body-map + adherence detail land next
|
||||
wave. */}
|
||||
{isInjectable && (
|
||||
<TabsContent value="injektion" className="space-y-6 pt-2">
|
||||
{isGlp1 && <SideEffectsSection medicationId={id} />}
|
||||
{isGlp1 && (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="injektion-drug-level-section"
|
||||
>
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
</div>
|
||||
)}
|
||||
{!isGlp1 && (
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
|
||||
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
|
||||
<TabsContent value="erweitert" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
active={medication.active}
|
||||
/>
|
||||
</div>
|
||||
{isGlp1 && (
|
||||
<div className="py-3">
|
||||
<PhasesRow
|
||||
medicationId={id}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.externalApi")}
|
||||
dataSlot="erweitert-group-external-api"
|
||||
>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<DrugCodingRow
|
||||
medicationId={id}
|
||||
atcCode={medication.atcCode}
|
||||
rxNormCode={medication.rxNormCode}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.data")}
|
||||
dataSlot="erweitert-group-data"
|
||||
>
|
||||
<div className="py-3">
|
||||
<DataPortabilityRow
|
||||
medicationId={id}
|
||||
onOpenImport={() => setImportOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<ComplianceGatedDangerZone
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
/>
|
||||
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={id}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 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. */}
|
||||
<MedicationWizardDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
mode="edit"
|
||||
initial={payload}
|
||||
onSuccess={() => setEditOpen(false)}
|
||||
/>
|
||||
|
||||
{/* GLP-1 phase/titration editor — sibling-swapped from the Lifecycle
|
||||
group so the modal stack never exceeds the tab + one sheet. */}
|
||||
{isGlp1 && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={id}
|
||||
open={phaseSheetOpen}
|
||||
onOpenChange={setPhaseSheetOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.danger")}
|
||||
dataSlot="erweitert-group-danger"
|
||||
className="border-destructive/40 bg-destructive/5"
|
||||
>
|
||||
<div className="py-3">
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -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<InventoryState, "secondary" | "outline" | "destructive"> = {
|
||||
ACTIVE: "secondary",
|
||||
IN_USE: "secondary",
|
||||
EXPIRED: "destructive",
|
||||
USED_UP: "outline",
|
||||
};
|
||||
|
||||
export function InventorySection({ medicationId }: { medicationId: string }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery<InventoryResponse>({
|
||||
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 (
|
||||
<div
|
||||
className="flex h-32 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EmptyState
|
||||
icon={<PackageOpen className="size-6" />}
|
||||
title={t("medications.detail.bestand.empty")}
|
||||
description={t("medications.detail.bestand.emptyHelper")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="medication-inventory-section">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.bestand.title")}
|
||||
dataSlot="inventory-summary-group"
|
||||
>
|
||||
<div className="py-3">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.bestand.summary", { remaining, total })}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.bestand.itemsTitle")}
|
||||
dataSlot="inventory-items-group"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 py-3"
|
||||
data-slot="inventory-item-row"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: item.dosesRemaining,
|
||||
total: item.dosesTotal,
|
||||
})}
|
||||
</span>
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user