feat(medications): inventory card on medication detail page

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:54:13 +02:00
1 parent 15a72bb329
commit a0b9c11e68
2 files changed
+512

No files matched your search

@@ -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({
</div>
</details>
{/* 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}
/>
{/* Compliance bars — identical to the generic card so the page
grid stays harmonious. */}
{medication.active && compliance && (
@@ -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<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",
);
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 (
<>
<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">
<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 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" />
<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
size="sm"
variant="outline"
className="h-7 px-2 text-[10px]"
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
size="sm"
variant="ghost"
className="h-7 px-2 text-[10px]"
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">
{t("medications.inventory.pastTitle", {
count: pastItems.length,
})}
</summary>
<ul 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
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
aria-label={t("medications.inventory.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</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>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("medications.inventory.addPenTitle")}
</DialogTitle>
</DialogHeader>
<form 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"
min={1}
max={100}
step={1}
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
required
/>
<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"
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"
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}
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 className="text-destructive text-sm">{formError}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
resetAddForm();
setAddOpen(false);
}}
>
{t("medications.inventory.cancel")}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("medications.inventory.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}