From 9dc0ccd03af35e9cd903e799d9217cb424132403 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 12 Jun 2026 19:11:53 +0200
Subject: [PATCH] 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.
---
docs/api/openapi.yaml | 16 +-
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 8 +-
.../[id]/glp1/__tests__/route.test.ts | 71 ++++-
src/app/api/medications/[id]/glp1/route.ts | 39 ++-
.../[itemId]/__tests__/route.test.ts | 16 +-
.../[id]/inventory/[itemId]/route.ts | 15 +-
.../[id]/inventory/__tests__/route.test.ts | 14 +-
.../api/medications/[id]/inventory/route.ts | 11 +-
.../__tests__/add-inventory-dialog.test.tsx | 20 +-
.../inventory-section-summary.test.tsx | 113 ++++++++
.../medication-reorder-dialog.test.tsx | 172 ++++++++++++
.../__tests__/medication-table.test.tsx | 14 +
.../detail/MedicationDetailTabs.tsx | 27 +-
.../medications/medication-reorder-dialog.tsx | 259 +++++++++++++-----
.../medications/medication-table.tsx | 14 +-
.../sections/inventory-section.tsx | 48 +++-
.../ai/coach/__tests__/glp1-snapshot.test.ts | 44 +++
src/lib/ai/coach/glp1-snapshot.ts | 36 ++-
.../inventory/__tests__/summary.test.ts | 115 ++++++++
src/lib/medications/inventory/summary.ts | 67 +++++
src/lib/openapi/routes/medications.ts | 4 +-
.../__tests__/medication-inventory.test.ts | 25 +-
src/lib/validations/medication.ts | 29 +-
28 files changed, 1017 insertions(+), 180 deletions(-)
create mode 100644 src/components/medications/__tests__/inventory-section-summary.test.tsx
create mode 100644 src/components/medications/__tests__/medication-reorder-dialog.test.tsx
create mode 100644 src/lib/medications/inventory/__tests__/summary.test.ts
create mode 100644 src/lib/medications/inventory/summary.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d5c80eb3a..9ff8f1ef6 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/messages/de.json b/messages/de.json
index f5debfaca..9930facb5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 5616d3971..f5927bd8b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index fbb57fbb3..514b42a15 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index a4dc6c116..68b82779e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index 45fadd671..7290f0b4e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 9004b7804..21e72f180 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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ść",
diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
index c8f10a701..6f74d2026 100644
--- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
@@ -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();
+ });
});
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index ee887b531..1ede74ba7 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -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 },
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
index 37792abf5..992409555 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
@@ -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);
});
});
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index d31d2b47e..fe03720e3 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -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(
{
diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
index 14b1cd9d2..16617b3fd 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -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,
diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts
index 0f4bbb205..db17065b8 100644
--- a/src/app/api/medications/[id]/inventory/route.ts
+++ b/src/app/api/medications/[id]/inventory/route.ts
@@ -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,
},
});
diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx
index 35e39d1c0..ce4c209b8 100644
--- a/src/components/medications/__tests__/add-inventory-dialog.test.tsx
+++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx
@@ -152,14 +152,30 @@ describe("", () => {
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(
+ {}}
+ />,
+ );
+ 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)');
diff --git a/src/components/medications/__tests__/inventory-section-summary.test.tsx b/src/components/medications/__tests__/inventory-section-summary.test.tsx
new file mode 100644
index 000000000..fa6a2001d
--- /dev/null
+++ b/src/components/medications/__tests__/inventory-section-summary.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * v1.16.10 — Bestand summary availability semantics.
+ *
+ * Renders `` 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(
+ {node},
+ );
+}
+
+function seedItems(items: unknown[]) {
+ useQueryMock.mockReturnValue({
+ data: { items, meta: { total: items.length } },
+ isLoading: false,
+ });
+}
+
+describe(" — 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(html).toContain("2 von 4 Dosen übrig");
+ expect(html).toContain("+ 3 Einheiten abgelaufen");
+ });
+});
diff --git a/src/components/medications/__tests__/medication-reorder-dialog.test.tsx b/src/components/medications/__tests__/medication-reorder-dialog.test.tsx
new file mode 100644
index 000000000..29c7486a3
--- /dev/null
+++ b/src/components/medications/__tests__/medication-reorder-dialog.test.tsx
@@ -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 }) => (
+ {children}
+ );
+ return {
+ Dialog: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ 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(
+ {node},
+ );
+}
+
+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(" — two-section rendering", () => {
+ it("renders an Aktiv and an Inaktiv section with the rows grouped", () => {
+ const html = render(
+ {}}
+ 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
");
+ expect(html).toContain(">Inaktiv");
+ // 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(
+ {}}
+ 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 inside the per-section component, none at the
+ // dialog level spanning both lists.
+ const sectionBody = src.slice(src.indexOf("function ReorderSection"));
+ expect(sectionBody).toContain(" {
}).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(" — reserved footprint", () => {
diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx
index fc6738656..b90ee22b1 100644
--- a/src/components/medications/detail/MedicationDetailTabs.tsx
+++ b/src/components/medications/detail/MedicationDetailTabs.tsx
@@ -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({
{")"}
)}
+ {expiredUnits > 0 && (
+
+ {" · "}
+ {t("medications.detail.bestand.expiredSuffix", {
+ units: expiredUnits,
+ })}
+
+ )}
{runwayDays !== null && (
{" · "}
diff --git a/src/components/medications/medication-reorder-dialog.tsx b/src/components/medications/medication-reorder-dialog.tsx
index 5dbb98baa..794ac85b3 100644
--- a/src/components/medications/medication-reorder-dialog.tsx
+++ b/src/components/medications/medication-reorder-dialog.tsx
@@ -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(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(null);
+ const [draftInactive, setDraftInactive] = useState(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 (