mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
8c81af10d6
commit
8c8d6dc27f
10 files changed
+29
-779
No files matched your search
+1
-31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user