fix(medications): release-QA findings on the inventory overhaul

- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall
  back to ledger-derived pen/dose figures when a medication has no
  inventory items; items always win once they exist. The delta branch
  on the POST is marked deprecated in favour of the inventory endpoints.
- Expired containers count as available nowhere: one shared supply
  summary (ACTIVE/IN_USE with units) feeds the overview row, the
  Bestand summary, the list payload, the GLP-1 card, and the runway
  estimate; expired units surface as a muted suffix instead.
- The inventory endpoints speak one wire dialect: request fields renamed
  to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated.
- The reorder dialog mirrors the page's grouping: active and inactive
  sections, each sortable within itself, so a saved order can never
  interleave them.
- Polish strings avoid the 2-4 plural trap, sub-dose quantities render
  as below one dose instead of approximately zero, and the table's name
  sort collates by the active locale instead of hardcoded German.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 19:11:53 +02:00
1 parent b765febc97
commit 9dc0ccd03a
28 files changed
+1017 -180

No files matched your search

@@ -152,14 +152,30 @@ describe("<AddInventoryDialog>", () => {
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`.
// unit count on the v1.16.10 symmetric wire field `unitsTotal`.
expect(src).toMatch(
/const units =\s*\n?\s*effectiveMode === "doses" \? parsed \* unitsPerDose : parsed;/,
);
expect(src).toMatch(/dosesTotal: units,/);
expect(src).toMatch(/unitsTotal: units,/);
expect(src).toMatch(/containerType,/);
});
it("renders '< 1 Dosis' instead of '≈ 0 Dosen' for a sub-dose unit count", () => {
// 3 units at 4 units per dose is less than one dose — the
// conversion line must say so rather than rounding to zero.
const html = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={3}
unitsPerDose={4}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(html).toContain("&lt; 1 Dosis");
expect(html).not.toContain("≈ 0 Dosen");
});
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)');
@@ -0,0 +1,113 @@
/**
* v1.16.10 — Bestand summary availability semantics.
*
* Renders `<InventorySection>` with a seeded query cache and pins the
* release-QA scenario: a single EXPIRED pen with units left shows ZERO
* available doses in the headline, with the expired units as a muted
* suffix — matching the list payload and the GLP-1 endpoint instead of
* counting expired stock as supply.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useQueryClient: () => ({
invalidateQueries: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const { InventorySection } = await import("../sections/inventory-section");
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
function seedItems(items: unknown[]) {
useQueryMock.mockReturnValue({
data: { items, meta: { total: items.length } },
isLoading: false,
});
}
describe("<InventorySection> — summary availability", () => {
it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => {
seedItems([
{
id: "i1",
state: "EXPIRED",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain("0 von 0 Dosen übrig");
expect(html).toContain('data-slot="inventory-expired-suffix"');
expect(html).toContain("+ 4 Einheiten abgelaufen");
// The item row itself stays visible with its state badge.
expect(html).toContain("Abgelaufen");
});
it("usable stock counts; expired suffix absent when nothing expired", () => {
seedItems([
{
id: "i1",
state: "ACTIVE",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
},
{
id: "i2",
state: "USED_UP",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 0,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain("4 von 4 Dosen übrig");
expect(html).not.toContain('data-slot="inventory-expired-suffix"');
});
it("mixed usable + expired: headline counts only the usable pool", () => {
seedItems([
{
id: "i1",
state: "IN_USE",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 2,
},
{
id: "i2",
state: "EXPIRED",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 3,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain("2 von 4 Dosen übrig");
expect(html).toContain("+ 3 Einheiten abgelaufen");
});
});
@@ -0,0 +1,172 @@
/**
* v1.16.10 — reorder dialog mirrors the page's grouping.
*
* Both /medications views pin inactive medications after the active
* block, so the dialog drafts TWO sections (Aktiv / Inaktiv) with a
* muted heading each: drag and arrows work within a section, and the
* saved order is active ids first, inactive ids after — by
* construction, not by validation.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`)
* plus pure-helper tests for the interactive plumbing an SSR mount
* can't exercise, plus source-string structural assertions.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// Radix Dialog portals at runtime; collapse to plain wrappers so the
// body materialises in static markup (same trick as the sibling suites).
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("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("@/lib/charts/reduced-motion", () => ({
prefersReducedMotion: () => true,
}));
vi.mock("@/lib/queries/use-medication-list-layout", () => ({
runSaveMedicationListOrder: vi.fn().mockResolvedValue(true),
}));
const { MedicationReorderDialog, buildSavedOrder, moveWithinSection } =
await import("../medication-reorder-dialog");
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
const MEDS = [
{ id: "a1", name: "Ramipril", dose: "5 mg", active: true },
{ id: "a2", name: "Mounjaro", dose: "7.5 mg", active: true },
{ id: "i1", name: "Amoxicillin", dose: "500 mg", active: false },
{ id: "i2", name: "Ibuprofen", dose: "400 mg", active: false },
];
describe("moveWithinSection — moves stay inside the group", () => {
it("swaps neighbours within the section (the arrow / drag contract)", () => {
expect(moveWithinSection(["a1", "a2"], "a2", -1)).toEqual(["a2", "a1"]);
expect(moveWithinSection(["i1", "i2"], "i1", 1)).toEqual(["i2", "i1"]);
});
it("cannot move past either section edge — the group boundary is unreachable", () => {
expect(moveWithinSection(["a1", "a2"], "a1", -1)).toEqual(["a1", "a2"]);
expect(moveWithinSection(["a1", "a2"], "a2", 1)).toEqual(["a1", "a2"]);
// The first inactive row moving "up" stays where it is — it cannot
// climb into the active block.
expect(moveWithinSection(["i1", "i2"], "i1", -1)).toEqual(["i1", "i2"]);
});
});
describe("buildSavedOrder — active ids always precede inactive ids", () => {
it("composes active block first regardless of within-group churn", () => {
expect(buildSavedOrder(["a2", "a1"], ["i2", "i1"])).toEqual([
"a2",
"a1",
"i2",
"i1",
]);
});
it("an inactive med can never land above an active one in the SAVED order", () => {
// Drive the dialog's own move primitive as far up as it allows and
// compose the save payload exactly like save() does.
let inactive = ["i1", "i2"];
inactive = moveWithinSection(inactive, "i2", -1); // i2 to the top of its group
inactive = moveWithinSection(inactive, "i2", -1); // edge — no-op
const saved = buildSavedOrder(["a1", "a2"], inactive);
expect(saved).toEqual(["a1", "a2", "i2", "i1"]);
const firstInactiveIdx = Math.min(
saved.indexOf("i1"),
saved.indexOf("i2"),
);
const lastActiveIdx = Math.max(saved.indexOf("a1"), saved.indexOf("a2"));
expect(firstInactiveIdx).toBeGreaterThan(lastActiveIdx);
});
});
describe("<MedicationReorderDialog> — two-section rendering", () => {
it("renders an Aktiv and an Inaktiv section with the rows grouped", () => {
const html = render(
<MedicationReorderDialog
open
onOpenChange={() => {}}
medications={MEDS}
/>,
);
expect(html).toContain('data-slot="medication-reorder-section-active"');
expect(html).toContain('data-slot="medication-reorder-section-inactive"');
// Muted group headings.
expect(html).toContain(">Aktiv</p>");
expect(html).toContain(">Inaktiv</p>");
// Every active row renders before every inactive row.
const lastActive = Math.max(
html.indexOf("Ramipril"),
html.indexOf("Mounjaro"),
);
const firstInactive = Math.min(
html.indexOf("Amoxicillin"),
html.indexOf("Ibuprofen"),
);
expect(lastActive).toBeLessThan(firstInactive);
});
it("omits the Inaktiv section when every medication is active", () => {
const html = render(
<MedicationReorderDialog
open
onOpenChange={() => {}}
medications={MEDS.filter((m) => m.active)}
/>,
);
expect(html).toContain('data-slot="medication-reorder-section-active"');
expect(html).not.toContain(
'data-slot="medication-reorder-section-inactive"',
);
});
});
describe("medication-reorder-dialog — structural guards (source)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/medications/medication-reorder-dialog.tsx",
),
"utf8",
);
it("saves through buildSavedOrder (active ids, then inactive ids)", () => {
expect(src).toContain("buildSavedOrder(activeIds, inactiveIds)");
});
it("each section owns its own DndContext, so a drag cannot cross groups", () => {
// One <DndContext> inside the per-section component, none at the
// dialog level spanning both lists.
const sectionBody = src.slice(src.indexOf("function ReorderSection"));
expect(sectionBody).toContain("<DndContext");
expect(
src.slice(0, src.indexOf("function ReorderSection")),
).not.toContain("<DndContext");
});
});
@@ -403,6 +403,20 @@ describe("sortMedicationRows — null-last semantics", () => {
}).map((m) => m.id),
).toEqual(["b", "c", "a"]);
});
it("collates names by the active locale, not a hardcoded one", () => {
// Swedish sorts Ö after Z; German folds Ö next to O. The same two
// rows must order differently under the two locales.
const oe = med({ id: "oe", name: "Övriga" });
const z = med({ id: "z", name: "Zyrtec" });
const sort = { column: "name", direction: "asc" } as const;
expect(
sortMedicationRows([z, oe], sort, undefined, "sv").map((m) => m.id),
).toEqual(["z", "oe"]);
expect(
sortMedicationRows([z, oe], sort, undefined, "de").map((m) => m.id),
).toEqual(["oe", "z"]);
});
});
describe("<MedicationTableSkeleton> — reserved footprint", () => {
@@ -71,6 +71,7 @@ import { PhaseConfigSheet } from "@/components/medications/sections/phase-config
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
@@ -330,19 +331,21 @@ export function MedicationDetailTabs({
});
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
const liveItems = inventoryItems.filter((i) => i.state !== "USED_UP");
// 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.
// Availability rides the shared summary helper (ACTIVE / IN_USE with
// units only — the list / GLP-1 semantic); expired stock surfaces as
// a separate muted suffix and never feeds the runway.
const perDose = Math.max(1, medication.unitsPerDose ?? 1);
const unitsRemaining = liveItems.reduce(
(sum, i) => sum + i.unitsRemaining,
0,
);
const unitsTotal = liveItems.reduce((sum, i) => sum + i.unitsTotal, 0);
const dosesRemaining = Math.floor(unitsRemaining / perDose);
const dosesTotal = Math.floor(unitsTotal / perDose);
const {
unitsRemaining,
unitsTotal,
dosesRemaining,
dosesTotal,
expiredUnits,
} = summariseSupply(inventoryItems, perDose);
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
@@ -442,6 +445,14 @@ export function MedicationDetailTabs({
{")"}
</span>
)}
{expiredUnits > 0 && (
<span className="text-muted-foreground">
{" · "}
{t("medications.detail.bestand.expiredSuffix", {
units: expiredUnits,
})}
</span>
)}
{runwayDays !== null && (
<span className="text-muted-foreground">
{" · "}
@@ -44,10 +44,14 @@ import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-la
* mode rides (the standalone extraction of the dashboard's
* `reorderWidgets` contract), so the surfaces cannot drift.
*
* The dialog edits a draft of the full medication list (active first,
* inactive after — matching the page's render grouping); Save persists
* the id order through `PUT /api/medications/layout` (order-only, the
* stored view is preserved server-side).
* The dialog mirrors the page's grouping: an Aktiv section and an
* Inaktiv section (muted heading), each with its own drag context and
* arrow bounds, so a row can only move WITHIN its group — both views
* pin inactive medications after the active block, and an order that
* crossed the boundary could never render. Save persists the id order
* (active ids first, then inactive ids) through
* `PUT /api/medications/layout` (order-only, the stored view is
* preserved server-side).
*/
export interface ReorderMedication {
@@ -64,6 +68,38 @@ interface MedicationReorderDialogProps {
medications: ReorderMedication[];
}
/**
* Saved-order composition: active ids always precede inactive ids,
* matching the render grouping of both list views. Exported so tests
* can pin that an inactive id cannot land above an active one.
*/
export function buildSavedOrder(
activeIds: readonly string[],
inactiveIds: readonly string[],
): string[] {
return [...activeIds, ...inactiveIds];
}
/**
* Arrow-button move, bounded to the section: swapping past either end
* returns the input untouched, so the boundary between the groups is
* unreachable by construction.
*/
export function moveWithinSection(
ids: readonly string[],
id: string,
delta: -1 | 1,
): string[] {
const idx = ids.indexOf(id);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return [...ids];
return reorderById(
ids.map((rowId, order) => ({ id: rowId, order })),
id,
ids[targetIdx],
).map((r) => r.id);
}
export function MedicationReorderDialog({
open,
onOpenChange,
@@ -73,67 +109,52 @@ export function MedicationReorderDialog({
const queryClient = useQueryClient();
const dragHintId = useId();
const [saving, setSaving] = useState(false);
// Draft id order — null means "the order the page passed in". Created
// on the first move so an untouched dialog can cancel without churn.
const [draft, setDraft] = useState<string[] | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Per-section draft id order — null means "the order the page passed
// in". Created on the first move so an untouched dialog can cancel
// without churn.
const [draftActive, setDraftActive] = useState<string[] | null>(null);
const [draftInactive, setDraftInactive] = useState<string[] | null>(null);
const byId = new Map(medications.map((m) => [m.id, m]));
const ids =
draft ?? medications.map((m) => m.id);
const rows = ids
.map((id) => byId.get(id))
.filter((m): m is ReorderMedication => m !== undefined);
const activeIds =
draftActive ??
medications.filter((m) => m.active).map((m) => m.id);
const inactiveIds =
draftInactive ??
medications.filter((m) => !m.active).map((m) => m.id);
function applyReorder(fromId: string, toId: string) {
const reordered = reorderById(
ids.map((id, order) => ({ id, order })),
fromId,
toId,
);
setDraft(reordered.map((r) => r.id));
}
function move(id: string, delta: -1 | 1) {
const idx = ids.indexOf(id);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return;
applyReorder(id, ids[targetIdx]);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
applyReorder(String(active.id), String(over.id));
}
const labels = {
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
inactive: t("common.inactive"),
};
async function save() {
setSaving(true);
const ok = await runSaveMedicationListOrder({
order: ids,
order: buildSavedOrder(activeIds, inactiveIds),
queryClient,
t,
});
setSaving(false);
if (ok) {
setDraft(null);
setDraftActive(null);
setDraftInactive(null);
onOpenChange(false);
}
}
function close(next: boolean) {
if (!next) setDraft(null);
if (!next) {
setDraftActive(null);
setDraftInactive(null);
}
onOpenChange(next);
}
const dirty = draftActive !== null || draftInactive !== null;
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-md">
@@ -144,33 +165,32 @@ export function MedicationReorderDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{rows.map((med, index) => (
<SortableMedicationRow
key={med.id}
medication={med}
index={index}
total={rows.length}
dragHintId={dragHintId}
disabled={saving}
labels={{
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
inactive: t("common.inactive"),
}}
onMove={move}
/>
))}
</SortableContext>
</DndContext>
{rows.length > 0 && (
<div className="space-y-4">
{activeIds.length > 0 && (
<ReorderSection
heading={t("common.active")}
ids={activeIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftActive}
dataSlot="medication-reorder-section-active"
/>
)}
{inactiveIds.length > 0 && (
<ReorderSection
heading={t("common.inactive")}
ids={inactiveIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftInactive}
dataSlot="medication-reorder-section-inactive"
/>
)}
{activeIds.length + inactiveIds.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
@@ -185,7 +205,7 @@ export function MedicationReorderDialog({
>
{t("common.cancel")}
</Button>
<Button onClick={() => void save()} disabled={saving || !draft}>
<Button onClick={() => void save()} disabled={saving || !dirty}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
@@ -197,6 +217,99 @@ export function MedicationReorderDialog({
);
}
interface ReorderSectionProps {
heading: string;
ids: string[];
byId: ReadonlyMap<string, ReorderMedication>;
dragHintId: string;
disabled: boolean;
labels: {
moveUp: string;
moveDown: string;
dragHandle: string;
inactive: string;
};
onChange: (ids: string[]) => void;
dataSlot: string;
}
/**
* One group (Aktiv or Inaktiv) with its OWN DndContext + SortableContext:
* a drag started in one section cannot drop into the other, and the
* arrow buttons stop at the section bounds — the saved order keeps the
* active block first by construction.
*/
function ReorderSection({
heading,
ids,
byId,
dragHintId,
disabled,
labels,
onChange,
dataSlot,
}: ReorderSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const rows = ids
.map((id) => byId.get(id))
.filter((m): m is ReorderMedication => m !== undefined);
function applyReorder(fromId: string, toId: string) {
onChange(
reorderById(
ids.map((id, order) => ({ id, order })),
fromId,
toId,
).map((r) => r.id),
);
}
function move(id: string, delta: -1 | 1) {
const next = moveWithinSection(ids, id, delta);
if (next.some((v, i) => v !== ids[i])) onChange(next);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
applyReorder(String(active.id), String(over.id));
}
return (
<div className="space-y-2" data-slot={dataSlot}>
<p className="text-muted-foreground text-xs font-medium">{heading}</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{rows.map((med, index) => (
<SortableMedicationRow
key={med.id}
medication={med}
index={index}
total={rows.length}
dragHintId={dragHintId}
disabled={disabled}
labels={labels}
onMove={move}
/>
))}
</SortableContext>
</DndContext>
</div>
);
}
interface SortableMedicationRowProps {
medication: ReorderMedication;
index: number;
@@ -125,6 +125,8 @@ export function sortMedicationRows<T extends TableMedication>(
rows: readonly T[],
sort: MedicationTableSort | null,
shortRateById?: ReadonlyMap<string, number>,
/** Active UI locale for name collation; omitted = runtime default. */
locale?: string,
): T[] {
if (!sort) return [...rows];
const dir = sort.direction === "asc" ? 1 : -1;
@@ -150,7 +152,7 @@ export function sortMedicationRows<T extends TableMedication>(
if (va === null) return 1;
if (vb === null) return -1;
if (typeof va === "string" && typeof vb === "string") {
return va.localeCompare(vb, "de", { sensitivity: "base" }) * dir;
return va.localeCompare(vb, locale, { sensitivity: "base" }) * dir;
}
return ((va as number) - (vb as number)) * dir;
});
@@ -172,7 +174,7 @@ export function MedicationTable({
inactiveMedications,
initialSort = null,
}: MedicationTableProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const [sort, setSort] = useState<MedicationTableSort | null>(initialSort);
@@ -211,11 +213,17 @@ export function MedicationTable({
return () => clearInterval(interval);
}, []);
const sortedActive = sortMedicationRows(activeMedications, sort, shortRateById);
const sortedActive = sortMedicationRows(
activeMedications,
sort,
shortRateById,
locale,
);
const sortedInactive = sortMedicationRows(
inactiveMedications,
sort,
shortRateById,
locale,
);
const lateMinutes = thresholds?.lateMinutes ?? 120;
@@ -11,7 +11,7 @@
* 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 —
* `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).
@@ -59,6 +59,7 @@ import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
@@ -144,13 +145,18 @@ export function InventorySection({
}
const items = Array.isArray(data?.items) ? data.items : [];
// Active supply: units left across every non-terminal (not used-up) item.
const live = items.filter((i) => i.state !== "USED_UP");
const remainingUnits = live.reduce((sum, i) => sum + i.unitsRemaining, 0);
const totalUnits = live.reduce((sum, i) => sum + i.unitsTotal, 0);
// 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).
const remaining = Math.floor(remainingUnits / perDose);
const total = Math.floor(totalUnits / perDose);
const {
unitsRemaining: remainingUnits,
unitsTotal: totalUnits,
dosesRemaining: remaining,
dosesTotal: total,
expiredUnits,
} = summariseSupply(items, perDose);
const dialogs = (
<>
@@ -210,6 +216,16 @@ export function InventorySection({
})}
</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"
@@ -328,8 +344,8 @@ export function AddInventoryDialog({
setBusy(true);
try {
await apiPost(`/api/medications/${medicationId}/inventory`, {
// Wire field name is `dosesTotal`; it carries UNITS.
dosesTotal: units,
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsTotal: units,
containerType,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
@@ -440,9 +456,13 @@ export function AddInventoryDialog({
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
: // 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
@@ -521,8 +541,8 @@ function AdjustInventoryDialog({
setBusy(true);
try {
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
// Wire field name is `dosesRemaining`; it carries UNITS.
dosesRemaining: parsed,
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
});
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),