fix(medications): surface and correct the dates that decide a container's state

The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.

Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.

The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 18:00:07 +02:00
1 parent bb1e01e456
commit a4ff11f10d
11 files changed
+536 -43

No files matched your search

+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Bestand erfasst.",
"addFailed": "Bestand konnte nicht erfasst werden.",
"adjustButton": "Anpassen",
"adjustTitle": "Bestand korrigieren",
"adjustDescription": "Setze die verbleibenden Einheiten neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.",
"adjustTitle": "Behälter korrigieren",
"adjustDescription": "Verbleibende Einheiten setzen und die Daten dieses Behälters korrigieren. 0 Einheiten markiert ihn als aufgebraucht.",
"adjustQuantityLabel": "Verbleibende Einheiten",
"adjustHelper": "Kapazität: {total} Einheiten.",
"adjustSubmit": "Speichern",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Speichern fehlgeschlagen",
"addManufacturerLabel": "Hersteller",
"addDoseStrengthLabel": "Wirkstärke",
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml.",
"openedOn": "Geöffnet {date}",
"expiresOn": "Haltbar bis {date}",
"summaryAllExpired": "Kein nutzbarer Bestand: {units} Einheiten als abgelaufen markiert",
"editExpiryLabel": "Aufgedrucktes Haltbarkeitsdatum",
"editExpiryHelper": "Das Datum auf der Packung. Leer lassen, wenn keines erfasst ist.",
"editOpenedLabel": "Geöffnet am",
"editOpenedHelper": "Leer lassen, wenn der Behälter noch verschlossen ist.",
"editOpenedHelperClock": "Ein Pen oder eine Ampulle läuft 30 Tage nach dem Öffnen ab. Leer lassen, wenn noch verschlossen."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Vorrat niedrig: {medName}",
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}."
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.",
"bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Supply registered.",
"addFailed": "The supply could not be registered.",
"adjustButton": "Adjust",
"adjustTitle": "Correct stock",
"adjustDescription": "Set the remaining units — for corrections or a withdrawal. 0 marks the item as used up.",
"adjustTitle": "Correct container",
"adjustDescription": "Set the remaining units and correct the dates printed on or recorded for this container. 0 units marks it as used up.",
"adjustQuantityLabel": "Remaining units",
"adjustHelper": "Capacity: {total} units.",
"adjustSubmit": "Save",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Saving failed",
"addManufacturerLabel": "Manufacturer",
"addDoseStrengthLabel": "Strength",
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml.",
"openedOn": "Opened {date}",
"expiresOn": "Expires {date}",
"summaryAllExpired": "No usable supply: {units} units marked expired",
"editExpiryLabel": "Printed expiry date",
"editExpiryHelper": "The date printed on the carton. Leave empty if none is recorded.",
"editOpenedLabel": "Opened on",
"editOpenedHelper": "Leave empty if the container is still sealed.",
"editOpenedHelperClock": "A pen or ampoule expires 30 days after it is opened. Leave empty if it is still sealed."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Low supply: {medName}",
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}."
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.",
"bodyExpired": "{medName}: no usable supply left. {units} units are marked expired."
},
"moodReminders": {
"dailyTitle": "Log your mood",
+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Existencias registradas.",
"addFailed": "No se pudieron registrar las existencias.",
"adjustButton": "Ajustar",
"adjustTitle": "Corregir existencias",
"adjustDescription": "Establece las unidades restantes — para correcciones o una retirada. 0 marca el envase como agotado.",
"adjustTitle": "Corregir envase",
"adjustDescription": "Ajusta las unidades restantes y corrige las fechas de este envase. 0 unidades lo marca como agotado.",
"adjustQuantityLabel": "Unidades restantes",
"adjustHelper": "Capacidad: {total} unidades.",
"adjustSubmit": "Guardar",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Error al guardar",
"addManufacturerLabel": "Fabricante",
"addDoseStrengthLabel": "Concentración",
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml.",
"openedOn": "Abierto el {date}",
"expiresOn": "Caduca el {date}",
"summaryAllExpired": "Sin existencias utilizables: {units} unidades marcadas como caducadas",
"editExpiryLabel": "Fecha de caducidad impresa",
"editExpiryHelper": "La fecha impresa en el envase. Déjalo vacío si no hay ninguna registrada.",
"editOpenedLabel": "Abierto el",
"editOpenedHelper": "Déjalo vacío si el envase sigue cerrado.",
"editOpenedHelperClock": "Una pluma o una ampolla caduca 30 días después de abrirse. Déjalo vacío si sigue cerrada."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Quedan pocas existencias: {medName}",
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}."
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.",
"bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Stock enregistré.",
"addFailed": "Le stock n'a pas pu être enregistré.",
"adjustButton": "Ajuster",
"adjustTitle": "Corriger le stock",
"adjustDescription": "Définissez les unités restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.",
"adjustTitle": "Corriger le contenant",
"adjustDescription": "Ajustez les unités restantes et corrigez les dates de ce contenant. 0 unité le marque comme épuisé.",
"adjustQuantityLabel": "Unités restantes",
"adjustHelper": "Capacité : {total} unités.",
"adjustSubmit": "Enregistrer",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Échec de l’enregistrement",
"addManufacturerLabel": "Fabricant",
"addDoseStrengthLabel": "Dosage",
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml.",
"openedOn": "Ouvert le {date}",
"expiresOn": "Périme le {date}",
"summaryAllExpired": "Aucun stock utilisable : {units} unités marquées périmées",
"editExpiryLabel": "Date de péremption imprimée",
"editExpiryHelper": "La date imprimée sur la boîte. Laissez vide si aucune n'est enregistrée.",
"editOpenedLabel": "Ouvert le",
"editOpenedHelper": "Laissez vide si le contenant est encore scellé.",
"editOpenedHelperClock": "Un stylo ou une ampoule périme 30 jours après ouverture. Laissez vide s'il est encore scellé."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Stock faible : {medName}",
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}."
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.",
"bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Scorta registrata.",
"addFailed": "Impossibile registrare la scorta.",
"adjustButton": "Correggi",
"adjustTitle": "Correggi la scorta",
"adjustDescription": "Imposta le unità rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.",
"adjustTitle": "Correggi il contenitore",
"adjustDescription": "Imposta le unità rimanenti e correggi le date di questo contenitore. 0 unità lo segna come esaurito.",
"adjustQuantityLabel": "Unità rimanenti",
"adjustHelper": "Capacità: {total} unità.",
"adjustSubmit": "Salva",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Salvataggio non riuscito",
"addManufacturerLabel": "Produttore",
"addDoseStrengthLabel": "Dosaggio",
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml.",
"openedOn": "Aperto il {date}",
"expiresOn": "Scade il {date}",
"summaryAllExpired": "Nessuna scorta utilizzabile: {units} unità contrassegnate come scadute",
"editExpiryLabel": "Data di scadenza stampata",
"editExpiryHelper": "La data stampata sulla confezione. Lascia vuoto se non ne è registrata nessuna.",
"editOpenedLabel": "Aperto il",
"editOpenedHelper": "Lascia vuoto se il contenitore è ancora sigillato.",
"editOpenedHelperClock": "Una penna o una fiala scade 30 giorni dopo l'apertura. Lascia vuoto se è ancora sigillata."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Scorte in esaurimento: {medName}",
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}."
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.",
"bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
+13 -4
View File
@@ -1716,8 +1716,8 @@
"addSuccess": "Zapas zarejestrowany.",
"addFailed": "Nie udało się zarejestrować zapasu.",
"adjustButton": "Skoryguj",
"adjustTitle": "Skoryguj zapas",
"adjustDescription": "Ustaw pozostałe jednostki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.",
"adjustTitle": "Popraw opakowanie",
"adjustDescription": "Ustaw pozostałe jednostki i popraw daty tego opakowania. 0 jednostek oznacza je jako zużyte.",
"adjustQuantityLabel": "Pozostałe jednostki",
"adjustHelper": "Pojemność: {total} jednostek.",
"adjustSubmit": "Zapisz",
@@ -1757,7 +1757,15 @@
"packagingFailed": "Zapis nie powiódł się",
"addManufacturerLabel": "Producent",
"addDoseStrengthLabel": "Moc",
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml.",
"openedOn": "Otwarto {date}",
"expiresOn": "Ważne do {date}",
"summaryAllExpired": "Brak zapasu do użycia: {units} jednostek oznaczono jako przeterminowane",
"editExpiryLabel": "Data ważności z opakowania",
"editExpiryHelper": "Data nadrukowana na opakowaniu. Zostaw puste, jeśli nie została zapisana.",
"editOpenedLabel": "Data otwarcia",
"editOpenedHelper": "Zostaw puste, jeśli opakowanie jest jeszcze zamknięte.",
"editOpenedHelperClock": "Pen lub ampułka traci ważność 30 dni po otwarciu. Zostaw puste, jeśli jest jeszcze zamknięta."
},
"erweitert": {
"group": {
@@ -7008,7 +7016,8 @@
"title": "Niski zapas: {medName}",
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}."
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.",
"bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
@@ -0,0 +1,208 @@
/**
* Bestand item-row dates: visible on the row, editable in the correction
* dialog.
*
* Both dates decide the container's state — the printed expiry directly,
* the opening date through the post-opening window on a pen or an
* ampoule — and the opening date is written by the intake consumption
* hook without the user ever typing it. Until now neither was rendered
* anywhere and neither could be corrected, so a container could read
* EXPIRED with nothing on screen to explain why.
*
* 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";
// The Radix Dialog / Select portal at runtime, so their bodies never
// materialise in static markup. Collapse the primitives to plain
// wrappers (same trick as the AddInventoryDialog suite).
vi.mock("@/components/ui/dialog", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Dialog: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
DialogHeader: Pass,
DialogTitle: Pass,
};
});
vi.mock("@/components/ui/select", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Select: ({
value,
children,
}: {
value: string;
children?: React.ReactNode;
}) => <div data-mock-select-value={value}>{children}</div>,
SelectContent: Pass,
SelectItem: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-select-item">{children}</div>
),
SelectTrigger: Pass,
SelectValue: () => null,
};
});
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, AddInventoryDialog, AdjustInventoryDialog } =
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,
});
}
const BASE = {
id: "i1",
state: "IN_USE" as const,
containerType: "BLISTER" as const,
unitsTotal: 60,
unitsRemaining: 54,
};
describe("<InventorySection> — item-row dates", () => {
it("renders the opening date on the row", () => {
seedItems([
{
...BASE,
firstUseAt: "2026-06-12T00:00:00.000Z",
printedExpiry: null,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain('data-slot="inventory-item-dates"');
expect(html).toContain("Geöffnet 12.06.2026");
});
it("renders the printed expiry on the row", () => {
seedItems([
{
...BASE,
state: "ACTIVE",
firstUseAt: null,
printedExpiry: "2027-06-01T00:00:00.000Z",
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain("Haltbar bis 01.06.2027");
});
it("shows no date line when neither date is recorded", () => {
// Absence reads as absence — no placeholder, no invented date.
seedItems([{ ...BASE, firstUseAt: null, printedExpiry: null }]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).not.toContain('data-slot="inventory-item-dates"');
});
});
describe("<AdjustInventoryDialog> — editable dates", () => {
const item = {
...BASE,
firstUseAt: "2026-06-12T00:00:00.000Z",
printedExpiry: "2027-06-01T00:00:00.000Z",
};
function renderDialog(over: Partial<typeof item> = {}): string {
return render(
<AdjustInventoryDialog
medicationId="med-1"
item={{ ...item, ...over }}
onClose={() => {}}
/>,
);
}
it("offers a printed-expiry field prefilled from the item", () => {
const html = renderDialog();
expect(html).toContain('id="inventory-edit-expiry"');
expect(html).toContain("Aufgedrucktes Haltbarkeitsdatum");
expect(html).toContain('value="2027-06-01"');
});
it("offers an opening-date field prefilled from the item", () => {
const html = renderDialog();
expect(html).toContain('id="inventory-edit-opened"');
expect(html).toContain("Geöffnet am");
expect(html).toContain('value="2026-06-12"');
});
it("leaves both date fields empty when the item records neither", () => {
const html = renderDialog({ firstUseAt: null, printedExpiry: null });
expect(html).toContain('id="inventory-edit-expiry"');
expect(html).toContain('id="inventory-edit-opened"');
expect(html).not.toContain("2027-06-01");
expect(html).not.toContain("2026-06-12");
});
it("explains the 30-day window beside the opening date on a pen", () => {
const html = renderDialog({ containerType: "PEN" });
expect(html).toContain("30 Tage nach dem Öffnen");
});
it("omits the window explanation for a container that has none", () => {
const html = renderDialog({ containerType: "BLISTER" });
expect(html).not.toContain("30 Tage nach dem Öffnen");
expect(html).toContain("noch verschlossen");
});
it("keeps the remaining-units correction alongside the dates", () => {
const html = renderDialog();
expect(html).toContain('id="inventory-adjust-remaining"');
});
});
describe("<AddInventoryDialog> — printed expiry on create", () => {
it("still offers the expiry field", () => {
// Regression guard: the create path always offered the printed
// expiry; the correction path is the addition.
const html = render(
<AddInventoryDialog
medicationId="med-1"
defaultUnitsTotal={30}
unitsPerDose={1}
initialContainerType="BLISTER"
onClose={() => {}}
/>,
);
expect(html).toContain('id="inventory-add-expiry"');
});
});
@@ -68,7 +68,11 @@ function seedItems(
}
describe("<InventorySection> — summary availability", () => {
it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => {
it("only expired stock ⇒ the headline says so instead of '0 of 0'", () => {
// "0 von 0 Dosen übrig" beside "+ 4 Einheiten abgelaufen" reads as
// two contradicting statements: the first says nothing was ever
// recorded, the second says stock was written off. One honest line
// replaces both.
seedItems([
{
id: "i1",
@@ -81,9 +85,9 @@ describe("<InventorySection> — summary availability", () => {
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");
expect(html).toContain("Kein nutzbarer Bestand: 4 Einheiten");
expect(html).not.toContain("0 von 0 Dosen übrig");
expect(html).not.toContain('data-slot="inventory-expired-suffix"');
// The item row itself stays visible with its state badge.
expect(html).toContain("Abgelaufen");
});
@@ -134,5 +138,25 @@ describe("<InventorySection> — summary availability", () => {
);
expect(html).toContain("2 von 4 Dosen übrig");
expect(html).toContain("+ 3 Einheiten abgelaufen");
expect(html).not.toContain("Kein nutzbarer Bestand");
});
it("keeps the plain empty-supply reading when nothing was written off", () => {
// Every container drained: zero available AND zero expired. That is
// the honest "0 of 0", and it must not be replaced.
seedItems([
{
id: "i1",
state: "USED_UP",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 0,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
expect(html).toContain("0 von 0 Dosen übrig");
expect(html).not.toContain("Kein nutzbarer Bestand");
});
});
@@ -10,11 +10,15 @@
* `POST /api/medications/[id]/inventory` — quantity in units,
* optional printed expiry. The quantity prefills from the
* medication's `dosesPerUnit` when configured.
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
* `unitsRemaining`) for stock corrections and manual withdrawals —
* the count is set absolutely, clamped server-side to the item's
* capacity, and the canonical state machine derives the next state
* (0 ⇒ used up).
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]`) 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). The
* same flow edits the two dates that decide the state: the
* carton-printed expiry and the date the container was opened. The
* opening date is written by the intake consumption hook without the
* user typing it, so it needs a correction path more than any other
* field on the row.
* - DELETE: a per-item trash affordance behind a destructive confirm
* (`DELETE …/inventory/[itemId]`). Consumption stamps on intake
* events that referenced the container stay in place; a later
@@ -60,7 +64,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
import {
@@ -117,6 +122,31 @@ interface InventoryItem {
// the UI renders "—" instead of a fabricated 0.
unitsTotal: number | null;
unitsRemaining: number | null;
/** ISO instant. The carton-printed expiry; null when none was recorded. */
printedExpiry: string | null;
/** ISO instant. When the container was opened; null while sealed. */
firstUseAt: string | null;
}
/**
* Both dates the row carries are entered — and were always stored — as a
* calendar day at the browser's local midnight. Reading them back in the
* same zone is what makes the value the user typed the value they see;
* pinning to UTC would slide the day for anyone east of Greenwich.
*/
function toLocalDayInput(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */
function fromLocalDayInput(day: string): string | null {
if (!day) return null;
const at = new Date(`${day}T00:00:00`);
return Number.isNaN(at.getTime()) ? null : at.toISOString();
}
interface InventoryResponse {
@@ -139,6 +169,15 @@ const STATE_BADGE: Record<
USED_UP: "outline",
};
/**
* Container kinds whose first opening starts a 30-day degradation clock.
* Mirrors `IN_USE_CLOCK_CONTAINER_TYPES` in
* `src/lib/medications/inventory/state-machine.ts` — the server owns the
* transition; the client only needs to know which helper text to show
* beside the opening date.
*/
const CLOCK_CONTAINER_TYPES: readonly ContainerType[] = ["PEN", "AMPOULE"];
/** Default container kind for the register flow, from the delivery form. */
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
@@ -160,7 +199,8 @@ export function InventorySection({
/** Defaults the register flow's container type. */
deliveryForm?: string;
}) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const dateFormatPref = useDateFormatPreference();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
@@ -244,6 +284,10 @@ export function InventorySection({
const remaining = summary?.dosesRemaining ?? 0;
const total = summary?.dosesTotal ?? 0;
const expiredUnits = summary?.expiredUnits ?? 0;
// Nothing available, but stock is sitting in containers that were
// declared unusable. That is a different statement from "no supply
// recorded" and the headline has to make the difference.
const allExpired = totalUnits === 0 && expiredUnits > 0;
const dialogs = (
<>
@@ -302,8 +346,17 @@ export function InventorySection({
>
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
{perDose !== 1 && (
{/* "0 of 0 doses" beside "+ 54 units expired" read as two
contradicting statements — the first says nothing was ever
configured, the second says stock was written off. When the
available pool is empty and expired stock is all that is
left, say that instead. */}
{allExpired
? t("medications.detail.bestand.summaryAllExpired", {
units: expiredUnits,
})
: t("medications.detail.bestand.summary", { remaining, total })}
{perDose !== 1 && !allExpired && (
<span className="text-muted-foreground block text-xs font-normal">
{t("medications.detail.bestand.unitsDetail", {
remaining: formatUnitCount(remainingUnits),
@@ -311,7 +364,7 @@ export function InventorySection({
})}
</span>
)}
{expiredUnits > 0 && (
{expiredUnits > 0 && !allExpired && (
<span
className="text-muted-foreground block text-xs font-normal"
data-slot="inventory-expired-suffix"
@@ -432,6 +485,39 @@ export function InventorySection({
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
</span>
{/* The two dates that decide the row's state. They used to
be invisible here — a container could read EXPIRED with
nothing on screen to explain why, and no way to correct
the value that caused it. */}
{(item.firstUseAt || item.printedExpiry) && (
<span
className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs"
data-slot="inventory-item-dates"
>
{item.firstUseAt && (
<span>
{t("medications.detail.bestand.openedOn", {
date: formatDate(
toLocalDayInput(item.firstUseAt),
dateFormatPref,
locale,
),
})}
</span>
)}
{item.printedExpiry && (
<span>
{t("medications.detail.bestand.expiresOn", {
date: formatDate(
toLocalDayInput(item.printedExpiry),
dateFormatPref,
locale,
),
})}
</span>
)}
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-1">
<Button
@@ -714,12 +800,20 @@ export function AddInventoryDialog({
}
/**
* Stock correction for one item: sets the remaining-unit count
* absolutely (covers both "I miscounted" and a manual withdrawal). The
* server clamps to the item's capacity and re-derives the state; 0
* marks the item used up.
* Correct one container: the remaining-unit count (covers both "I
* miscounted" and a manual withdrawal), the carton-printed expiry, and
* the date it was opened. The server clamps the count to the item's
* capacity and re-derives the state from the two dates; 0 units marks
* the item used up.
*
* Both dates are editable because both decide the state — and the
* opening date in particular is written by the intake consumption hook
* without the user ever typing it, so an auto-open on a back-dated dose
* needs a way back. Each field is sent only when it actually changed,
* so saving a unit correction never rewrites a date the user did not
* touch.
*/
function AdjustInventoryDialog({
export function AdjustInventoryDialog({
medicationId,
item,
onClose,
@@ -735,6 +829,10 @@ function AdjustInventoryDialog({
const [value, setValue] = useState(
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
);
const initialExpiry = toLocalDayInput(item.printedExpiry);
const initialOpened = toLocalDayInput(item.firstUseAt);
const [expiry, setExpiry] = useState(initialExpiry);
const [opened, setOpened] = useState(initialOpened);
const [busy, setBusy] = useState(false);
const formId = useId();
@@ -755,6 +853,15 @@ function AdjustInventoryDialog({
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
// Absent means untouched on the server, so only changed fields
// ride along. A cleared field sends null, which is a deliberate
// "there is no such date" — not the same as leaving it alone.
...(expiry !== initialExpiry && {
printedExpiry: fromLocalDayInput(expiry),
}),
...(opened !== initialOpened && {
markAsFirstUseAt: fromLocalDayInput(opened),
}),
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.adjustSuccess"));
@@ -824,6 +931,50 @@ function AdjustInventoryDialog({
})}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editExpiryLabel")}
</label>
<DateField
id="inventory-edit-expiry"
value={expiry}
onChange={setExpiry}
aria-describedby="inventory-edit-expiry-helper"
data-testid="inventory-edit-expiry"
/>
<p
id="inventory-edit-expiry-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.editExpiryHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-opened"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editOpenedLabel")}
</label>
<DateField
id="inventory-edit-opened"
value={opened}
onChange={setOpened}
aria-describedby="inventory-edit-opened-helper"
data-testid="inventory-edit-opened"
/>
<p
id="inventory-edit-opened-helper"
className="text-muted-foreground text-xs"
>
{CLOCK_CONTAINER_TYPES.includes(item.containerType)
? t("medications.detail.bestand.editOpenedHelperClock")
: t("medications.detail.bestand.editOpenedHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
@@ -48,6 +48,7 @@ describe("evaluateMedicationRunway", () => {
runwayDays: 6,
dosesRemaining: 6,
unitsRemaining: 6,
expiredUnits: 0,
});
});
@@ -65,6 +66,8 @@ describe("evaluateMedicationRunway", () => {
);
expect(out.dosesRemaining).toBe(2);
expect(out.runwayDays).toBe(2);
// Written-off stock is surfaced alongside, never folded into supply.
expect(out.expiredUnits).toBe(10);
});
it("returns runway 0 for an exhausted supply with a consuming schedule", () => {
@@ -146,6 +149,7 @@ const payloadArgs = (
medName: "Metformin",
runwayDays: 5,
unitsRemaining: 10,
expiredUnits: 0,
leadDays: 0,
triggerDays: 7,
schedules: [dailySchedule],
@@ -174,6 +178,28 @@ describe("buildLowStockPayload", () => {
expect(out.body).toContain("less than one day");
});
it("names the written-off stock when nothing usable is left but expired units are", () => {
// "0 units left" and "everything you have left is written off" are
// different errands; the second must not read as the first.
const out = payloadArgs({
runwayDays: 0,
unitsRemaining: 0,
expiredUnits: 54,
});
expect(out.body).toContain("no usable supply left");
expect(out.body).toContain("54 units are marked expired");
expect(out.body).not.toContain("less than one day");
});
it("keeps the depleted line when nothing expired either", () => {
const out = payloadArgs({
runwayDays: 0,
unitsRemaining: 0,
expiredUnits: 0,
});
expect(out.body).toContain("less than one day");
});
it("renders the last-dose line when the runway is one cadence interval", () => {
// Weekly cadence, runway 7 ≈ one dose-interval → last-dose state.
const out = payloadArgs({
+30
View File
@@ -105,6 +105,13 @@ export interface LowStockEvaluation {
runwayDays: number | null;
dosesRemaining: number;
unitsRemaining: number;
/**
* Units sitting in containers that were declared unusable. Never part
* of the runway — but "you have nothing left" and "everything you have
* left is written off" are different errands, so the push has to be
* able to tell them apart.
*/
expiredUnits: number;
}
/**
@@ -141,6 +148,7 @@ export function evaluateMedicationRunway(
runwayDays: null,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
expiredUnits: supply.expiredUnits,
};
}
const runwayDays =
@@ -151,6 +159,7 @@ export function evaluateMedicationRunway(
runwayDays,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
expiredUnits: supply.expiredUnits,
};
}
@@ -234,6 +243,7 @@ export function buildLowStockPayload(input: {
medName: string;
runwayDays: number;
unitsRemaining: number;
expiredUnits: number;
leadDays: number;
triggerDays: number;
schedules: RunwaySchedule[];
@@ -243,6 +253,19 @@ export function buildLowStockPayload(input: {
const title = t("lowStockReminders.title", { medName: input.medName });
if (input.runwayDays < 1) {
// Nothing usable left. Whether that means the packs are empty or
// that every remaining unit was written off is the difference
// between "reorder" and "check whether that really is unusable", so
// the two get different copy.
if (input.unitsRemaining <= 0 && input.expiredUnits > 0) {
return {
title,
body: t("lowStockReminders.bodyExpired", {
medName: input.medName,
units: input.expiredUnits,
}),
};
}
return {
title,
body: t("lowStockReminders.bodyToday", {
@@ -305,6 +328,7 @@ interface LowStockEventMeta {
threshold_days: number;
doses_remaining: number;
units_remaining: number;
expired_units: number;
reason?: "already_notified" | "no_runway" | "no_channel";
}
@@ -436,6 +460,7 @@ export async function runMedicationLowStockTick(
threshold_days: triggerDays,
doses_remaining: evaluation.dosesRemaining,
units_remaining: evaluation.unitsRemaining,
expired_units: evaluation.expiredUnits,
};
switch (decision) {
@@ -466,6 +491,7 @@ export async function runMedicationLowStockTick(
medName: med.name,
runwayDays: evaluation.runwayDays ?? 0,
unitsRemaining: evaluation.unitsRemaining,
expiredUnits: evaluation.expiredUnits,
leadDays,
triggerDays,
schedules: med.schedules,
@@ -487,6 +513,10 @@ export async function runMedicationLowStockTick(
leadDays,
unitsRemaining: evaluation.unitsRemaining,
dosesRemaining: evaluation.dosesRemaining,
// Stock the user physically holds but that was declared
// unusable. The client shows it so "0 left" never reads
// as "you never had any".
expiredUnits: evaluation.expiredUnits,
// Deep link — the web-push sender threads this into
// the service-worker payload's click target. Lands on
// the detail page's Bestand (supply) tab, the surface