diff --git a/messages/de.json b/messages/de.json index b9b7f4634..9d5e4c563 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 449c66f8b..8ecd87c26 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c1cf596a7..4db619488 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 4069e9223..fa16f5ccb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 6bd5e8b8b..482034b86 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 138100ce1..07647b3a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 03903569b..363c0282c 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -308,7 +308,11 @@ describe(" — 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(" — 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(" — 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", () => { diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 7d9b8da5a..2018fb08e 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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({ )} - {/* Inventory line — only when dosesPerUnit + at least one - inventory event recorded. Low-stock gets a warning badge. */} - {inventory && inventory.pensRemaining !== null && ( -
- - {t("medications.glp1Inventory", { - pens: inventory.pensRemaining, - weeks: inventory.weeksOfSupply ?? "—", - })} - - {inventory.lowStock && ( - - {t("medications.glp1InventoryLow")} - - )} -
- )} - - {/* 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. */} - + {/* 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. */} diff --git a/src/components/medications/inventory-section.tsx b/src/components/medications/inventory-section.tsx deleted file mode 100644 index e9c75f5b4..000000000 --- a/src/components/medications/inventory-section.tsx +++ /dev/null @@ -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 `` footer slot. - const formId = useId(); - const [footerEl, setFooterEl] = useState(null); - const [dosesTotal, setDosesTotal] = useState( - defaultDosesPerUnit ? String(defaultDosesPerUnit) : "", - ); - const [printedExpiry, setPrintedExpiry] = useState(""); - const [purchasedAt, setPurchasedAt] = useState(""); - const [notes, setNotes] = useState(""); - const [formError, setFormError] = useState(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; - }) => { - 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) { - 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 ( - <> -
setOpen((e.target as HTMLDetailsElement).open)} - > - - - {t("medications.inventory.title")} - {liveItems.length > 0 && ( - - ({liveItems.length}) - - )} - - - -
- {isLoading && ( -
- - {t("medications.inventory.loading")} -
- )} - - {!isLoading && liveItems.length === 0 && pastItems.length === 0 && ( -

- {t("medications.inventory.empty")} -

- )} - - {liveItems.length > 0 && ( -
    - {liveItems.map((item) => ( -
  • -
    -
    - - {stateLabel(item)} - - - {item.dosesRemaining}/{item.dosesTotal}{" "} - {t("medications.inventory.dosesLabel")} - -
    - {item.printedExpiry && ( -

    - {t("medications.inventory.printedExpiry", { - date: fmt.dateShort(new Date(item.printedExpiry)), - })} -

    - )} - {item.purchasedAt && ( -

    - {t("medications.inventory.purchasedAt", { - date: fmt.dateShort(new Date(item.purchasedAt)), - })} -

    - )} - {item.notes && ( -

    - {item.notes} -

    - )} -
    -
    - {item.state === "ACTIVE" && ( - - )} - {(item.state === "ACTIVE" || item.state === "IN_USE") && ( - - )} -
    -
  • - ))} -
- )} - - {pastItems.length > 0 && ( -
- - {t("medications.inventory.pastTitle", { - count: pastItems.length, - })} - -
    - {pastItems.map((item) => ( -
  • -
    - - {stateLabel(item)} - - - {item.dosesRemaining}/{item.dosesTotal} - - {item.firstUseAt && ( - - ·{" "} - {fmt.dateShort(new Date(item.firstUseAt))} - - )} -
    - -
  • - ))} -
-
- )} - - -
-
- - } - > -
-
- - setDosesTotal(e.target.value)} - required - aria-required="true" - aria-invalid={!!formError || undefined} - aria-describedby={formError ? formErrorId : undefined} - /> -

- {t("medications.inventory.fieldDosesTotalHelp")} -

-
- -
- - setPrintedExpiry(e.target.value)} - /> -
- -
- - setPurchasedAt(e.target.value)} - /> -
- -
- -