mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b765febc97
commit
9dc0ccd03a
28 files changed
+1017
-180
No files matched your search
@@ -2377,7 +2377,7 @@ paths:
|
||||
tags:
|
||||
- Medications
|
||||
summary: Register a new supply container
|
||||
description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field
|
||||
description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field
|
||||
carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -2413,7 +2413,7 @@ paths:
|
||||
- Medications
|
||||
summary: Mutate a supply container
|
||||
description: "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`),
|
||||
printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's
|
||||
printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's
|
||||
capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as
|
||||
`medication.inventory.update`."
|
||||
parameters:
|
||||
@@ -5177,12 +5177,12 @@ components:
|
||||
CreateMedicationInventoryItemRequest:
|
||||
type: object
|
||||
properties:
|
||||
dosesTotal:
|
||||
unitsTotal:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived
|
||||
readouts divide by the medication's `unitsPerDose`.
|
||||
description: Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the
|
||||
medication's `unitsPerDose`.
|
||||
containerType:
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only;
|
||||
defaults to OTHER.
|
||||
@@ -5212,8 +5212,8 @@ components:
|
||||
maxLength: 200
|
||||
- type: "null"
|
||||
required:
|
||||
- dosesTotal
|
||||
description: Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item
|
||||
- unitsTotal
|
||||
description: Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item
|
||||
starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.
|
||||
UpdateMedicationInventoryItemRequest:
|
||||
type: object
|
||||
@@ -5233,7 +5233,7 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
dosesRemaining:
|
||||
unitsRemaining:
|
||||
description: Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical
|
||||
state machine re-derives the state (0 ⇒ USED_UP).
|
||||
type: integer
|
||||
|
||||
+3
-1
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Dosen",
|
||||
"quantityModeUnits": "Einheiten",
|
||||
"quantityInUnits": "= {units} Einheiten",
|
||||
"quantityInDoses": "≈ {doses} Dosen"
|
||||
"quantityInDoses": "≈ {doses} Dosen",
|
||||
"quantityUnderOneDose": "< 1 Dosis",
|
||||
"expiredSuffix": "+ {units} Einheiten abgelaufen"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
|
||||
+3
-1
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Doses",
|
||||
"quantityModeUnits": "Units",
|
||||
"quantityInUnits": "= {units} units",
|
||||
"quantityInDoses": "≈ {doses} doses"
|
||||
"quantityInDoses": "≈ {doses} doses",
|
||||
"quantityUnderOneDose": "< 1 dose",
|
||||
"expiredSuffix": "+ {units} units expired"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
|
||||
+3
-1
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Dosis",
|
||||
"quantityModeUnits": "Unidades",
|
||||
"quantityInUnits": "= {units} unidades",
|
||||
"quantityInDoses": "≈ {doses} dosis"
|
||||
"quantityInDoses": "≈ {doses} dosis",
|
||||
"quantityUnderOneDose": "< 1 dosis",
|
||||
"expiredSuffix": "+ {units} unidades caducadas"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
|
||||
+3
-1
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Doses",
|
||||
"quantityModeUnits": "Unités",
|
||||
"quantityInUnits": "= {units} unités",
|
||||
"quantityInDoses": "≈ {doses} doses"
|
||||
"quantityInDoses": "≈ {doses} doses",
|
||||
"quantityUnderOneDose": "< 1 dose",
|
||||
"expiredSuffix": "+ {units} unités périmées"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
|
||||
+3
-1
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Dosi",
|
||||
"quantityModeUnits": "Unità",
|
||||
"quantityInUnits": "= {units} unità",
|
||||
"quantityInDoses": "≈ {doses} dosi"
|
||||
"quantityInDoses": "≈ {doses} dosi",
|
||||
"quantityUnderOneDose": "< 1 dose",
|
||||
"expiredSuffix": "+ {units} unità scadute"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
|
||||
+5
-3
@@ -773,7 +773,7 @@
|
||||
"complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu",
|
||||
"complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.",
|
||||
"complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
|
||||
"complianceDoses": "{count} dawek",
|
||||
"complianceDoses": "Dawki: {count}",
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć",
|
||||
@@ -1629,7 +1629,9 @@
|
||||
"quantityModeDoses": "Dawki",
|
||||
"quantityModeUnits": "Jednostki",
|
||||
"quantityInUnits": "= {units} jednostek",
|
||||
"quantityInDoses": "≈ {doses} dawek"
|
||||
"quantityInDoses": "Dawki: ≈ {doses}",
|
||||
"quantityUnderOneDose": "< 1 dawka",
|
||||
"expiredSuffix": "+ przeterminowane jednostki: {units}"
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1729,7 +1731,7 @@
|
||||
"tableSortBy": "Sortuj według {column}",
|
||||
"tableComplianceDays": "{days} dni",
|
||||
"tableStockDoseOne": "1 dawka",
|
||||
"tableStockDoses": "{count} dawek",
|
||||
"tableStockDoses": "Dawki: {count}",
|
||||
"takeActionFor": "Przyjmij – {name}",
|
||||
"skipActionFor": "Pomiń – {name}",
|
||||
"reorderTitle": "Zmień kolejność",
|
||||
|
||||
@@ -405,6 +405,7 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () =>
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
@@ -455,6 +456,9 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
// Not usable: expired stock is not supply.
|
||||
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
|
||||
],
|
||||
// Items exist, so the legacy ledger below must be IGNORED — a
|
||||
// stale delta history cannot override the per-item truth.
|
||||
inventoryEvents: [{ delta: 9 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
@@ -480,7 +484,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null inventory block when no items exist", async () => {
|
||||
it("returns a null inventory block when no items and no ledger rows exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
@@ -491,6 +495,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
dosesPerUnit: null,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
@@ -500,4 +505,68 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
const body = (await res.json()) as { data: { inventory: unknown } };
|
||||
expect(body.data.inventory).toBeNull();
|
||||
});
|
||||
|
||||
// W1 — the legacy delta ledger the POST below still accepts must stay
|
||||
// readable: an account that only ever posted `{inventory: {delta}}`
|
||||
// sees its pen count on GET instead of a silent null.
|
||||
it("falls back to the legacy ledger when the user has zero inventory items", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
// Running sum: +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses.
|
||||
inventoryEvents: [{ delta: 2 }, { delta: -1 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
inventory: {
|
||||
pensRemaining: number;
|
||||
dosesRemaining: number;
|
||||
weeksOfSupply: number;
|
||||
lowStock: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.inventory).toEqual({
|
||||
pensRemaining: 1,
|
||||
dosesRemaining: 4,
|
||||
weeksOfSupply: 4,
|
||||
lowStock: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the ledger fallback when dosesPerUnit is not configured", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [{ delta: 2 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { inventory: unknown } };
|
||||
// Pre-item contract: without `dosesPerUnit` the ledger never
|
||||
// produced an inventory block either.
|
||||
expect(body.data.inventory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -56,9 +56,12 @@ export const GET = apiHandler(
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
// v1.16.10 — the inventory block reads the per-item entities
|
||||
// (the same rows the Bestand tab and the intake consumption
|
||||
// hook move), replacing the legacy MedicationInventoryEvent
|
||||
// running-sum ledger that no write path feeds any more.
|
||||
// hook move). The legacy MedicationInventoryEvent running-sum
|
||||
// ledger stays as a READ fallback for accounts that only ever
|
||||
// posted deltas: when zero items exist, the ledger-derived
|
||||
// numbers surface instead of a silent null.
|
||||
inventoryItems: { orderBy: { createdAt: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -107,6 +110,30 @@ export const GET = apiHandler(
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
} else if (
|
||||
medication.dosesPerUnit &&
|
||||
medication.inventoryEvents.length > 0
|
||||
) {
|
||||
// Read-side fallback for ledger-only accounts (pre-v1.16.10 the
|
||||
// POST below was the only inventory writer). Items win whenever
|
||||
// any exist — the ledger never overrides them and no item rows
|
||||
// are fabricated from it. Math is the pre-item contract verbatim:
|
||||
// running sum of deltas counts pens, `dosesPerUnit` maps pens to
|
||||
// doses, weekly cadence approximates the supply horizon.
|
||||
const pens = medication.inventoryEvents.reduce(
|
||||
(sum, ev) => sum + ev.delta,
|
||||
0,
|
||||
);
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
@@ -213,6 +240,14 @@ export const POST = apiHandler(
|
||||
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
//
|
||||
// DEPRECATED branch (v1.16.10): this writes the legacy
|
||||
// MedicationInventoryEvent running-sum ledger. The per-item
|
||||
// endpoints (`POST /api/medications/[id]/inventory`,
|
||||
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
|
||||
// GET above and the Coach snapshot read the ledger only while the
|
||||
// medication has zero inventory items. New callers must register
|
||||
// containers instead of posting deltas.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
|
||||
* /api/medications/[id]/inventory/[itemId].
|
||||
*
|
||||
* v1.16.1 — stock-correction contract: `dosesRemaining` sets the count
|
||||
* absolutely, clamps to `dosesTotal`, and the canonical state machine
|
||||
* v1.16.1 — stock-correction contract: `unitsRemaining` sets the count
|
||||
* absolutely, clamps to `unitsTotal`, and the canonical state machine
|
||||
* derives the next state (0 ⇒ USED_UP).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -83,7 +83,7 @@ beforeEach(() => {
|
||||
userId: "user-1",
|
||||
firstUseAt: null,
|
||||
state: "ACTIVE",
|
||||
dosesRemaining: 4,
|
||||
unitsRemaining: 4,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => {
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
id: "i1",
|
||||
@@ -151,7 +151,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("sets the remaining count absolutely", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
@@ -159,7 +159,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("derives USED_UP when corrected to zero", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } };
|
||||
@@ -168,7 +168,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("clamps a raise above the item's capacity to unitsTotal", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
@@ -176,7 +176,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("rejects a negative correction with 422", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,10 @@
|
||||
* - mark-as-used-up (terminal — operator override when a pen is
|
||||
* physically discarded but the dose ledger hasn't reached zero)
|
||||
* - update printed expiry (e.g. carton label correction)
|
||||
* - stock correction via `dosesRemaining` (v1.16.1 — the Bestand
|
||||
* tab's adjust / withdraw flow; clamped to `dosesTotal`, state
|
||||
* re-derived by the canonical state machine)
|
||||
* - stock correction via `unitsRemaining` (v1.16.1 — the Bestand
|
||||
* tab's adjust / withdraw flow; clamped to `unitsTotal`, state
|
||||
* re-derived by the canonical state machine; v1.16.10 renamed
|
||||
* the request field from `dosesRemaining` to match the response)
|
||||
* - update notes
|
||||
*
|
||||
* DELETE /api/medications/[id]/inventory/[itemId]
|
||||
@@ -74,7 +75,7 @@ export const PATCH = apiHandler(
|
||||
markAsFirstUseAt,
|
||||
markAsUsedUp,
|
||||
printedExpiry,
|
||||
dosesRemaining,
|
||||
unitsRemaining,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
@@ -96,13 +97,13 @@ export const PATCH = apiHandler(
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (dosesRemaining !== undefined) {
|
||||
if (unitsRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// The wire field counts UNITS. Clamp to the item's capacity; the
|
||||
// state-machine re-run below owns every state consequence (0 ⇒
|
||||
// USED_UP, a raise out of 0 re-evaluates against the expiry
|
||||
// clocks).
|
||||
nextUnitsRemaining = Math.min(dosesRemaining, existing.unitsTotal);
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
@@ -120,7 +121,7 @@ export const PATCH = apiHandler(
|
||||
// pure and idempotent — running it once more with the composed view
|
||||
// collapses every edge case onto the same decision tree the intake
|
||||
// hook and the daily expire cron already share. USED_UP is terminal
|
||||
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// override remains sticky.
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
|
||||
@@ -124,19 +124,19 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when dosesTotal is missing or invalid", async () => {
|
||||
it("returns 422 when unitsTotal is missing or invalid", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 0,
|
||||
unitsTotal: 0,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
@@ -154,7 +154,7 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
const printed = "2027-06-01T00:00:00Z";
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
printedExpiry: printed,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
@@ -185,7 +185,7 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
@@ -357,7 +357,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
unitsTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
@@ -383,7 +383,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
unitsTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
purchasedAt: "also-not-iso",
|
||||
notes: 123,
|
||||
|
||||
@@ -90,16 +90,17 @@ export const POST = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { dosesTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
parsed.data;
|
||||
|
||||
const created = await prisma.medicationInventoryItem.create({
|
||||
// The wire field `dosesTotal` counts UNITS (tablets / ampoules /
|
||||
// puffs) — the v1.16.10 rename to `unitsTotal` happens here.
|
||||
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
|
||||
// puffs) — v1.16.10 renamed the request field to match the
|
||||
// response side.
|
||||
data: buildCreateInventoryInput({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
unitsTotal: dosesTotal,
|
||||
unitsTotal,
|
||||
containerType: containerType ?? "OTHER",
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
@@ -113,7 +114,7 @@ export const POST = apiHandler(
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId: created.id,
|
||||
unitsTotal: dosesTotal,
|
||||
unitsTotal,
|
||||
containerType: created.containerType,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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("< 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),
|
||||
|
||||
@@ -32,6 +32,7 @@ function fakeMedication(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
],
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
intakeEvents: [],
|
||||
...overrides,
|
||||
};
|
||||
@@ -156,4 +157,47 @@ describe("buildGlp1SnapshotBlock", () => {
|
||||
weeksOfSupplyApprox: 3,
|
||||
});
|
||||
});
|
||||
|
||||
// W1 — ledger-only accounts (the pre-item delta writer) keep their
|
||||
// pen count in the Coach prompt instead of a silent omission.
|
||||
it("falls back to the legacy ledger when the medication has zero items", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
fakeMedication({
|
||||
dosesPerUnit: 4,
|
||||
inventoryItems: [],
|
||||
// +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses.
|
||||
inventoryEvents: [{ delta: 2 }, { delta: -1 }],
|
||||
}),
|
||||
]);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
expect(out?.medications[0].penInventory).toEqual({
|
||||
pensRemaining: 1,
|
||||
dosesRemaining: 4,
|
||||
weeksOfSupplyApprox: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the ledger whenever items exist (items win)", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
fakeMedication({
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }],
|
||||
// Stale delta history must not override the per-item truth.
|
||||
inventoryEvents: [{ delta: 9 }],
|
||||
}),
|
||||
]);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
expect(out?.medications[0].penInventory).toEqual({
|
||||
pensRemaining: 1,
|
||||
dosesRemaining: 4,
|
||||
weeksOfSupplyApprox: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits pen inventory when neither items nor ledger rows exist", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
expect(out?.medications[0].penInventory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -267,8 +267,12 @@ export async function buildGlp1SnapshotBlock(
|
||||
schedules: true,
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
// v1.16.10 — pen inventory reads the per-item entities (the rows
|
||||
// the intake consumption hook moves), not the legacy event ledger.
|
||||
// the intake consumption hook moves). The legacy event ledger
|
||||
// stays as a READ fallback for ledger-only accounts: when a
|
||||
// medication has zero items, the running-sum numbers surface so
|
||||
// the Coach still knows the pen count.
|
||||
inventoryItems: { orderBy: { createdAt: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -371,14 +375,21 @@ export async function buildGlp1SnapshotBlock(
|
||||
const nextInjection = predictNextInjection(schedule, lastInjection, now);
|
||||
|
||||
// Inventory math over the per-item entities (v1.16.10 — the same
|
||||
// rows the Bestand tab and the consumption hook move; the legacy
|
||||
// event ledger no longer receives writes). `pensRemaining` counts
|
||||
// usable containers; `dosesRemaining` pools the units and divides
|
||||
// by `unitsPerDose` so the Coach can answer "how long until my
|
||||
// next refill?".
|
||||
// rows the Bestand tab and the consumption hook move).
|
||||
// `pensRemaining` counts usable containers; `dosesRemaining` pools
|
||||
// the units and divides by `unitsPerDose` so the Coach can answer
|
||||
// "how long until my next refill?". Ledger-only accounts (the
|
||||
// pre-item delta writer) fall back to the running sum — items win
|
||||
// whenever any exist.
|
||||
let dosesRemaining: number | null = null;
|
||||
let pensRemaining: number | null = null;
|
||||
let weeksOfSupplyApprox: number | null = null;
|
||||
const dosesPerWeek =
|
||||
schedule?.cadence === "weekly"
|
||||
? 1
|
||||
: schedule?.cadence === "daily"
|
||||
? 7
|
||||
: 1;
|
||||
if (med.inventoryItems.length > 0) {
|
||||
const usable = med.inventoryItems.filter(
|
||||
(item) =>
|
||||
@@ -393,12 +404,13 @@ export async function buildGlp1SnapshotBlock(
|
||||
dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, med.unitsPerDose),
|
||||
);
|
||||
const dosesPerWeek =
|
||||
schedule?.cadence === "weekly"
|
||||
? 1
|
||||
: schedule?.cadence === "daily"
|
||||
? 7
|
||||
: 1;
|
||||
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
|
||||
} else if (med.dosesPerUnit && med.inventoryEvents.length > 0) {
|
||||
// Legacy-ledger fallback — the pre-item contract verbatim: the
|
||||
// delta sum counts pens, `dosesPerUnit` maps pens to doses.
|
||||
const pens = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0);
|
||||
pensRemaining = Math.max(0, pens);
|
||||
dosesRemaining = pensRemaining * med.dosesPerUnit;
|
||||
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* v1.16.10 — shared supply-summary math (`summariseSupply`).
|
||||
*
|
||||
* Pins the release-QA disagreement scenario: a single EXPIRED pen with
|
||||
* units left must read as ZERO available supply on every surface
|
||||
* (Übersicht supply row, Bestand summary, list payload, GLP-1 endpoint)
|
||||
* while the expired units stay visible separately. Both detail-page
|
||||
* surfaces render from this helper, so the predicate cannot drift from
|
||||
* the list / GLP-1 one (ACTIVE / IN_USE with units only).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isAvailableSupply,
|
||||
summariseSupply,
|
||||
} from "@/lib/medications/inventory/summary";
|
||||
|
||||
describe("summariseSupply — expired stock is visible but never available", () => {
|
||||
it("one expired pen with units ⇒ 0 available, expired units surfaced separately", () => {
|
||||
const out = summariseSupply(
|
||||
[{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }],
|
||||
1,
|
||||
);
|
||||
expect(out).toEqual({
|
||||
unitsRemaining: 0,
|
||||
unitsTotal: 0,
|
||||
dosesRemaining: 0,
|
||||
dosesTotal: 0,
|
||||
expiredUnits: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("pools only ACTIVE / IN_USE containers with units left", () => {
|
||||
const out = summariseSupply(
|
||||
[
|
||||
{ state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
|
||||
{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
|
||||
{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
|
||||
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
|
||||
],
|
||||
2,
|
||||
);
|
||||
// 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses.
|
||||
expect(out).toEqual({
|
||||
unitsRemaining: 7,
|
||||
unitsTotal: 8,
|
||||
dosesRemaining: 3,
|
||||
dosesTotal: 4,
|
||||
expiredUnits: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("a drained (used-up) container counts nowhere", () => {
|
||||
const out = summariseSupply(
|
||||
[{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }],
|
||||
1,
|
||||
);
|
||||
expect(out).toEqual({
|
||||
unitsRemaining: 0,
|
||||
unitsTotal: 0,
|
||||
dosesRemaining: 0,
|
||||
dosesTotal: 0,
|
||||
expiredUnits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("matches the list-route availability predicate per state", () => {
|
||||
expect(
|
||||
isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAvailableSupply({ state: "IN_USE", unitsTotal: 4, unitsRemaining: 1 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAvailableSupply({ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAvailableSupply({ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 0 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => {
|
||||
const read = (rel: string) =>
|
||||
readFileSync(join(process.cwd(), rel), "utf8");
|
||||
|
||||
it("the Übersicht supply row sums through summariseSupply and feeds the runway with the available-only figure", () => {
|
||||
const src = read(
|
||||
"src/components/medications/detail/MedicationDetailTabs.tsx",
|
||||
);
|
||||
expect(src).toContain('from "@/lib/medications/inventory/summary"');
|
||||
expect(src).toMatch(/summariseSupply\(inventoryItems, perDose\)/);
|
||||
expect(src).toMatch(
|
||||
/estimateRunwayDays\(dosesRemaining, medication\.schedules\)/,
|
||||
);
|
||||
// No hand-rolled "everything but USED_UP" filter remains.
|
||||
expect(src).not.toContain('state !== "USED_UP"');
|
||||
expect(src).toContain("medications.detail.bestand.expiredSuffix");
|
||||
});
|
||||
|
||||
it("the Bestand summary sums through summariseSupply and surfaces the expired suffix", () => {
|
||||
const src = read(
|
||||
"src/components/medications/sections/inventory-section.tsx",
|
||||
);
|
||||
expect(src).toContain('from "@/lib/medications/inventory/summary"');
|
||||
expect(src).toMatch(/summariseSupply\(items, perDose\)/);
|
||||
expect(src).not.toContain('state !== "USED_UP"');
|
||||
expect(src).toContain("medications.detail.bestand.expiredSuffix");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* v1.16.10 — shared supply-summary math for every stock readout.
|
||||
*
|
||||
* One predicate decides what counts as AVAILABLE supply: ACTIVE or
|
||||
* IN_USE containers with units left — the same filter the medications
|
||||
* list route (`stockUnitsRemaining`), the GLP-1 detail endpoint and the
|
||||
* Coach snapshot run. EXPIRED stock is visible but never available:
|
||||
* it surfaces separately (`expiredUnits`) so the UI can show a muted
|
||||
* "expired" suffix without folding it into the headline or the runway
|
||||
* estimate. USED_UP containers count nowhere.
|
||||
*
|
||||
* The Übersicht supply row and the Bestand summary both render from
|
||||
* this helper, so the surfaces cannot disagree about what "remaining"
|
||||
* means.
|
||||
*/
|
||||
|
||||
export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
|
||||
export interface SupplyItem {
|
||||
state: SupplyItemState;
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
}
|
||||
|
||||
export interface SupplySummary {
|
||||
/** Pooled units across available (ACTIVE / IN_USE, units left) containers. */
|
||||
unitsRemaining: number;
|
||||
/** Pooled capacity across the same available containers. */
|
||||
unitsTotal: number;
|
||||
/** Dose-derived headline: floor(unitsRemaining / unitsPerDose). */
|
||||
dosesRemaining: number;
|
||||
/** Dose-derived capacity: floor(unitsTotal / unitsPerDose). */
|
||||
dosesTotal: number;
|
||||
/** Units still sitting in EXPIRED containers — visible, never available. */
|
||||
expiredUnits: number;
|
||||
}
|
||||
|
||||
/** The list-route / GLP-1 availability predicate, verbatim. */
|
||||
export function isAvailableSupply(item: SupplyItem): boolean {
|
||||
return (
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function summariseSupply(
|
||||
items: readonly SupplyItem[],
|
||||
unitsPerDose: number,
|
||||
): SupplySummary {
|
||||
const perDose = Math.max(1, unitsPerDose);
|
||||
const available = items.filter(isAvailableSupply);
|
||||
const unitsRemaining = available.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
0,
|
||||
);
|
||||
const unitsTotal = available.reduce((sum, item) => sum + item.unitsTotal, 0);
|
||||
const expiredUnits = items
|
||||
.filter((item) => item.state === "EXPIRED")
|
||||
.reduce((sum, item) => sum + item.unitsRemaining, 0);
|
||||
return {
|
||||
unitsRemaining,
|
||||
unitsTotal,
|
||||
dosesRemaining: Math.floor(unitsRemaining / perDose),
|
||||
dosesTotal: Math.floor(unitsTotal / perDose),
|
||||
expiredUnits,
|
||||
};
|
||||
}
|
||||
@@ -1005,7 +1005,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Register a new supply container",
|
||||
description:
|
||||
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
|
||||
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
@@ -1040,7 +1040,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Mutate a supply container",
|
||||
description:
|
||||
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
|
||||
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* v1.16.10 — inventory + units-per-dose validation bounds.
|
||||
*
|
||||
* Pins the raised unit caps (100 → 1000 on container capacity and the
|
||||
* stock correction; `dosesPerUnit` rides along) and the new
|
||||
* `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy
|
||||
* ledger delta deliberately KEEPS its ±100 bound — it counts pens, not
|
||||
* units.
|
||||
* stock correction; `dosesPerUnit` rides along), the new
|
||||
* `unitsPerDose` (1–100) and `containerType` fields, and the symmetric
|
||||
* request field names (`unitsTotal` / `unitsRemaining` — the response
|
||||
* always carried them). The GLP-1 legacy ledger delta deliberately
|
||||
* KEEPS its ±100 bound — it counts pens, not units.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -30,17 +31,17 @@ const MINIMAL_MEDICATION = {
|
||||
|
||||
describe("createInventoryItemSchema — unit cap 1000", () => {
|
||||
it("accepts 1000 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 });
|
||||
const r = createInventoryItemSchema.safeParse({ unitsTotal: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001 units", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 });
|
||||
const r = createInventoryItemSchema.safeParse({ unitsTotal: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects zero", () => {
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 });
|
||||
const r = createInventoryItemSchema.safeParse({ unitsTotal: 0 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -54,19 +55,19 @@ describe("createInventoryItemSchema — unit cap 1000", () => {
|
||||
"OTHER",
|
||||
]) {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
containerType: ct,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
}
|
||||
const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 });
|
||||
const r = createInventoryItemSchema.safeParse({ unitsTotal: 4 });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.success && r.data.containerType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown container type", () => {
|
||||
const r = createInventoryItemSchema.safeParse({
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
containerType: "BAG",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
@@ -75,12 +76,12 @@ describe("createInventoryItemSchema — unit cap 1000", () => {
|
||||
|
||||
describe("updateInventoryItemSchema — stock correction cap 1000", () => {
|
||||
it("accepts 1000", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 });
|
||||
const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 1000 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 1001", () => {
|
||||
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 });
|
||||
const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 1001 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -842,16 +842,16 @@ export type MedicationContainerTypeValue =
|
||||
export const createInventoryItemSchema = z
|
||||
.object({
|
||||
/** Units the container ships with. v1.16.10 raises the cap from
|
||||
* 100 to 1000 (large tablet packs); the wire field name stays
|
||||
* `dosesTotal` — it counts units, mapped to doses via
|
||||
* 100 to 1000 (large tablet packs) and renames the wire field to
|
||||
* `unitsTotal` — it counts units, mapped to doses via
|
||||
* `Medication.unitsPerDose`. */
|
||||
dosesTotal: z
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
@@ -875,7 +875,7 @@ export const createInventoryItemSchema = z
|
||||
.meta({
|
||||
id: "CreateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
|
||||
"Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
|
||||
});
|
||||
|
||||
export const updateInventoryItemSchema = z
|
||||
@@ -903,9 +903,11 @@ export const updateInventoryItemSchema = z
|
||||
* flow). Sets the remaining-unit count directly; the route clamps to
|
||||
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
* v1.16.10 raises the cap to 1000 alongside the capacity cap.
|
||||
* v1.16.10 raises the cap to 1000 alongside the capacity cap and
|
||||
* renames the wire field to `unitsRemaining` (it always counted
|
||||
* units), matching the response side.
|
||||
*/
|
||||
dosesRemaining: z
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
@@ -972,6 +974,14 @@ export const glp1DoseChangePostSchema = z.object({
|
||||
note: z.string().max(MAX_NOTE_CHARS).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds
|
||||
* the legacy `MedicationInventoryEvent` running-sum ledger. The per-item
|
||||
* endpoints (`POST /api/medications/[id]/inventory`,
|
||||
* `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads
|
||||
* fall back to the ledger only while a medication has zero inventory
|
||||
* items. New callers must register containers instead of posting deltas.
|
||||
*/
|
||||
export const glp1InventoryPostSchema = z.object({
|
||||
delta: z
|
||||
.number()
|
||||
@@ -979,7 +989,10 @@ export const glp1InventoryPostSchema = z.object({
|
||||
.finite()
|
||||
.min(-100)
|
||||
.max(100)
|
||||
.refine((n) => n !== 0, { message: "delta must be non-zero" }),
|
||||
.refine((n) => n !== 0, { message: "delta must be non-zero" })
|
||||
.describe(
|
||||
"Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.",
|
||||
),
|
||||
reason: z.string().min(1).max(MAX_REASON_CHARS),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user