mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
1 parent
dacdcccfd2
commit
7d3557a3fd
53 files changed
+4118
-641
No files matched your search
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* v1.16.10 — `<AddInventoryDialog>` register-flow coverage.
|
||||
*
|
||||
* Pins the multi-unit quantity input: the segmented Dosen | Einheiten
|
||||
* control only surfaces when a dose spans more than one unit, the live
|
||||
* conversion line renders next to the quantity, the POST always carries
|
||||
* UNITS, and the container-type select defaults from the delivery form.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`,
|
||||
* no `@testing-library/react`) plus source-string structural assertions
|
||||
* for the interactive plumbing an SSR mount can't exercise.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The Radix Dialog / Select portal at runtime, so their bodies never
|
||||
// materialise in static markup. Collapse the primitives to plain
|
||||
// wrappers (same trick as the LogInjectionSiteDialog suite).
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: Pass,
|
||||
DialogDescription: Pass,
|
||||
DialogFooter: Pass,
|
||||
DialogHeader: Pass,
|
||||
DialogTitle: Pass,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Select: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children?: React.ReactNode;
|
||||
}) => <div data-mock-select-value={value}>{children}</div>,
|
||||
SelectContent: Pass,
|
||||
SelectItem: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-select-item">{children}</div>
|
||||
),
|
||||
SelectTrigger: Pass,
|
||||
SelectValue: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { AddInventoryDialog } = await import(
|
||||
"../sections/inventory-section"
|
||||
);
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const src = readFileSync(
|
||||
resolve(__dirname, "../sections/inventory-section.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("<AddInventoryDialog>", () => {
|
||||
it("surfaces the Dosen | Einheiten segmented control only for multi-unit doses", () => {
|
||||
const multi = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={4}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(multi).toContain('data-slot="inventory-quantity-mode"');
|
||||
expect(multi).toContain(">Dosen<");
|
||||
expect(multi).toContain(">Einheiten<");
|
||||
|
||||
const single = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={1}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(single).not.toContain('data-slot="inventory-quantity-mode"');
|
||||
});
|
||||
|
||||
it("renders the live conversion next to the prefilled quantity (units mode → dose equivalent)", () => {
|
||||
// Prefill 28 units at 4 units per dose → "≈ 7 Dosen".
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={28}
|
||||
unitsPerDose={4}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="inventory-quantity-conversion"');
|
||||
expect(html).toContain("≈ 7 Dosen");
|
||||
});
|
||||
|
||||
it("defaults the container type from the caller (delivery-form mapping upstream)", () => {
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={null}
|
||||
unitsPerDose={1}
|
||||
initialContainerType="PEN"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-mock-select-value="PEN"');
|
||||
// All six container kinds are offered.
|
||||
for (const label of [
|
||||
"Pen",
|
||||
"Ampulle",
|
||||
"Tablettenpackung",
|
||||
"Inhalator",
|
||||
"Flasche",
|
||||
"Sonstiges",
|
||||
]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("always submits UNITS: dose-mode input multiplies by unitsPerDose before the POST", () => {
|
||||
// Interactive mode-switching is out of SSR reach; the structural
|
||||
// contract lives in the source: the submitted value is the converted
|
||||
// unit count, and the wire field stays `dosesTotal`.
|
||||
expect(src).toMatch(
|
||||
/const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/,
|
||||
);
|
||||
expect(src).toMatch(/dosesTotal: units,/);
|
||||
expect(src).toMatch(/containerType,/);
|
||||
});
|
||||
|
||||
it("bounds the quantity to 1000 units (dose mode scales the max down)", () => {
|
||||
expect(src).toContain("units >= 1 && units <= 1000");
|
||||
expect(src).toContain('Math.floor(1000 / unitsPerDose)');
|
||||
});
|
||||
|
||||
it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => {
|
||||
expect(src).toMatch(
|
||||
/if \(deliveryForm === "INJECTION"\) return "PEN";\s*\n\s*if \(deliveryForm === "ORAL"\) return "BLISTER";\s*\n\s*return "OTHER";/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -106,3 +106,27 @@ describe("estimateRunwayDays", () => {
|
||||
).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => {
|
||||
it("runs off the dose-derived count: floor(units / unitsPerDose) before dividing by daily consumption", () => {
|
||||
// 9 units at 2 units per dose → floor(9 / 2) = 4 doses; a daily
|
||||
// single-dose schedule then reads 4 days of runway. The call sites
|
||||
// (Übersicht supply row, Bestand summary) derive the dose count this
|
||||
// way before handing it to the estimator.
|
||||
const unitsRemaining = 9;
|
||||
const unitsPerDose = 2;
|
||||
const dosesRemaining = Math.floor(unitsRemaining / unitsPerDose);
|
||||
expect(
|
||||
estimateRunwayDays(dosesRemaining, [schedule({ timesOfDay: ["08:00"] })]),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("a partial dose's worth of units never counts as a day", () => {
|
||||
// 1 unit at 2 units per dose is not a dose → no runway.
|
||||
expect(
|
||||
estimateRunwayDays(Math.floor(1 / 2), [
|
||||
schedule({ timesOfDay: ["08:00"] }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,8 @@ export interface MedicationDetailSnapshot {
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.16.10 — inventory units one dose consumes (default 1). */
|
||||
unitsPerDose?: number | null;
|
||||
trackInjectionSites?: boolean;
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
@@ -179,6 +181,7 @@ function snapshotToWizardPayload(
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
unitsPerDose: med.unitsPerDose ?? 1,
|
||||
trackInjectionSites: med.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: med.allowedInjectionSites ?? [],
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
@@ -314,8 +317,8 @@ export function MedicationDetailTabs({
|
||||
return await apiGet<{
|
||||
items: Array<{
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
}>;
|
||||
} | null>(`/api/medications/${id}/inventory`);
|
||||
} catch {
|
||||
@@ -328,11 +331,18 @@ export function MedicationDetailTabs({
|
||||
|
||||
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
|
||||
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
|
||||
const dosesRemaining = liveItems.reduce(
|
||||
(sum, i) => sum + i.dosesRemaining,
|
||||
// v1.16.10 — items count UNITS; the supply readout and the runway are
|
||||
// dose-derived (floor over the pooled units — consumption spills
|
||||
// across containers, so the pool is the honest dose count). The raw
|
||||
// unit tally renders as secondary text when a dose spans > 1 unit.
|
||||
const perDose = Math.max(1, medication.unitsPerDose ?? 1);
|
||||
const unitsRemaining = liveItems.reduce(
|
||||
(sum, i) => sum + i.unitsRemaining,
|
||||
0,
|
||||
);
|
||||
const dosesTotal = liveItems.reduce((sum, i) => sum + i.dosesTotal, 0);
|
||||
const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0);
|
||||
const dosesRemaining = Math.floor(unitsRemaining / perDose);
|
||||
const dosesTotal = Math.floor(unitsTotal / perDose);
|
||||
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
|
||||
|
||||
return (
|
||||
@@ -422,6 +432,16 @@ export function MedicationDetailTabs({
|
||||
remaining: dosesRemaining,
|
||||
total: dosesTotal,
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" ("}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: unitsRemaining,
|
||||
total: unitsTotal,
|
||||
})}
|
||||
{")"}
|
||||
</span>
|
||||
)}
|
||||
{runwayDays !== null && (
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
@@ -562,6 +582,8 @@ export function MedicationDetailTabs({
|
||||
<InventorySection
|
||||
medicationId={id}
|
||||
dosesPerUnit={medication.dosesPerUnit}
|
||||
unitsPerDose={medication.unitsPerDose}
|
||||
deliveryForm={medication.deliveryForm}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -6,20 +6,29 @@
|
||||
* v1.16.1 — the tab grows its write affordances:
|
||||
*
|
||||
* - 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.
|
||||
* 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
|
||||
* `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).
|
||||
*
|
||||
* 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 pen/vial CRUD route serves): a calm summary — the
|
||||
* 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 state badge. Shown for ALL medications (pill packs count
|
||||
* too); the richer pen math is implicit in the per-item doses figures.
|
||||
* with its container type and state badge. Shown for ALL medications
|
||||
* (pill packs count too).
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
@@ -39,6 +48,13 @@ import {
|
||||
} 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";
|
||||
@@ -46,11 +62,22 @@ import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
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;
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
containerType: ContainerType;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
}
|
||||
|
||||
interface InventoryResponse {
|
||||
@@ -65,18 +92,33 @@ const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "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 [addOpen, setAddOpen] = useState(false);
|
||||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
const perDose = Math.max(1, unitsPerDose ?? 1);
|
||||
|
||||
const { data, isLoading } = useQuery<InventoryResponse>({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
queryFn: async () => {
|
||||
@@ -102,17 +144,22 @@ export function InventorySection({
|
||||
}
|
||||
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
// Active supply: doses left across every non-terminal (not used-up) item.
|
||||
// Active supply: units left across every non-terminal (not used-up) item.
|
||||
const live = items.filter((i) => i.state !== "USED_UP");
|
||||
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
|
||||
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
|
||||
const remainingUnits = live.reduce((sum, i) => sum + i.unitsRemaining, 0);
|
||||
const totalUnits = live.reduce((sum, i) => sum + i.unitsTotal, 0);
|
||||
// Dose-derived headline (floor — a partial dose is not a dose).
|
||||
const remaining = Math.floor(remainingUnits / perDose);
|
||||
const total = Math.floor(totalUnits / perDose);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{addOpen && (
|
||||
<AddInventoryDialog
|
||||
medicationId={medicationId}
|
||||
defaultDosesTotal={dosesPerUnit ?? null}
|
||||
defaultUnitsTotal={dosesPerUnit ?? null}
|
||||
unitsPerDose={perDose}
|
||||
initialContainerType={defaultContainerType(deliveryForm)}
|
||||
onClose={() => setAddOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -155,6 +202,14 @@ export function InventorySection({
|
||||
<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: remainingUnits,
|
||||
total: totalUnits,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -180,10 +235,26 @@ export function InventorySection({
|
||||
data-slot="inventory-item-row"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: item.dosesRemaining,
|
||||
total: item.dosesTotal,
|
||||
})}
|
||||
<span className="block">
|
||||
{t(
|
||||
`medications.detail.bestand.containerType.${item.containerType}`,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("medications.detail.bestand.doses", {
|
||||
remaining: Math.floor(item.unitsRemaining / perDose),
|
||||
total: Math.floor(item.unitsTotal / perDose),
|
||||
})}
|
||||
{perDose > 1 && (
|
||||
<>
|
||||
{" · "}
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: item.unitsRemaining,
|
||||
total: item.unitsTotal,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
@@ -209,32 +280,47 @@ export function InventorySection({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function AddInventoryDialog({
|
||||
export function AddInventoryDialog({
|
||||
medicationId,
|
||||
defaultDosesTotal,
|
||||
defaultUnitsTotal,
|
||||
unitsPerDose,
|
||||
initialContainerType,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string;
|
||||
defaultDosesTotal: number | null;
|
||||
defaultUnitsTotal: number | null;
|
||||
unitsPerDose: number;
|
||||
initialContainerType: ContainerType;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dosesTotal, setDosesTotal] = useState(
|
||||
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
|
||||
? String(defaultDosesTotal)
|
||||
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(dosesTotal);
|
||||
const parsed = Number(quantity);
|
||||
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
|
||||
const units =
|
||||
effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
|
||||
const quantityValid =
|
||||
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
|
||||
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -242,7 +328,9 @@ function AddInventoryDialog({
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost(`/api/medications/${medicationId}/inventory`, {
|
||||
dosesTotal: parsed,
|
||||
// Wire field name is `dosesTotal`; it carries UNITS.
|
||||
dosesTotal: units,
|
||||
containerType,
|
||||
printedExpiry: expiry
|
||||
? new Date(`${expiry}T00:00:00`).toISOString()
|
||||
: null,
|
||||
@@ -269,6 +357,33 @@ function AddInventoryDialog({
|
||||
</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"
|
||||
@@ -276,19 +391,60 @@ function AddInventoryDialog({
|
||||
>
|
||||
{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={100}
|
||||
max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={dosesTotal}
|
||||
onChange={(e) => setDosesTotal(e.target.value)}
|
||||
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 })
|
||||
: t("medications.detail.bestand.quantityInDoses", {
|
||||
doses: Math.floor(parsed / unitsPerDose),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
@@ -336,7 +492,7 @@ function AddInventoryDialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock correction for one item: sets the remaining-dose count
|
||||
* 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.
|
||||
@@ -352,12 +508,12 @@ function AdjustInventoryDialog({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState(String(item.dosesRemaining));
|
||||
const [value, setValue] = useState(String(item.unitsRemaining));
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const parsed = Number(value);
|
||||
const valid =
|
||||
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal;
|
||||
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -365,6 +521,7 @@ function AdjustInventoryDialog({
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
|
||||
// Wire field name is `dosesRemaining`; it carries UNITS.
|
||||
dosesRemaining: parsed,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -403,7 +560,7 @@ function AdjustInventoryDialog({
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={item.dosesTotal}
|
||||
max={item.unitsTotal}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -416,7 +573,7 @@ function AdjustInventoryDialog({
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total: item.dosesTotal,
|
||||
total: item.unitsTotal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -383,6 +383,50 @@ describe("buildCreateBody", () => {
|
||||
expect(body.schedules[0].windowEnd).toBe("09:00");
|
||||
});
|
||||
|
||||
it("carries unitsPerDose onto the body and round-trips it on edit-hydrate (v1.16.10)", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
treatmentRow: "bloodPressure",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
unitsPerDose: "2",
|
||||
};
|
||||
expect(buildCreateBody(p).unitsPerDose).toBe(2);
|
||||
|
||||
// The default "1" still ships (an edit back to 1 must reach the server).
|
||||
expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1);
|
||||
// An empty field omits it (server default applies).
|
||||
expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined();
|
||||
|
||||
const hydrated = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Foo",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
unitsPerDose: 2,
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [],
|
||||
});
|
||||
expect(hydrated.unitsPerDose).toBe("2");
|
||||
// Absent on the snapshot → the default 1.
|
||||
const hydratedDefault = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Foo",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [],
|
||||
});
|
||||
expect(hydratedDefault.unitsPerDose).toBe("1");
|
||||
});
|
||||
|
||||
it("emits DIABETES category for the diabetes row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
|
||||
@@ -147,13 +147,14 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
value={payload.dosesPerUnit}
|
||||
onChange={(e) =>
|
||||
applyPartial({
|
||||
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
|
||||
// v1.16.10 — the cap rose to 1000, so four digits.
|
||||
dosesPerUnit: e.target.value.replace(/[^0-9]/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"medications.wizard.steps.step3.dosesPerUnitPlaceholder",
|
||||
)}
|
||||
maxLength={3}
|
||||
maxLength={4}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -162,6 +163,33 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.16.10 — units consumed per dose. The inventory counts units
|
||||
(tablets / ampoules); a multi-unit dose (2 × 2 mg for 4 mg)
|
||||
decrements several per take and every dose-level readout
|
||||
divides by this factor. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-units-per-dose" className="text-sm">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-units-per-dose"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={payload.unitsPerDose}
|
||||
onChange={(e) =>
|
||||
applyPartial({
|
||||
unitsPerDose: e.target.value.replace(/[^0-9]/g, "").slice(0, 3),
|
||||
})
|
||||
}
|
||||
placeholder="1"
|
||||
maxLength={3}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.unitsPerDoseHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor.
|
||||
Shown only for an INJECTION delivery form. Default off; enabling
|
||||
it surfaces the post-dose site prompt and the rotation
|
||||
|
||||
@@ -219,6 +219,11 @@ export interface WizardPayload {
|
||||
* inventory tracking off (maps to `undefined` / NULL on the wire).
|
||||
*/
|
||||
dosesPerUnit: string;
|
||||
/**
|
||||
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets for
|
||||
* a 4 mg dose). Empty string / "1" both map to the server default 1.
|
||||
*/
|
||||
unitsPerDose: string;
|
||||
/**
|
||||
* v1.8.5 — per-medication injection-site tracking opt-in. Only
|
||||
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
|
||||
@@ -283,6 +288,7 @@ export function emptyWizardPayload(): WizardPayload {
|
||||
treatmentRow: null,
|
||||
deliveryForm: "ORAL",
|
||||
dosesPerUnit: "",
|
||||
unitsPerDose: "1",
|
||||
trackInjectionSites: false,
|
||||
allowedInjectionSites: [],
|
||||
mode: draft.mode,
|
||||
@@ -528,6 +534,8 @@ export interface CreateMedicationBody {
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
|
||||
dosesPerUnit?: number;
|
||||
/** v1.16.10 — inventory units per dose. Omitted at the default 1. */
|
||||
unitsPerDose?: number;
|
||||
/** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */
|
||||
@@ -673,6 +681,7 @@ export function buildCreateBody(
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
@@ -683,6 +692,12 @@ export function buildCreateBody(
|
||||
parsedDosesPerUnit >= 1 && {
|
||||
dosesPerUnit: parsedDosesPerUnit,
|
||||
}),
|
||||
// v1.16.10 — always sent when valid (an edit back to 1 must reach
|
||||
// the server; the create default matches the schema default).
|
||||
...(Number.isFinite(parsedUnitsPerDose) &&
|
||||
parsedUnitsPerDose >= 1 && {
|
||||
unitsPerDose: parsedUnitsPerDose,
|
||||
}),
|
||||
// v1.8.5 — injection-site tracking is only meaningful for an
|
||||
// INJECTION delivery form. Always send the boolean for an injection
|
||||
// (so edits can deactivate it); send the allowed list only when
|
||||
@@ -903,6 +918,8 @@ export interface MedicationPayload {
|
||||
deliveryForm?: string;
|
||||
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.16.10 — inventory units per dose (default 1). */
|
||||
unitsPerDose?: number | null;
|
||||
/** v1.8.5 — injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
@@ -1014,6 +1031,10 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
unitsPerDose:
|
||||
typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1
|
||||
? String(initial.unitsPerDose)
|
||||
: "1",
|
||||
trackInjectionSites: initial.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (initial.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
|
||||
Reference in new issue
Block a user