feat(medications): an api tab, a stockable inventory and a next-due that tells the truth

The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 20:48:46 +02:00
1 parent a2524b6d70
commit 258e2fd365
22 files changed
+1373 -221

No files matched your search

@@ -61,6 +61,26 @@ describe("resolveDisplayedSlotInstant", () => {
expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
});
it("anchors on the matched band's timeOfDay over the stale legacy windowStart (v1.16.1)", () => {
// The production regression fixture: degenerate 07:00 / 07:00 window
// left behind while the dose times moved to 09:00 / 21:00. The matched
// band carries timeOfDay 09:00 — the recorded slot must be 09:00, not
// the stale window's 07:00.
const inBandNow = new Date("2026-06-05T07:30:00.000Z"); // 09:30 Berlin
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "07:00", windowEnd: "07:00" },
window: { timeOfDay: "09:00" },
},
nextDueAt: null,
now: inBandNow,
timeZone: "Europe/Berlin",
});
// 09:00 Berlin on 2026-06-05 is 07:00 UTC (CEST = +2).
expect(slot!.toISOString()).toBe("2026-06-05T07:00:00.000Z");
});
it("falls back to the server's next-due instant when no window is currently actionable", () => {
const nextDueAt = "2026-06-06T05:00:00.000Z";
const slot = resolveDisplayedSlotInstant({
@@ -33,10 +33,17 @@ export interface ResolveDisplayedSlotInstantInput {
* The card's current-window status. When its `status` is non-null the
* card is surfacing that schedule's window today (in_window / late /
* very_late), so the recorded dose is that window's slot today.
*
* v1.16.1 — `window` carries the matched dose band's anchor
* (`timeOfDay`, a `timesOfDay` entry). It is the canonical slot time;
* the schedule's `windowStart` stays only as the legacy fallback for
* rows without `timesOfDay`, so a stale / degenerate window can no
* longer mis-anchor the recorded dose.
*/
currentWindowStatus: {
status: "in_window" | "late" | "very_late" | null;
schedule: DisplayedSlotSchedule | null;
window?: { timeOfDay: string } | null;
};
/**
* The server-computed next-due instant (`computeNextDueAt`, the canonical
@@ -130,10 +137,15 @@ export function resolveDisplayedSlotInstant(
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
input;
// The card surfaces the current/overdue window — record that window's
// slot on today's calendar day.
// The card surfaces the current/overdue dose band — record that band's
// slot on today's calendar day. The matched band's `timeOfDay` is the
// canonical dose anchor; `windowStart` is only the legacy fallback for
// schedules without `timesOfDay`.
if (currentWindowStatus.status && currentWindowStatus.schedule) {
const hm = parseHm(currentWindowStatus.schedule.windowStart);
const anchor =
currentWindowStatus.window?.timeOfDay ??
currentWindowStatus.schedule.windowStart;
const hm = parseHm(anchor);
if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
}
@@ -151,6 +151,7 @@ const TAB_SLUGS = [
"bestand",
"verlauf",
"injektion",
"api",
"erweitert",
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
@@ -337,31 +338,19 @@ export function MedicationDetailTabs({
</Link>
</Button>
{/* HERO — always-visible read-only summary + the structural-edit
jump into the wizard. Everyday levers live in the tabs below. */}
<div className="flex items-start justify-between gap-3">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">
{t("medications.detail.shell.fullEdit")}
</span>
</Button>
</div>
{/* HERO — always-visible read-only summary. The structural edit
(wizard) moved out of the header: it lives as a row under
Erweitert → Lebenszyklus (the header button read as "edit this
page" and misled). `?edit=1` still opens the wizard directly. */}
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Tabs
value={activeTab}
@@ -528,13 +517,28 @@ export function MedicationDetailTabs({
doseWindows: s.doseWindows,
}))}
/>
{/* Quiet cross-link: WHICH channels deliver the reminder
(push / Telegram / ntfy, quiet hours) is configured
globally, not per medication. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/notifications"
className="focus-visible:ring-ring rounded-sm underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-manage-channels-link"
>
{t("medications.detail.zeitplan.manageChannels")}
</Link>
</p>
</div>
</MedicationDetailSection>
</TabsContent>
{/* BESTAND — inventory readout for all meds. */}
{/* BESTAND — inventory readout + register / correct flows. */}
<TabsContent value="bestand" className="space-y-4 pt-2">
<InventorySection medicationId={id} />
<InventorySection
medicationId={id}
dosesPerUnit={medication.dosesPerUnit}
/>
</TabsContent>
{/* VERLAUF — the dose-history ledger: every expected slot with its
@@ -586,36 +590,14 @@ export function MedicationDetailTabs({
</TabsContent>
)}
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
{/* API — the external-ingest surface in its own tab: per-medication
tokens + the drug-coding identifiers external systems key on.
Pulled out of Erweitert so the destructive zone and the token
management stop sharing one surface. */}
<TabsContent value="api" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="erweitert-group-external-api"
dataSlot="api-group-external-api"
>
<div className="py-3">
<ApiTokensRow
@@ -631,6 +613,61 @@ export function MedicationDetailTabs({
/>
</div>
</SettingsGroup>
</TabsContent>
{/* ERWEITERT — Lifecycle → Daten → Gefahrenzone. The external-API
group moved to its own API tab. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{/* The structural editor (the create/edit wizard) — name,
class, cadence kind, schedules. Replaces the former hero
"Vollständig bearbeiten" button, which read as "edit this
page". */}
<div
className="flex items-center justify-between gap-3 py-3"
data-slot="erweitert-editor-row"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.erweitert.editor.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.erweitert.editor.helper")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.erweitert.editor.button")}
</Button>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
@@ -763,18 +800,18 @@ function ComplianceGatedDangerZone({
const { t } = useTranslations();
return (
// v1.16.1 — same neutral card chrome as every other group; only the
// action buttons stay destructive. A red-washed card read as a
// constant alarm on a page the user visits routinely.
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
className="border-destructive/40 bg-destructive/5"
>
<div className="py-3">
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</div>
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</SettingsGroup>
);
}
@@ -52,6 +52,13 @@ interface ScheduleLite {
label: string | null;
dose: string | null;
daysOfWeek: string | null;
/**
* v1.16.1 — first-class dose times + explicit per-dose bands; the
* window-status helper derives its bands from these (canonical) and
* only falls back to the legacy window when they are absent.
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
}
interface DoseChangeLite {
@@ -402,8 +409,10 @@ export function Glp1MedicationCard({
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
// v1.16.1 — the pill shows the MATCHED dose band, not the
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
}
: null
}
@@ -9,9 +9,10 @@
* affordance instead of a cluster. The card body itself navigates to the
* detail page (the former chevron target); this menu owns the edit and
* history actions. v1.15.20 retires the "Advanced settings" item — the
* detail page's Erweitert tab is reachable through the card body, and
* Edit now opens the medication wizard directly instead of landing on
* the Zeitplan tab.
* detail page's Erweitert tab is reachable through the card body.
* v1.16.1 — Edit navigates to the detail page's Übersicht tab (the same
* target as the name tap); the structural wizard lives under the detail
* page's Erweitert → Lebenszyklus row.
*
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
* "Log side effect" item into the SAME menu, but it is NOT wired on the
+21 -3
View File
@@ -34,6 +34,14 @@ interface Schedule {
label: string | null;
dose: string | null;
daysOfWeek: string | null;
/**
* v1.16.1 — first-class dose times + explicit per-dose bands. The
* window-status helper derives its bands from these (canonical) and
* only falls back to `windowStart` / `windowEnd` when they are absent,
* so a stale window can no longer drive the pill or the recorded slot.
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
}
interface Medication {
@@ -318,10 +326,18 @@ export function MedicationCard({
}
}
// v1.16.1 — the TIME is the canonical next-due instant (the
// recurrence engine's timesOfDay-anchored slot), not the legacy
// schedule window. A stale `windowStart` / `windowEnd` pair
// (e.g. 07:00 / 07:00 while the times moved to 09:00 / 21:00)
// used to print "today, 07:00" here. The window range stays as
// the fallback for rows the server has no next-due for.
return (
<>
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{nextAt
? formatTime(new Date(nextAt).toISOString())
: formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
@@ -350,8 +366,10 @@ export function MedicationCard({
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
// v1.16.1 — the pill shows the MATCHED dose band, not the
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
}
: null
}
@@ -43,7 +43,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
@@ -233,8 +232,10 @@ export function LifecycleManageBody({
/**
* v1.7.0 — irreversible danger zone: purge intake history + delete the
* medication. Wrapped in a `border-destructive/40` card to keep the
* visual warning convention.
* medication. v1.16.1 — the body is bare rows (the caller's
* `SettingsGroup` supplies the neutral card chrome and dividers); the
* destructive signal lives ONLY in the red action buttons + the confirm
* dialogs, not in a tinted surface.
*/
export function DangerZoneBody({
medicationId,
@@ -317,8 +318,8 @@ export function DangerZoneBody({
}
return (
<Card
className="border-destructive/40 bg-destructive/5 divide-destructive/20 gap-0 divide-y px-4 py-1"
<div
className="divide-border/60 divide-y"
data-slot="destructive-zone-card-b"
>
{/* Tier 3a — Verlauf löschen. Matched pair with the delete row
@@ -440,7 +441,7 @@ export function DangerZoneBody({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
</div>
);
}
@@ -3,23 +3,42 @@
/**
* v1.15.18 — Bestand (supply) tab body.
*
* Read-only inventory readout for the medication detail page's Supply
* tab. Reads `GET /api/medications/[id]/inventory` (the same per-item
* list the pen/vial CRUD route serves) and renders a calm summary — the
* doses remaining across every non-terminal item plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count too);
* the richer pen math is implicit in the per-item doses figures.
* v1.16.1 — the tab grows its write affordances:
*
* No write affordance here yet — registering a pen still rides the
* dedicated inventory route; the tab is the read surface the spec asks
* for. An empty inventory shows a quiet empty state, not a spinner.
* - REGISTER: a "+" flow (header button + the empty-state CTA) that
* records a new pen / pack via `POST /api/medications/[id]/inventory`
* — quantity in doses/units, optional printed expiry. The quantity
* prefills from the medication's `dosesPerUnit` when configured.
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
* `dosesRemaining`) for stock corrections and manual withdrawals —
* the count is set absolutely, clamped server-side to the item's
* capacity, and the canonical state machine derives the next state
* (0 ⇒ used up).
*
* Reads stay on `GET /api/medications/[id]/inventory` (the same
* per-item list the pen/vial CRUD route serves): a calm summary — the
* doses remaining across every non-terminal item — plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count
* too); the richer pen math is implicit in the per-item doses figures.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, PackageOpen } from "lucide-react";
import { useState, type FormEvent } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, PackageOpen, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -45,8 +64,17 @@ const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive
USED_UP: "outline",
};
export function InventorySection({ medicationId }: { medicationId: string }) {
export function InventorySection({
medicationId,
dosesPerUnit,
}: {
medicationId: string;
/** Prefills the register flow's quantity when the medication tracks it. */
dosesPerUnit?: number | null;
}) {
const { t } = useTranslations();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
@@ -80,13 +108,42 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
const dialogs = (
<>
{addOpen && (
<AddInventoryDialog
medicationId={medicationId}
defaultDosesTotal={dosesPerUnit ?? null}
onClose={() => setAddOpen(false)}
/>
)}
{adjustItem && (
<AdjustInventoryDialog
medicationId={medicationId}
item={adjustItem}
onClose={() => setAdjustItem(null)}
/>
)}
</>
);
if (items.length === 0) {
return (
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
/>
<>
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
ctaSize="lg"
action={
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.bestand.emptyCta")}
</Button>
}
/>
{dialogs}
</>
);
}
@@ -96,10 +153,20 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
label={t("medications.detail.bestand.title")}
dataSlot="inventory-summary-group"
>
<div className="py-3">
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="inventory-add-button"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.bestand.addButton")}
</Button>
</div>
</SettingsGroup>
@@ -119,12 +186,278 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
total: item.dosesTotal,
})}
</span>
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
<span className="flex shrink-0 items-center gap-1">
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setAdjustItem(item)}
className="min-h-11 sm:min-h-9"
data-slot="inventory-adjust-button"
>
{t("medications.detail.bestand.adjustButton")}
</Button>
</span>
</div>
))}
</SettingsGroup>
{dialogs}
</div>
);
}
/**
* Register a new pen / pack. Two fields (quantity + optional printed
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
* the medication's `dosesPerUnit` so the common case is one tap.
*/
function AddInventoryDialog({
medicationId,
defaultDosesTotal,
onClose,
}: {
medicationId: string;
defaultDosesTotal: number | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dosesTotal, setDosesTotal] = useState(
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
? String(defaultDosesTotal)
: "",
);
const [expiry, setExpiry] = useState("");
const [busy, setBusy] = useState(false);
const parsed = Number(dosesTotal);
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
async function submit(e: FormEvent) {
e.preventDefault();
if (!quantityValid || busy) return;
setBusy(true);
try {
const res = await fetch(`/api/medications/${medicationId}/inventory`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dosesTotal: parsed,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
}),
});
if (!res.ok) {
toast.error(t("medications.detail.bestand.addFailed"));
return;
}
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.addFailed"));
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.addDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.addQuantityHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<Input
id="inventory-add-expiry"
type="date"
autoComplete="off"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.addSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
/**
* Stock correction for one item: sets the remaining-dose count
* absolutely (covers both "I miscounted" and a manual withdrawal). The
* server clamps to the item's capacity and re-derives the state; 0
* marks the item used up.
*/
function AdjustInventoryDialog({
medicationId,
item,
onClose,
}: {
medicationId: string;
item: InventoryItem;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [value, setValue] = useState(String(item.dosesRemaining));
const [busy, setBusy] = useState(false);
const parsed = Number(value);
const valid =
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal;
async function submit(e: FormEvent) {
e.preventDefault();
if (!valid || busy) return;
setBusy(true);
try {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${item.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dosesRemaining: parsed }),
},
);
if (!res.ok) {
toast.error(t("medications.detail.bestand.adjustFailed"));
return;
}
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.adjustFailed"));
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.adjustTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.adjustDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="numeric"
min={0}
max={item.dosesTotal}
step={1}
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.dosesTotal,
})}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}