chore(medications): drop the Bestand section from GLP-1 detail

Retire the entire inventory surface from the GLP-1 medication card:
the inline "pens left · weeks of supply" summary line, the disclosure
that wrapped the per-pen list, and the InventorySection component
file (no other consumer). The orphan i18n keys under
medications.inventory.* and the glp1Inventory* labels retire alongside
the help-text revision for glp1DosesPerUnitHelp. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays byte-stable
in the response shape, and the server-side state-machine helpers
remain for that endpoint's computed weeksOfSupply.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:33:50 +02:00
1 parent 8c81af10d6
commit 8c8d6dc27f
10 files changed
+29 -779

No files matched your search

+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Letzten gewählten Wochentag übernehmen",
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — setzt die Reichweite-Anzeige im Dashboard frei. Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, um die Bestandsverfolgung auszuschalten.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
"glp1LastInjection": "Letzter Termin: {label}",
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
"glp1RotationLast": "Letzte Stelle: {site}",
"glp1RotationSuggested": "Empfohlen: {site}",
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
"glp1LogSideEffect": "Nebenwirkung loggen",
"inventory": {
"title": "Bestand",
"addPen": "Neuen Pen erfassen",
"addPenTitle": "Neuen Pen erfassen",
"loading": "Bestand wird geladen…",
"empty": "Noch keine Pens erfasst. Erfasse den nächsten, um die 30-Tage-Frist zu starten.",
"dosesLabel": "Dosen",
"printedExpiry": "Verfallsdatum auf der Schachtel: {date}",
"purchasedAt": "Gekauft am {date}",
"pastTitle": "Frühere Pens ({count})",
"markAsInUse": "Als angebrochen markieren",
"markAsUsedUp": "Als verbraucht markieren",
"delete": "Löschen",
"cancel": "Abbrechen",
"save": "Speichern",
"fieldDosesTotal": "Dosen pro Pen",
"fieldDosesTotalHelp": "Anzahl der Dosen, die dieser Pen enthält. Mounjaro-KwikPen = 4, Trulicity Single-Dose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Aufgedrucktes Verfallsdatum",
"fieldPurchasedAt": "Kaufdatum",
"fieldNotes": "Notiz (optional)",
"errorDosesTotal": "Dosen pro Pen müssen zwischen 1 und 100 liegen",
"state": {
"active": "Im Kühlschrank",
"inUse": "In Verwendung · {days} Tage übrig",
"expired": "Abgelaufen",
"usedUp": "Verbraucht"
}
},
"sideEffects": {
"section": "Nebenwirkungen",
"addCta": "Nebenwirkung erfassen",
+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventory",
"addPen": "Add new pen",
"addPenTitle": "Register new pen",
"loading": "Loading inventory…",
"empty": "No pens tracked yet. Add the next one to start the 30-day in-use clock.",
"dosesLabel": "doses",
"printedExpiry": "Carton expiry {date}",
"purchasedAt": "Bought {date}",
"pastTitle": "Past pens ({count})",
"markAsInUse": "Mark in use",
"markAsUsedUp": "Mark used up",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"fieldDosesTotal": "Doses per pen",
"fieldDosesTotalHelp": "Number of doses this pen ships with. Mounjaro KwikPen = 4, Trulicity single-dose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Printed expiry date",
"fieldPurchasedAt": "Date purchased",
"fieldNotes": "Notes (optional)",
"errorDosesTotal": "Doses per pen must be between 1 and 100",
"state": {
"active": "Refrigerated",
"inUse": "In use · {days} days left",
"expired": "Expired",
"usedUp": "Used up"
}
},
"sideEffects": {
"section": "Side effects",
"addCta": "Log side effect",
+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Siguiente:{label} (in {days} days)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventario",
"addPen": "Añadir nueva pluma",
"addPenTitle": "Registrar nueva pluma",
"loading": "Cargando inventario…",
"empty": "Aún no hay plumas registradas. Añade la siguiente para iniciar el contador de 30 días.",
"dosesLabel": "dosis",
"printedExpiry": "Caducidad de la caja: {date}",
"purchasedAt": "Comprado el {date}",
"pastTitle": "Plumas anteriores ({count})",
"markAsInUse": "Marcar como abierta",
"markAsUsedUp": "Marcar como agotada",
"delete": "Eliminar",
"cancel": "Cancelar",
"save": "Guardar",
"fieldDosesTotal": "Dosis por pluma",
"fieldDosesTotalHelp": "Número de dosis que contiene la pluma. KwikPen de Mounjaro = 4, Trulicity dosis única = 1, Byetta = 60.",
"fieldPrintedExpiry": "Fecha de caducidad impresa",
"fieldPurchasedAt": "Fecha de compra",
"fieldNotes": "Nota (opcional)",
"errorDosesTotal": "Las dosis por pluma deben estar entre 1 y 100",
"state": {
"active": "Refrigerada",
"inUse": "En uso · {days} días restantes",
"expired": "Caducada",
"usedUp": "Agotada"
}
},
"sideEffects": {
"section": "Efectos secundarios",
"addCta": "Registrar efecto",
+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Suivant:{label} (in {days} days)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventaire",
"addPen": "Ajouter un nouveau stylo",
"addPenTitle": "Enregistrer un nouveau stylo",
"loading": "Chargement de l'inventaire…",
"empty": "Aucun stylo suivi. Ajoutez le prochain pour démarrer le compte à rebours de 30 jours.",
"dosesLabel": "doses",
"printedExpiry": "Péremption sur la boîte : {date}",
"purchasedAt": "Acheté le {date}",
"pastTitle": "Anciens stylos ({count})",
"markAsInUse": "Marquer comme entamé",
"markAsUsedUp": "Marquer comme épuisé",
"delete": "Supprimer",
"cancel": "Annuler",
"save": "Enregistrer",
"fieldDosesTotal": "Doses par stylo",
"fieldDosesTotalHelp": "Nombre de doses contenues dans ce stylo. KwikPen Mounjaro = 4, Trulicity dose unique = 1, Byetta = 60.",
"fieldPrintedExpiry": "Date de péremption imprimée",
"fieldPurchasedAt": "Date d'achat",
"fieldNotes": "Note (facultatif)",
"errorDosesTotal": "Le nombre de doses par stylo doit être compris entre 1 et 100",
"state": {
"active": "Au réfrigérateur",
"inUse": "En cours · {days} jours restants",
"expired": "Périmé",
"usedUp": "Épuisé"
}
},
"sideEffects": {
"section": "Effets secondaires",
"addCta": "Consigner un effet",
+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Avanti:{label} (in {days} days)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventario",
"addPen": "Aggiungi nuova penna",
"addPenTitle": "Registra nuova penna",
"loading": "Caricamento inventario…",
"empty": "Nessuna penna registrata. Aggiungi la prossima per avviare il conto alla rovescia di 30 giorni.",
"dosesLabel": "dosi",
"printedExpiry": "Scadenza sulla scatola: {date}",
"purchasedAt": "Acquistata il {date}",
"pastTitle": "Penne precedenti ({count})",
"markAsInUse": "Segna come in uso",
"markAsUsedUp": "Segna come esaurita",
"delete": "Elimina",
"cancel": "Annulla",
"save": "Salva",
"fieldDosesTotal": "Dosi per penna",
"fieldDosesTotalHelp": "Numero di dosi contenute nella penna. KwikPen Mounjaro = 4, Trulicity monodose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Data di scadenza stampata",
"fieldPurchasedAt": "Data di acquisto",
"fieldNotes": "Nota (facoltativo)",
"errorDosesTotal": "Le dosi per penna devono essere comprese tra 1 e 100",
"state": {
"active": "In frigorifero",
"inUse": "In uso · {days} giorni rimasti",
"expired": "Scaduta",
"usedUp": "Esaurita"
}
},
"sideEffects": {
"section": "Effetti collaterali",
"addCta": "Registra effetto",
+1 -31
View File
@@ -486,7 +486,7 @@
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjectionDays": "Dalej:{label} (in {days} days)",
@@ -494,37 +494,7 @@
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Magazyn",
"addPen": "Dodaj nowy wstrzykiwacz",
"addPenTitle": "Zarejestruj nowy wstrzykiwacz",
"loading": "Ładowanie magazynu…",
"empty": "Brak wstrzykiwaczy w magazynie. Dodaj następny, aby uruchomić 30-dniowy licznik.",
"dosesLabel": "dawki",
"printedExpiry": "Data ważności na opakowaniu: {date}",
"purchasedAt": "Zakupiono {date}",
"pastTitle": "Wcześniejsze wstrzykiwacze ({count})",
"markAsInUse": "Oznacz jako otwarty",
"markAsUsedUp": "Oznacz jako zużyty",
"delete": "Usuń",
"cancel": "Anuluj",
"save": "Zapisz",
"fieldDosesTotal": "Dawki na wstrzykiwacz",
"fieldDosesTotalHelp": "Liczba dawek w jednym wstrzykiwaczu. Mounjaro KwikPen = 4, Trulicity jednodawkowy = 1, Byetta = 60.",
"fieldPrintedExpiry": "Nadrukowana data ważności",
"fieldPurchasedAt": "Data zakupu",
"fieldNotes": "Notatka (opcjonalnie)",
"errorDosesTotal": "Liczba dawek na wstrzykiwacz musi mieścić się w zakresie 1–100",
"state": {
"active": "W lodówce",
"inUse": "W użyciu · pozostało {days} dni",
"expired": "Wygasł",
"usedUp": "Zużyty"
}
},
"sideEffects": {
"section": "Skutki uboczne",
"addCta": "Dodaj skutek uboczny",
@@ -308,7 +308,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).toContain("Abdomen, lower left");
});
it("renders the pen-inventory line when inventory data is present", () => {
it("no longer renders the pen-inventory line (retired v1.4.28)", () => {
// The maintainer retired the entire Bestand / inventory surface
// on the GLP-1 card in v1.4.28. The iOS-consumed Glp1InventoryDTO
// slot stays in the response shape, but the web card never paints
// the inline pens-remaining line or the low-stock badge any more.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -325,14 +329,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The inventory copy template is "{pens} pens left · ~{weeks} weeks of supply".
expect(html).toContain("2 pens left");
expect(html).toContain("8 weeks of supply");
// Low-stock badge omitted when lowStock=false.
expect(html).not.toContain("pens left");
expect(html).not.toContain("Low stock");
});
it("renders the low-stock badge when inventory.lowStock is true", () => {
it("does not render the low-stock badge even when inventory.lowStock is true (retired v1.4.28)", () => {
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -349,8 +350,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
expect(html).toContain("1 pens left");
expect(html).toContain("Low stock");
expect(html).not.toContain("pens left");
expect(html).not.toContain("Low stock");
});
it("side-effect quick-log button hands off the medication object", () => {
@@ -26,16 +26,15 @@ import {
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { InventorySection } from "@/components/medications/inventory-section";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
*
* Marc directive 2026-05-14: NO chart on the medication card. The card
* stays text-rich (drug name + current dose, last/next injection,
* injection-site rotation hint, pen inventory, side-effect quick-log).
* v1.4.28 retired the inline dose-history disclosure. Chart-only
* surfaces are the Dashboard tile + Insights /medikamente sub-page.
* injection-site rotation hint, side-effect quick-log). v1.4.28
* retired the inline dose-history disclosure and the inventory
* surface. Chart-only surfaces are the Dashboard tile + Insights
* /medikamente sub-page.
*
* Renders inside the standard medications grid alongside generic
* medication cards; visually mirrors `medication-card.tsx` so the page
@@ -219,8 +218,6 @@ export function Glp1MedicationCard({
.filter(Boolean) as InjectionSiteKey[],
);
const inventory = details?.inventory ?? null;
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
const d = new Date(medication.lastTakenAt);
@@ -337,31 +334,11 @@ export function Glp1MedicationCard({
</div>
)}
{/* Inventory line — only when dosesPerUnit + at least one
inventory event recorded. Low-stock gets a warning badge. */}
{inventory && inventory.pensRemaining !== null && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{t("medications.glp1Inventory", {
pens: inventory.pensRemaining,
weeks: inventory.weeksOfSupply ?? "—",
})}
</span>
{inventory.lowStock && (
<Badge variant="destructive" className="text-[10px]">
{t("medications.glp1InventoryLow")}
</Badge>
)}
</div>
)}
{/* v1.4.25 W19b — per-pen / per-vial inventory disclosure.
Lazy-loads the inventory list on open; otherwise stays
collapsed so the card height matches generic cards. */}
<InventorySection
medicationId={medication.id}
defaultDosesPerUnit={medication.dosesPerUnit ?? null}
/>
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
the GLP-1 card: both the inline pens-remaining summary line
and the per-pen disclosure are gone. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — identical to the generic card so the page
grid stays harmonious. */}
@@ -1,549 +0,0 @@
"use client";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MedicationInventoryState } from "@/generated/prisma/client";
import {
DEFAULT_IN_USE_WINDOW_DAYS,
daysRemainingInUse,
} from "@/lib/medications/inventory/state-machine";
/**
* v1.4.25 W19b — Inventory disclosure for the GLP-1 medication card.
*
* Sits inline in the GLP-1 card as a collapsible section. Shows the
* active and recently-used pens, surfaces the 30-day in-use clock per
* pen, and offers an "Add new pen" dialog for `purchasedAt` +
* `printedExpiry` + `dosesTotal`. Per-pen actions are
* mark-as-in-use (manual override when an intake didn't auto-flip
* the state) and mark-as-used-up (terminal override when a pen is
* physically discarded).
*
* The UI deliberately reuses existing primitives — Card, Dialog,
* Input, Badge, Button — no new shadcn block.
*
* v1.4.25 W21 Fix-N
* - The local `InventoryState` string-union has been dropped in
* favour of the canonical `MedicationInventoryState` enum from
* the Prisma client.
* - The reimplemented client-side `daysRemainingInUse` has been
* replaced by the widened pure helper from the inventory
* state-machine module; one rule for the deadline math.
*/
interface InventoryItem {
id: string;
state: MedicationInventoryState;
dosesTotal: number;
dosesRemaining: number;
firstUseAt: string | null;
expiresAt: string | null;
printedExpiry: string | null;
purchasedAt: string | null;
notes: string | null;
createdAt: string;
}
interface InventorySectionProps {
medicationId: string;
/** Doses per pen — passed in so the dialog can pre-fill the input
* with the knowledge-layer value (e.g. 4 for a Mounjaro KwikPen). */
defaultDosesPerUnit: number | null;
}
export function InventorySection({
medicationId,
defaultDosesPerUnit,
}: InventorySectionProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
// v1.4.27 B7 / L3 — explicit aria-controls between summary and disclosed
// panel for parity with the W8e Provenance accordion.
const panelId = useId();
const pastPanelId = useId();
// v1.4.27 MB3 — link the add-pen error message back to the doses-total
// input via aria-describedby so screen readers announce the failure
// when the form rejects an out-of-range value.
const formErrorId = useId();
// v1.4.27 R4 RC2 — portal target + stable form id wire the sticky
// Save / Cancel row into the `<ResponsiveSheet>` footer slot.
const formId = useId();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [dosesTotal, setDosesTotal] = useState<string>(
defaultDosesPerUnit ? String(defaultDosesPerUnit) : "",
);
const [printedExpiry, setPrintedExpiry] = useState<string>("");
const [purchasedAt, setPurchasedAt] = useState<string>("");
const [notes, setNotes] = useState<string>("");
const [formError, setFormError] = useState<string | null>(null);
const listKey = ["medications", medicationId, "inventory"] as const;
const { data, isLoading } = useQuery({
queryKey: listKey,
queryFn: async () => {
const res = await fetch(
`/api/medications/${medicationId}/inventory`,
);
if (!res.ok) return null;
const json = (await res.json()) as {
data: { items: InventoryItem[]; meta: { total: number } };
};
return json.data;
},
staleTime: 60 * 1000,
enabled: open,
});
const createMutation = useMutation({
mutationFn: async (payload: {
dosesTotal: number;
printedExpiry: string | null;
purchasedAt: string | null;
notes: string | null;
}) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error ?? "Failed to add pen");
}
return (await res.json()).data as InventoryItem;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const patchMutation = useMutation({
mutationFn: async (vars: {
itemId: string;
body: Record<string, unknown>;
}) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${vars.itemId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(vars.body),
},
);
if (!res.ok) throw new Error("Failed to update pen");
return (await res.json()).data as InventoryItem;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const deleteMutation = useMutation({
mutationFn: async (itemId: string) => {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${itemId}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Failed to delete pen");
return true;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
function resetAddForm() {
setDosesTotal(defaultDosesPerUnit ? String(defaultDosesPerUnit) : "");
setPrintedExpiry("");
setPurchasedAt("");
setNotes("");
setFormError(null);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
const dt = Number(dosesTotal);
if (!Number.isInteger(dt) || dt < 1 || dt > 100) {
setFormError(t("medications.inventory.errorDosesTotal"));
return;
}
createMutation.mutate(
{
dosesTotal: dt,
printedExpiry: printedExpiry ? new Date(printedExpiry).toISOString() : null,
purchasedAt: purchasedAt ? new Date(purchasedAt).toISOString() : null,
notes: notes.trim() ? notes.trim() : null,
},
{
onSuccess: () => {
resetAddForm();
setAddOpen(false);
},
onError: (err) => {
setFormError(err instanceof Error ? err.message : String(err));
},
},
);
}
const items = data?.items ?? [];
const liveItems = items.filter(
(i) => i.state === "ACTIVE" || i.state === "IN_USE",
);
const pastItems = items.filter(
(i) => i.state === "EXPIRED" || i.state === "USED_UP",
);
// Snapshot "now" once per render. The disclosure is opt-in (the
// user clicks to expand), so re-rendering on focus events should
// not cause the "days remaining" labels to flicker between values.
// eslint-disable-next-line react-hooks/purity -- intentional one-shot snapshot
const nowMs = useMemo(() => Date.now(), []);
function stateLabel(item: InventoryItem): string {
if (item.state === "ACTIVE") {
return t("medications.inventory.state.active");
}
if (item.state === "IN_USE") {
const d = daysRemainingInUse(
{ firstUseAt: item.firstUseAt },
nowMs,
DEFAULT_IN_USE_WINDOW_DAYS,
);
return t("medications.inventory.state.inUse", { days: d ?? 0 });
}
if (item.state === "EXPIRED") {
return t("medications.inventory.state.expired");
}
return t("medications.inventory.state.usedUp");
}
function badgeVariant(
state: MedicationInventoryState,
): "default" | "outline" | "secondary" | "destructive" {
if (state === "EXPIRED") return "destructive";
if (state === "USED_UP") return "secondary";
if (state === "IN_USE") return "default";
return "outline";
}
return (
<>
<details
className="border-border/60 rounded-md border text-xs"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
>
<summary
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
aria-controls={panelId}
aria-expanded={open}
>
<span>
{t("medications.inventory.title")}
{liveItems.length > 0 && (
<span className="text-muted-foreground ml-1.5 font-normal">
({liveItems.length})
</span>
)}
</span>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</summary>
<div
id={panelId}
className="border-border/60 space-y-3 border-t px-3 py-2.5"
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.inventory.loading")}</span>
</div>
)}
{!isLoading && liveItems.length === 0 && pastItems.length === 0 && (
<p className="text-muted-foreground">
{t("medications.inventory.empty")}
</p>
)}
{liveItems.length > 0 && (
<ul className="space-y-2">
{liveItems.map((item) => (
<li
key={item.id}
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
>
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={badgeVariant(item.state)}
className="text-[10px]"
>
{stateLabel(item)}
</Badge>
<span className="text-foreground/85 font-medium tabular-nums">
{item.dosesRemaining}/{item.dosesTotal}{" "}
{t("medications.inventory.dosesLabel")}
</span>
</div>
{item.printedExpiry && (
<p className="text-muted-foreground">
{t("medications.inventory.printedExpiry", {
date: fmt.dateShort(new Date(item.printedExpiry)),
})}
</p>
)}
{item.purchasedAt && (
<p className="text-muted-foreground">
{t("medications.inventory.purchasedAt", {
date: fmt.dateShort(new Date(item.purchasedAt)),
})}
</p>
)}
{item.notes && (
<p className="text-muted-foreground italic">
{item.notes}
</p>
)}
</div>
<div className="flex shrink-0 flex-col gap-1">
{item.state === "ACTIVE" && (
<Button
variant="outline"
className="min-h-11 px-3 text-xs"
onClick={() =>
patchMutation.mutate({
itemId: item.id,
body: { markAsFirstUseAt: new Date().toISOString() },
})
}
disabled={patchMutation.isPending}
>
{t("medications.inventory.markAsInUse")}
</Button>
)}
{(item.state === "ACTIVE" || item.state === "IN_USE") && (
<Button
variant="ghost"
className="min-h-11 px-3 text-xs"
onClick={() =>
patchMutation.mutate({
itemId: item.id,
body: { markAsUsedUp: true },
})
}
disabled={patchMutation.isPending}
>
{t("medications.inventory.markAsUsedUp")}
</Button>
)}
</div>
</li>
))}
</ul>
)}
{pastItems.length > 0 && (
<details className="border-border/40 rounded-md border">
<summary
className="text-muted-foreground cursor-pointer list-none px-2.5 py-1.5 font-medium"
aria-controls={pastPanelId}
>
{t("medications.inventory.pastTitle", {
count: pastItems.length,
})}
</summary>
<ul
id={pastPanelId}
className="border-border/40 space-y-1 border-t px-2.5 py-2"
>
{pastItems.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-2"
>
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={badgeVariant(item.state)}
className="text-[10px]"
>
{stateLabel(item)}
</Badge>
<span className="text-muted-foreground tabular-nums">
{item.dosesRemaining}/{item.dosesTotal}
</span>
{item.firstUseAt && (
<span className="text-muted-foreground">
·{" "}
{fmt.dateShort(new Date(item.firstUseAt))}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
aria-label={t("medications.inventory.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</details>
)}
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => setAddOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("medications.inventory.addPen")}
</Button>
</div>
</details>
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("medications.inventory.addPenTitle")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="inv-doses-total">
{t("medications.inventory.fieldDosesTotal")}
</Label>
<Input
id="inv-doses-total"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={1}
max={100}
step={1}
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
required
aria-required="true"
aria-invalid={!!formError || undefined}
aria-describedby={formError ? formErrorId : undefined}
/>
<p className="text-muted-foreground text-xs">
{t("medications.inventory.fieldDosesTotalHelp")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-printed-expiry">
{t("medications.inventory.fieldPrintedExpiry")}
</Label>
<Input
id="inv-printed-expiry"
type="date"
enterKeyHint="next"
value={printedExpiry}
onChange={(e) => setPrintedExpiry(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-purchased-at">
{t("medications.inventory.fieldPurchasedAt")}
</Label>
<Input
id="inv-purchased-at"
type="date"
enterKeyHint="next"
value={purchasedAt}
onChange={(e) => setPurchasedAt(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="inv-notes">
{t("medications.inventory.fieldNotes")}
</Label>
<textarea
id="inv-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={200}
rows={2}
enterKeyHint="done"
autoCapitalize="sentences"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{formError && (
<p
id={formErrorId}
role="alert"
aria-live="polite"
className="text-destructive text-sm"
>
{formError}
</p>
)}
</form>
{footerEl
? createPortal(
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
resetAddForm();
setAddOpen(false);
}}
>
{t("medications.inventory.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("medications.inventory.save")}
</Button>
</div>,
footerEl,
)
: null}
</ResponsiveSheet>
</>
);
}
@@ -210,10 +210,11 @@ export function decrementDose(
*
* v1.4.25 W21 Fix-N — widened to accept either the full
* `InventoryItemView` (server-side path: state-machine-gated, returns
* null for non-IN_USE) OR a thin `{ firstUseAt }` shape (client-side
* path: caller already knows state is IN_USE so the gate is a no-op).
* The widening collapses the reimplementation the inventory-section
* client previously carried.
* null for non-IN_USE) OR a thin `{ firstUseAt }` shape (caller
* already knows state is IN_USE so the gate is a no-op). The widening
* collapsed an earlier reimplementation in the now-retired web
* inventory surface; the helper stays because the GLP-1 details
* endpoint still computes weeksOfSupply for iOS.
*/
export function daysRemainingInUse(
item: InventoryItemView | { firstUseAt: Date | string | null },