diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index d2be7693a..3f68c1789 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -27,6 +27,7 @@ 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. @@ -402,6 +403,14 @@ export function Glp1MedicationCard({ + {/* 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. */} + + {/* Compliance bars — identical to the generic card so the page grid stays harmonious. */} {medication.active && compliance && ( diff --git a/src/components/medications/inventory-section.tsx b/src/components/medications/inventory-section.tsx new file mode 100644 index 000000000..e3c907625 --- /dev/null +++ b/src/components/medications/inventory-section.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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"; + +/** + * 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. + */ + +type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + +interface InventoryItem { + id: string; + state: InventoryState; + 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; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function daysRemainingInUse( + firstUseAt: string | null, + windowDays: number, +): number | null { + if (!firstUseAt) return null; + const deadline = new Date(firstUseAt).getTime() + windowDays * MS_PER_DAY; + const ms = deadline - Date.now(); + if (ms <= 0) return 0; + return Math.floor(ms / MS_PER_DAY); +} + +const IN_USE_WINDOW_DAYS = 30; + +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); + 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", + ); + + function stateLabel(item: InventoryItem): string { + if (item.state === "ACTIVE") { + return t("medications.inventory.state.active"); + } + if (item.state === "IN_USE") { + const d = daysRemainingInUse(item.firstUseAt, 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: InventoryState, + ): "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))} + + )} +
    + +
  • + ))} +
+
+ )} + + +
+
+ + + + + + {t("medications.inventory.addPenTitle")} + + +
+
+ + setDosesTotal(e.target.value)} + required + /> +

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

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