mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
963 lines
33 KiB
TypeScript
963 lines
33 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.15.18 — Bestand (supply) tab body.
|
|
*
|
|
* v1.16.1 — the tab grows its write affordances:
|
|
*
|
|
* - REGISTER: a "+" flow (header button + the empty-state CTA) that
|
|
* records a new pack / container via
|
|
* `POST /api/medications/[id]/inventory` — quantity in units,
|
|
* optional printed expiry. The quantity prefills from the
|
|
* medication's `dosesPerUnit` when configured.
|
|
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
|
|
* `unitsRemaining`) 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).
|
|
* - DELETE: a per-item trash affordance behind a destructive confirm
|
|
* (`DELETE …/inventory/[itemId]`). Consumption stamps on intake
|
|
* events that referenced the container stay in place; a later
|
|
* restore skips the missing item.
|
|
*
|
|
* v1.16.10 — items count UNITS (tablets / ampoules / puffs);
|
|
* `Medication.unitsPerDose` maps units to doses. Every dose-facing
|
|
* readout divides by it (floor), with the raw unit count as secondary
|
|
* text when the factor is > 1. The register flow gains a container-type
|
|
* select (pen / ampoule / tablet pack / …, defaulted from the delivery
|
|
* form) and, for multi-unit doses, a segmented Dosen | Einheiten
|
|
* quantity input with live conversion — the wire always carries units.
|
|
*
|
|
* Reads stay on `GET /api/medications/[id]/inventory` (the same
|
|
* per-item list the inventory CRUD route serves): a calm summary — the
|
|
* doses remaining across every non-terminal item — plus a per-item list
|
|
* with its container type and state badge. Shown for ALL medications
|
|
* (pill packs count too).
|
|
*/
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
useQuery,
|
|
useQueryClient,
|
|
type QueryClient,
|
|
} 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 { DeleteButton } from "@/components/data-list/delete-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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { SettingsGroup } from "@/components/medications/settings-group";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
|
import {
|
|
apiDelete,
|
|
apiGet,
|
|
apiPatch,
|
|
apiPost,
|
|
apiPut,
|
|
} from "@/lib/api/api-fetch";
|
|
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
|
|
|
/**
|
|
* Invalidate every read key whose payload reflects a medication's supply
|
|
* after a container write (register / adjust / delete).
|
|
*
|
|
* The per-medication inventory read (`medicationInventory`) is the supply
|
|
* tab's own list. The medications LIST read (`medications`) carries the
|
|
* dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
|
|
* the card and table render — without invalidating it, the card kept
|
|
* showing the pre-write stock until an unrelated refetch landed (the
|
|
* supply-staleness bug). Both keys must drop together.
|
|
*/
|
|
export async function invalidateSupplyQueries(
|
|
queryClient: QueryClient,
|
|
medicationId: string,
|
|
): Promise<void> {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.medicationInventory(medicationId),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.medications(),
|
|
}),
|
|
]);
|
|
}
|
|
|
|
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
|
|
|
const CONTAINER_TYPES = [
|
|
"PEN",
|
|
"AMPOULE",
|
|
"BLISTER",
|
|
"INHALER",
|
|
"BOTTLE",
|
|
"OTHER",
|
|
] as const;
|
|
type ContainerType = (typeof CONTAINER_TYPES)[number];
|
|
|
|
interface InventoryItem {
|
|
id: string;
|
|
state: InventoryState;
|
|
containerType: ContainerType;
|
|
// v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row);
|
|
// the UI renders "—" instead of a fabricated 0.
|
|
unitsTotal: number | null;
|
|
unitsRemaining: number | null;
|
|
}
|
|
|
|
interface InventoryResponse {
|
|
items: InventoryItem[];
|
|
meta?: { total: number };
|
|
}
|
|
|
|
const STATE_BADGE: Record<
|
|
InventoryState,
|
|
"secondary" | "outline" | "destructive"
|
|
> = {
|
|
ACTIVE: "secondary",
|
|
IN_USE: "secondary",
|
|
EXPIRED: "destructive",
|
|
USED_UP: "outline",
|
|
};
|
|
|
|
/** Default container kind for the register flow, from the delivery form. */
|
|
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
|
|
if (deliveryForm === "INJECTION") return "PEN";
|
|
if (deliveryForm === "ORAL") return "BLISTER";
|
|
return "OTHER";
|
|
}
|
|
|
|
export function InventorySection({
|
|
medicationId,
|
|
dosesPerUnit,
|
|
unitsPerDose,
|
|
deliveryForm,
|
|
}: {
|
|
medicationId: string;
|
|
/** Prefills the register flow's quantity when the medication tracks it. */
|
|
dosesPerUnit?: number | null;
|
|
/** Units one dose consumes; dose-derived readouts divide by it. */
|
|
unitsPerDose?: number | null;
|
|
/** Defaults the register flow's container type. */
|
|
deliveryForm?: string;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
|
const [packagingOpen, setPackagingOpen] = useState(false);
|
|
|
|
async function deleteItem(item: InventoryItem) {
|
|
try {
|
|
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
|
|
await invalidateSupplyQueries(queryClient, medicationId);
|
|
toast.success(t("medications.detail.bestand.deleteSuccess"));
|
|
} catch {
|
|
toast.error(t("medications.detail.bestand.deleteFailed"));
|
|
}
|
|
}
|
|
|
|
// v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet
|
|
// per dose) must stay fractional or the dose-derived counts halve.
|
|
const perDose = unitsPerDose && unitsPerDose > 0 ? unitsPerDose : 1;
|
|
|
|
const { data, isLoading } = useQuery<InventoryResponse>({
|
|
queryKey: queryKeys.medicationInventory(medicationId),
|
|
queryFn: async () => {
|
|
return apiGet<InventoryResponse>(
|
|
`/api/medications/${medicationId}/inventory`,
|
|
);
|
|
},
|
|
staleTime: 30_000,
|
|
// v1.16.12 (#316) — fresh on every mount so reopening the supply tab
|
|
// reflects stock changed elsewhere (a dose on another device, a
|
|
// refill) without a manual reload.
|
|
refetchOnMount: "always",
|
|
});
|
|
|
|
// v1.16.11 — the low-stock alert threshold, for the cross-link row
|
|
// below the supply summary (same shared key + cache the cards read).
|
|
// Null on failure falls back to the server default (7 days).
|
|
const { data: thresholds } = useQuery({
|
|
queryKey: queryKeys.settingsReminderThresholds(),
|
|
queryFn: async () => {
|
|
try {
|
|
return await apiGet<{
|
|
lateMinutes: number;
|
|
missedMinutes: number;
|
|
lowStockRunwayDays: number | null;
|
|
}>("/api/settings/reminder-thresholds");
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const lowStockDays = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div
|
|
className="flex h-32 items-center justify-center"
|
|
role="status"
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
>
|
|
<Loader2
|
|
aria-hidden="true"
|
|
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const items = Array.isArray(data?.items) ? data.items : [];
|
|
// Available supply: ACTIVE / IN_USE containers with units left — the
|
|
// shared summary helper keeps this row, the Übersicht row, the list
|
|
// payload and the GLP-1 endpoint on one predicate. Expired stock is
|
|
// never available; it renders as a separate muted suffix.
|
|
// Dose-derived headline (floor — a partial dose is not a dose).
|
|
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
|
|
// the available-supply math; it shows "—" in its own row but cannot pad
|
|
// the pooled headline. Coalesce null → 0 only for the summary inputs.
|
|
const {
|
|
unitsRemaining: remainingUnits,
|
|
unitsTotal: totalUnits,
|
|
dosesRemaining: remaining,
|
|
dosesTotal: total,
|
|
expiredUnits,
|
|
} = summariseSupply(
|
|
items.map((item) => ({
|
|
state: item.state,
|
|
unitsTotal: item.unitsTotal ?? 0,
|
|
unitsRemaining: item.unitsRemaining ?? 0,
|
|
})),
|
|
perDose,
|
|
);
|
|
|
|
const dialogs = (
|
|
<>
|
|
{addOpen && (
|
|
<AddInventoryDialog
|
|
medicationId={medicationId}
|
|
defaultUnitsTotal={dosesPerUnit ?? null}
|
|
unitsPerDose={perDose}
|
|
initialContainerType={defaultContainerType(deliveryForm)}
|
|
onClose={() => setAddOpen(false)}
|
|
/>
|
|
)}
|
|
{adjustItem && (
|
|
<AdjustInventoryDialog
|
|
medicationId={medicationId}
|
|
item={adjustItem}
|
|
onClose={() => setAdjustItem(null)}
|
|
/>
|
|
)}
|
|
{packagingOpen && (
|
|
<PackagingDialog
|
|
medicationId={medicationId}
|
|
unitsPerDose={perDose}
|
|
dosesPerUnit={dosesPerUnit ?? null}
|
|
onClose={() => setPackagingOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<>
|
|
<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}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4" data-slot="medication-inventory-section">
|
|
<SettingsGroup
|
|
label={t("medications.detail.bestand.title")}
|
|
dataSlot="inventory-summary-group"
|
|
>
|
|
<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 })}
|
|
{perDose !== 1 && (
|
|
<span className="text-muted-foreground block text-xs font-normal">
|
|
{t("medications.detail.bestand.unitsDetail", {
|
|
remaining: formatUnitCount(remainingUnits),
|
|
total: formatUnitCount(totalUnits),
|
|
})}
|
|
</span>
|
|
)}
|
|
{expiredUnits > 0 && (
|
|
<span
|
|
className="text-muted-foreground block text-xs font-normal"
|
|
data-slot="inventory-expired-suffix"
|
|
>
|
|
{t("medications.detail.bestand.expiredSuffix", {
|
|
units: expiredUnits,
|
|
})}
|
|
</span>
|
|
)}
|
|
</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>
|
|
{/* v1.16.11 — cross-link to the low-stock alert setting: the
|
|
threshold lives in Settings → Notifications, but the question
|
|
"when will it warn me?" comes up here, where the stock lives. */}
|
|
<div className="py-2">
|
|
<Link
|
|
href="/settings/notifications#low-stock"
|
|
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
|
data-slot="inventory-low-stock-link"
|
|
>
|
|
{lowStockDays !== null
|
|
? t("medications.detail.bestand.lowStockLinkOn", {
|
|
days: lowStockDays,
|
|
})
|
|
: t("medications.detail.bestand.lowStockLinkOff")}
|
|
</Link>
|
|
</div>
|
|
</SettingsGroup>
|
|
|
|
{/* v1.16.11 — packaging economics surfaced where the stock lives:
|
|
the wizard's dose step owns these on create/edit, but a
|
|
manufacturer switch (a different blister size) happens while
|
|
looking at the supply, so the supply tab carries them too. */}
|
|
<SettingsGroup
|
|
label={t("medications.detail.bestand.packagingTitle")}
|
|
dataSlot="inventory-packaging-group"
|
|
>
|
|
<div className="flex items-center justify-between gap-3 py-3">
|
|
<p className="text-foreground text-sm font-medium">
|
|
{t("medications.detail.bestand.packagingUnitsPerDose", {
|
|
units: perDose,
|
|
})}
|
|
<span className="text-muted-foreground block text-xs font-normal">
|
|
{dosesPerUnit != null
|
|
? t("medications.detail.bestand.packagingDefaultPack", {
|
|
units: dosesPerUnit,
|
|
})
|
|
: t("medications.detail.bestand.packagingNoDefaultPack")}
|
|
</span>
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPackagingOpen(true)}
|
|
className="min-h-11 shrink-0 sm:min-h-9"
|
|
data-slot="inventory-packaging-edit"
|
|
>
|
|
{t("medications.detail.bestand.packagingEdit")}
|
|
</Button>
|
|
</div>
|
|
</SettingsGroup>
|
|
|
|
<SettingsGroup
|
|
label={t("medications.detail.bestand.itemsTitle")}
|
|
dataSlot="inventory-items-group"
|
|
>
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center justify-between gap-3 py-3"
|
|
data-slot="inventory-item-row"
|
|
>
|
|
<span className="text-foreground text-sm">
|
|
<span className="block">
|
|
{t(
|
|
`medications.detail.bestand.containerType.${item.containerType}`,
|
|
)}
|
|
</span>
|
|
{/* Meta line: per-container figures with the state badge
|
|
inline at meta-text size — read-only, never a control. */}
|
|
<span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
|
|
<span>
|
|
{/* v1.18.3 (iOS#31) — an unknown unit count (null) renders
|
|
"—" rather than a fabricated 0-dose figure. */}
|
|
{item.unitsRemaining == null || item.unitsTotal == null
|
|
? t("medications.detail.bestand.unknown")
|
|
: t("medications.detail.bestand.doses", {
|
|
remaining: Math.floor(item.unitsRemaining / perDose),
|
|
total: Math.floor(item.unitsTotal / perDose),
|
|
})}
|
|
{perDose !== 1 &&
|
|
item.unitsRemaining != null &&
|
|
item.unitsTotal != null && (
|
|
<>
|
|
{" · "}
|
|
{t("medications.detail.bestand.unitsDetail", {
|
|
remaining: formatUnitCount(item.unitsRemaining),
|
|
total: formatUnitCount(item.unitsTotal),
|
|
})}
|
|
</>
|
|
)}
|
|
</span>
|
|
<Badge
|
|
variant={STATE_BADGE[item.state]}
|
|
className="px-1.5 py-0 text-xs font-normal"
|
|
data-slot="inventory-state-badge"
|
|
>
|
|
{t(`medications.detail.bestand.state.${item.state}`)}
|
|
</Badge>
|
|
</span>
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAdjustItem(item)}
|
|
className="min-h-11 sm:min-h-9"
|
|
data-slot="inventory-adjust-button"
|
|
>
|
|
{t("medications.detail.bestand.adjustButton")}
|
|
</Button>
|
|
<DeleteButton
|
|
onConfirm={() => void deleteItem(item)}
|
|
title={t("medications.detail.bestand.deleteTitle")}
|
|
description={t("medications.detail.bestand.deleteDescription")}
|
|
/>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</SettingsGroup>
|
|
|
|
{dialogs}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Register a new pack / container. Quantity (units), container type and
|
|
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
|
|
* prefills from the medication's `dosesPerUnit` so the common case is
|
|
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
|
|
* Dosen | Einheiten control converts live — the POST always carries
|
|
* units.
|
|
*/
|
|
export function AddInventoryDialog({
|
|
medicationId,
|
|
defaultUnitsTotal,
|
|
unitsPerDose,
|
|
initialContainerType,
|
|
onClose,
|
|
}: {
|
|
medicationId: string;
|
|
defaultUnitsTotal: number | null;
|
|
unitsPerDose: number;
|
|
initialContainerType: ContainerType;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [quantity, setQuantity] = useState(
|
|
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
|
|
? String(defaultUnitsTotal)
|
|
: "",
|
|
);
|
|
// The unit the typed quantity is read in. Only surfaced when a dose
|
|
// spans several units; the stored value is ALWAYS units.
|
|
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
|
|
const [containerType, setContainerType] =
|
|
useState<ContainerType>(initialContainerType);
|
|
const [expiry, setExpiry] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const parsed = Number(quantity);
|
|
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
|
|
const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
|
|
const quantityValid =
|
|
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
|
|
|
|
async function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!quantityValid || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await apiPost(`/api/medications/${medicationId}/inventory`, {
|
|
// The wire field carries UNITS (v1.16.10 symmetric naming).
|
|
unitsTotal: units,
|
|
containerType,
|
|
printedExpiry: expiry
|
|
? new Date(`${expiry}T00:00:00`).toISOString()
|
|
: null,
|
|
});
|
|
await invalidateSupplyQueries(queryClient, 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-container-type"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("medications.detail.bestand.containerTypeLabel")}
|
|
</label>
|
|
<Select
|
|
value={containerType}
|
|
onValueChange={(v) => setContainerType(v as ContainerType)}
|
|
>
|
|
<SelectTrigger
|
|
id="inventory-add-container-type"
|
|
className="w-full"
|
|
data-slot="inventory-container-type"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CONTAINER_TYPES.map((ct) => (
|
|
<SelectItem key={ct} value={ct}>
|
|
{t(`medications.detail.bestand.containerType.${ct}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="inventory-add-quantity"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("medications.detail.bestand.addQuantityLabel")}
|
|
</label>
|
|
{unitsPerDose > 1 && (
|
|
<div
|
|
className="border-border/60 inline-flex rounded-md border p-0.5"
|
|
role="group"
|
|
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
|
data-slot="inventory-quantity-mode"
|
|
>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
|
aria-pressed={quantityMode === "doses"}
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setQuantityMode("doses")}
|
|
>
|
|
{t("medications.detail.bestand.quantityModeDoses")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
|
aria-pressed={quantityMode === "units"}
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setQuantityMode("units")}
|
|
>
|
|
{t("medications.detail.bestand.quantityModeUnits")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<Input
|
|
id="inventory-add-quantity"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={
|
|
effectiveMode === "doses"
|
|
? Math.floor(1000 / unitsPerDose)
|
|
: 1000
|
|
}
|
|
step={1}
|
|
required
|
|
autoComplete="off"
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(e.target.value)}
|
|
aria-describedby="inventory-add-quantity-helper"
|
|
/>
|
|
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
|
|
<p
|
|
className="text-muted-foreground text-xs"
|
|
data-slot="inventory-quantity-conversion"
|
|
>
|
|
{effectiveMode === "doses"
|
|
? t("medications.detail.bestand.quantityInUnits", { units })
|
|
: // A unit count below one dose must not read "≈ 0 doses" —
|
|
// it is simply less than one dose.
|
|
Math.floor(parsed / unitsPerDose) === 0
|
|
? t("medications.detail.bestand.quantityUnderOneDose")
|
|
: t("medications.detail.bestand.quantityInDoses", {
|
|
doses: Math.floor(parsed / unitsPerDose),
|
|
})}
|
|
</p>
|
|
)}
|
|
<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-unit 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();
|
|
// v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty,
|
|
// not the literal "null"; the operator types the corrected figure.
|
|
const [value, setValue] = useState(
|
|
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const parsed = Number(value);
|
|
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
|
|
// v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness.
|
|
const valid =
|
|
value.trim() !== "" &&
|
|
Number.isFinite(parsed) &&
|
|
parsed >= 0 &&
|
|
(item.unitsTotal == null || parsed <= item.unitsTotal);
|
|
|
|
async function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!valid || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
|
|
// The wire field carries UNITS (v1.16.10 symmetric naming).
|
|
unitsRemaining: parsed,
|
|
});
|
|
await invalidateSupplyQueries(queryClient, 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="decimal"
|
|
min={0}
|
|
max={item.unitsTotal ?? undefined}
|
|
step="any"
|
|
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.unitsTotal ?? t("medications.detail.bestand.unknown"),
|
|
})}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Edit the medication-level packaging economics from the supply tab:
|
|
* units one dose consumes, and the default container size the register
|
|
* flow prefills. Both PUT sparsely onto the medication — the wizard's
|
|
* dose step stays the source on create; this is the correction surface
|
|
* for a manufacturer switch (same medication, different blister size).
|
|
*/
|
|
function PackagingDialog({
|
|
medicationId,
|
|
unitsPerDose,
|
|
dosesPerUnit,
|
|
onClose,
|
|
}: {
|
|
medicationId: string;
|
|
unitsPerDose: number;
|
|
dosesPerUnit: number | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose));
|
|
const [packValue, setPackValue] = useState(
|
|
dosesPerUnit === null ? "" : String(dosesPerUnit),
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const parsedPerDose = Number(perDoseValue);
|
|
const perDoseValid =
|
|
Number.isInteger(parsedPerDose) &&
|
|
parsedPerDose >= 1 &&
|
|
parsedPerDose <= 100;
|
|
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
|
|
const packValid =
|
|
parsedPack === null ||
|
|
(Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000);
|
|
|
|
async function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!perDoseValid || !packValid || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await apiPut(`/api/medications/${medicationId}`, {
|
|
unitsPerDose: parsedPerDose,
|
|
dosesPerUnit: parsedPack,
|
|
});
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.medicationDetail(medicationId),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.medications(),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.medicationInventory(medicationId),
|
|
}),
|
|
]);
|
|
toast.success(t("medications.detail.bestand.packagingSuccess"));
|
|
onClose();
|
|
} catch {
|
|
toast.error(t("medications.detail.bestand.packagingFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("medications.detail.bestand.packagingTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("medications.detail.bestand.packagingDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="packaging-units-per-dose"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
|
|
</label>
|
|
<Input
|
|
id="packaging-units-per-dose"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={100}
|
|
step={1}
|
|
required
|
|
autoComplete="off"
|
|
value={perDoseValue}
|
|
onChange={(e) => setPerDoseValue(e.target.value)}
|
|
aria-describedby="packaging-units-per-dose-helper"
|
|
/>
|
|
<p
|
|
id="packaging-units-per-dose-helper"
|
|
className="text-muted-foreground text-xs"
|
|
>
|
|
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="packaging-default-pack"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("medications.detail.bestand.packagingDefaultPackLabel")}
|
|
</label>
|
|
<Input
|
|
id="packaging-default-pack"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={1000}
|
|
step={1}
|
|
autoComplete="off"
|
|
value={packValue}
|
|
onChange={(e) => setPackValue(e.target.value)}
|
|
aria-describedby="packaging-default-pack-helper"
|
|
/>
|
|
<p
|
|
id="packaging-default-pack-helper"
|
|
className="text-muted-foreground text-xs"
|
|
>
|
|
{t("medications.detail.bestand.packagingDefaultPackHelper")}
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={busy}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={!perDoseValid || !packValid || busy}
|
|
>
|
|
{busy && (
|
|
<Loader2
|
|
aria-hidden="true"
|
|
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
|
/>
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|