diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index 0d0d203f7..bd4bd0fbf 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -171,14 +171,15 @@ export default function MedicationsPage() {
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
}
- // v1.15.18 — the card kebab actions now navigate to the full-page
- // tabbed detail surface on the right tab rather than opening a modal
- // editor / sheet. Edit lands on Zeitplan (the everyday schedule view +
- // the hero's "Vollständig bearbeiten" jump into the wizard), History on
- // Verlauf, Advanced on Erweitert (the dissolved advanced-settings).
+ // v1.15.20 — the kebab's Edit opens the medication wizard directly:
+ // it navigates to the detail page with `?edit=1`, which the tabbed
+ // shell reads synchronously to mount the wizard in edit mode (the same
+ // `editOpen` path the hero's "Vollständig bearbeiten" uses). History
+ // still lands on the Verlauf tab; the former Advanced item is retired —
+ // the Erweitert tab stays reachable through the card body.
function openEdit(med: Medication) {
seedDetail(med);
- router.push(`/medications/${med.id}?tab=zeitplan`);
+ router.push(`/medications/${med.id}?edit=1`);
}
function openHistory(med: Medication) {
@@ -186,11 +187,6 @@ export default function MedicationsPage() {
router.push(`/medications/${med.id}?tab=verlauf`);
}
- function openAdvanced(med: Medication) {
- seedDetail(med);
- router.push(`/medications/${med.id}?tab=erweitert`);
- }
-
function closeDialog() {
setDialogOpen(false);
}
@@ -327,7 +323,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
- onOpenAdvanced={openAdvanced}
/>
) : (
),
)}
@@ -357,7 +351,6 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
- onOpenAdvanced={openAdvanced}
/>
) : (
),
)}
diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx
index 7a9bfa811..bcf14ff3a 100644
--- a/src/components/medications/SideEffectsSection.tsx
+++ b/src/components/medications/SideEffectsSection.tsx
@@ -262,7 +262,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
titleId="side-effects-heading"
title={t("medications.sideEffects.section")}
headerExtras={headerExtras}
- bodyPaddingY="py-2.5"
>
{isLoading && (
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index 07f351996..044ab318b 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -349,7 +349,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -358,7 +357,6 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx
index d07c69d14..fb5517007 100644
--- a/src/components/medications/__tests__/medication-card-glp1.test.tsx
+++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx
@@ -194,7 +194,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -208,26 +207,24 @@ describe(" — GLP-1 variant rendering", () => {
expect(html).not.toMatch(/lucide-syringe/i);
});
- it("collapses edit / history / advanced into a single overflow kebab + navigable header", () => {
- // v1.7.2 W3 — the four former header icon-buttons collapse into one
+ it("collapses edit / history into a single overflow kebab + navigable header", () => {
+ // v1.7.2 W3 — the former header icon-buttons collapse into one
// kebab; the card header links to the detail page. The menu items
- // (edit / history / advanced) render inside the portalled dropdown
- // content, so SSR markup carries only the kebab trigger + the header
- // link, not the individual action buttons.
+ // (edit / history) render inside the portalled dropdown content, so
+ // SSR markup carries only the kebab trigger + the header link, not
+ // the individual action buttons. v1.15.20 retires the Advanced item.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {});
const onEdit = vi.fn();
const onOpenHistory = vi.fn();
- const onOpenAdvanced = vi.fn();
const html = render(
,
client,
);
@@ -240,13 +237,13 @@ describe(" — GLP-1 variant rendering", () => {
// Smoke-check the handler contract — SSR can't fire DOM events, so
// invoke the handlers the way each menu item's onClick would and pin
- // the medication-object payload the parent routes / sheets against.
+ // the medication-object payload the parent routes against.
onOpenHistory(med7p5);
- onOpenAdvanced(med7p5);
+ onEdit(med7p5);
expect(onOpenHistory).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
- expect(onOpenAdvanced).toHaveBeenCalledWith(
+ expect(onEdit).toHaveBeenCalledWith(
expect.objectContaining({ id: "med-glp1-1", treatmentClass: "GLP1" }),
);
});
@@ -264,7 +261,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={defaultMed}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -288,7 +284,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -315,7 +310,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -364,7 +358,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -397,7 +390,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -428,7 +420,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -454,7 +445,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -479,7 +469,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
onLogSideEffect={handler}
/>,
client,
@@ -516,7 +505,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -540,7 +528,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -565,7 +552,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={paused}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -598,7 +584,6 @@ describe(" — GLP-1 variant rendering", () => {
medication={med7p5}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
"de",
diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx
index d37d5ae2b..d6b5cbd96 100644
--- a/src/components/medications/__tests__/medication-card-next-due.test.tsx
+++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx
@@ -101,7 +101,6 @@ describe(" — next-due reads from server nextDueAt", () => {
medication={rollingMed}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -151,7 +150,6 @@ describe(" — next-due reads from server nextDueAt", () => {
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -206,7 +204,6 @@ describe(" — next-due reads from server nextDueAt", () => {
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx
index d7040d59d..12c6af0b4 100644
--- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx
+++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx
@@ -153,7 +153,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -162,7 +161,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -197,7 +195,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -219,7 +216,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -228,7 +224,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -252,7 +247,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -261,7 +255,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -285,7 +278,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramipril}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -294,7 +286,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaro}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
onLogSideEffect={() => {}}
/>,
client,
@@ -364,7 +355,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramiprilFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -373,7 +363,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaroFuture}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -426,7 +415,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={ramiprilBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -435,7 +423,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={mounjaroBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
);
@@ -520,7 +507,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={oral}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
),
@@ -529,7 +515,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={prn}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
),
@@ -538,7 +523,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={oneShot}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
),
@@ -547,7 +531,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
medication={glp1}
onEdit={() => {}}
onOpenHistory={() => {}}
- onOpenAdvanced={() => {}}
/>,
client,
),
diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts
new file mode 100644
index 000000000..bb08eb09b
--- /dev/null
+++ b/src/components/medications/__tests__/supply-runway.test.ts
@@ -0,0 +1,108 @@
+/**
+ * v1.15.20 — supply-runway estimate behind the detail Übersicht's
+ * "lasts about N more days" line. Pure helpers, no render needed.
+ */
+
+import { describe, it, expect } from "vitest";
+
+import {
+ estimateDailyDoseCount,
+ estimateRunwayDays,
+ type RunwaySchedule,
+} from "@/components/medications/detail/supply-runway";
+
+function schedule(partial: Partial): RunwaySchedule {
+ return {
+ windowStart: "08:00",
+ daysOfWeek: null,
+ ...partial,
+ };
+}
+
+describe("estimateDailyDoseCount", () => {
+ it("counts a plain daily schedule as its times-of-day per day", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["08:00", "20:00"] }),
+ ]),
+ ).toBe(2);
+ });
+
+ it("falls back to one dose per day when timesOfDay is absent", () => {
+ expect(estimateDailyDoseCount([schedule({})])).toBe(1);
+ });
+
+ it("scales a rolling interval down (one dose every N days)", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
+ ]),
+ ).toBeCloseTo(1 / 7);
+ });
+
+ it("scales weekday picks against the 7-day week", () => {
+ // Mon/Wed/Fri → 3 doses per 7 days.
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["08:00"], daysOfWeek: "1,3,5" }),
+ ]),
+ ).toBeCloseTo(3 / 7);
+ });
+
+ it("honours the encoded interval-weeks cadence", () => {
+ // Every 2nd week on one weekday → 1 dose per 14 days.
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["08:00"], daysOfWeek: "i2;1" }),
+ ]),
+ ).toBeCloseTo(1 / 14);
+ });
+
+ it("approximates a monthly RRULE at one dose per 30 days", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["08:00"], rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }),
+ ]),
+ ).toBeCloseTo(1 / 30);
+ });
+
+ it("sums across multiple schedules", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ timesOfDay: ["08:00"] }),
+ schedule({ timesOfDay: ["20:00"] }),
+ ]),
+ ).toBe(2);
+ });
+});
+
+describe("estimateRunwayDays", () => {
+ it("divides the remaining doses by the daily consumption", () => {
+ expect(
+ estimateRunwayDays(14, [schedule({ timesOfDay: ["08:00", "20:00"] })]),
+ ).toBe(7);
+ });
+
+ it("floors to whole days", () => {
+ expect(estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })])).toBe(
+ 2,
+ );
+ });
+
+ it("returns null when no supply remains", () => {
+ expect(estimateRunwayDays(0, [schedule({})])).toBeNull();
+ });
+
+ it("returns null when no schedule consumes doses", () => {
+ expect(estimateRunwayDays(10, [])).toBeNull();
+ });
+
+ it("covers the weekly injection case (rolling 7-day pen)", () => {
+ // 4 doses left on a once-a-week injection → ~28 days.
+ expect(
+ estimateRunwayDays(4, [
+ schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
+ ]),
+ ).toBe(28);
+ });
+});
diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx
index aed58d3e7..886b52945 100644
--- a/src/components/medications/detail/MedicationDetailTabs.tsx
+++ b/src/components/medications/detail/MedicationDetailTabs.tsx
@@ -11,24 +11,35 @@
*
* 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 /
+ * (History→verlauf). The kebab's Edit arrives as `?edit=1`, which opens
+ * the wizard in edit mode on top of the landing tab. Slugs are ASCII /
* locale-independent; the visible labels are i18n.
*
+ * v1.15.20 — six tabs: the former Erinnerung tab dissolved into Zeitplan
+ * (a "Erinnerung" card under the times editor owns the notifications
+ * switch + the reminder grace), and Übersicht grew from a lone
+ * compliance bar into the status landing surface (next due dose,
+ * reminder state, supply runway — each with a jump link into its owner
+ * tab). Stale `?tab=erinnerung` deep-links land on Zeitplan.
+ *
* 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.
+ * live inline in the tabs. Injektion is gated to injectable routes of
+ * administration.
*/
-import { useCallback, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import Link from "next/link";
+import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Pencil } from "lucide-react";
@@ -56,19 +67,37 @@ import {
} 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 { ChartSkeleton } from "@/components/charts/chart-skeleton";
+import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
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 { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
+// v1.15.20 — Recharts stays out of the detail page's initial bundle:
+// both chart bodies load through `next/dynamic` (client-only) with the
+// shared chart skeleton standing in while the chunk streams.
+const DrugLevelChart = dynamic(
+ () =>
+ import("@/components/medications/DrugLevelChart").then((mod) => ({
+ default: mod.DrugLevelChart,
+ })),
+ { ssr: false, loading: () => },
+);
+const DoseStrengthCurve = dynamic(
+ () =>
+ import("@/components/medications/dose-strength-curve").then((mod) => ({
+ default: mod.DoseStrengthCurve,
+ })),
+ { ssr: false, loading: () => },
+);
+
interface ScheduleSnapshot {
id: string;
windowStart: string;
@@ -106,6 +135,12 @@ export interface MedicationDetailSnapshot {
oneShot?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
+ /**
+ * v1.15.20 — server-computed next due instant (the detail GET carries
+ * it alongside the row). The Übersicht status surface renders it
+ * directly; no client-side recurrence walking.
+ */
+ nextDueAt?: string | null;
schedules: ScheduleSnapshot[];
}
@@ -115,7 +150,6 @@ export interface MedicationDetailSnapshot {
const TAB_SLUGS = [
"uebersicht",
"zeitplan",
- "erinnerung",
"bestand",
"verlauf",
"injektion",
@@ -123,6 +157,16 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
+/**
+ * Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
+ * into Zeitplan in v1.15.20; old deep-links keep landing on the surface
+ * that now owns the reminder controls instead of falling back to the
+ * landing tab.
+ */
+const LEGACY_TAB_SLUGS: Record = {
+ erinnerung: "zeitplan",
+};
+
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
@@ -161,10 +205,17 @@ export function MedicationDetailTabs({
medication: MedicationDetailSnapshot;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const router = useRouter();
const searchParams = useSearchParams();
- const [editOpen, setEditOpen] = useState(false);
+ // v1.15.20 — the medications-list kebab's "Bearbeiten" arrives as
+ // `?edit=1` and opens the wizard straight away (the same editOpen the
+ // hero button drives). Read synchronously so the wizard mounts open on
+ // the first render; the effect below strips the param so a close +
+ // reload stays closed.
+ const shouldOpenEditFromUrl = searchParams?.get("edit") === "1";
+ const [editOpen, setEditOpen] = useState(shouldOpenEditFromUrl);
const [importOpen, setImportOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
@@ -188,10 +239,30 @@ export function MedicationDetailTabs({
[isInjectable],
);
- const requested = searchParams?.get("tab") as TabSlug | null;
+ const requestedRaw = searchParams?.get("tab");
+ const requested = (
+ requestedRaw && requestedRaw in LEGACY_TAB_SLUGS
+ ? LEGACY_TAB_SLUGS[requestedRaw]
+ : requestedRaw
+ ) as TabSlug | null;
const activeTab: TabSlug =
requested && availableTabs.includes(requested) ? requested : "uebersicht";
+ useEffect(() => {
+ if (shouldOpenEditFromUrl) {
+ // Drop `?edit=1` (keep any `tab` param) so a refresh after closing
+ // the wizard does not keep reopening it.
+ const params = new URLSearchParams(
+ Array.from(searchParams?.entries() ?? []),
+ );
+ params.delete("edit");
+ const qs = params.toString();
+ router.replace(`/medications/${id}${qs ? `?${qs}` : ""}`, {
+ scroll: false,
+ });
+ }
+ }, [shouldOpenEditFromUrl, id, router, searchParams]);
+
const onTabChange = useCallback(
(next: string) => {
const params = new URLSearchParams(
@@ -230,6 +301,38 @@ export function MedicationDetailTabs({
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
+ // v1.15.20 — supply readout for the Übersicht status surface. Same
+ // query key the Bestand tab uses, so the two tabs share one cache slot
+ // and the row only renders when the user has actually registered
+ // supply items.
+ const { data: inventory } = useQuery({
+ queryKey: queryKeys.medicationInventory(id),
+ queryFn: async () => {
+ const res = await fetch(`/api/medications/${id}/inventory`);
+ if (!res.ok) return null;
+ return (await res.json()).data as {
+ items: Array<{
+ state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
+ dosesTotal: number;
+ dosesRemaining: number;
+ }>;
+ } | null;
+ },
+ enabled: activeTab === "uebersicht",
+ staleTime: 30_000,
+ });
+
+ const inventoryItems = Array.isArray(inventory?.items)
+ ? inventory.items
+ : [];
+ const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
+ const dosesRemaining = liveItems.reduce(
+ (sum, i) => sum + i.dosesRemaining,
+ 0,
+ );
+ const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0);
+ const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
+
return (