From a4ff11f10d86fc51b3ce8202683927b7f1e38676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 10:32:07 +0200 Subject: [PATCH] fix(medications): surface and correct the dates that decide a container's state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 17 +- messages/en.json | 17 +- messages/es.json | 17 +- messages/fr.json | 17 +- messages/it.json | 17 +- messages/pl.json | 17 +- .../inventory-section-dates.test.tsx | 208 ++++++++++++++++++ .../inventory-section-summary.test.tsx | 32 ++- .../sections/inventory-section.tsx | 181 +++++++++++++-- .../__tests__/medication-low-stock.test.ts | 26 +++ src/lib/jobs/medication-low-stock.ts | 30 +++ 11 files changed, 536 insertions(+), 43 deletions(-) create mode 100644 src/components/medications/__tests__/inventory-section-dates.test.tsx diff --git a/messages/de.json b/messages/de.json index e6e20165d..b10ea57da 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index b1b50f6fc..ee47dc886 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 9f56ae1f6..50a18d9f4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index f5348e6d8..713e46e24 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 03c3462ee..6bc653e8c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f1d9fb61a..b05dc3814 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx new file mode 100644 index 000000000..b52cbcff2 --- /dev/null +++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx @@ -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 }) => ( +
{children}
+ ); + return { + Dialog: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: Pass, + DialogDescription: Pass, + DialogFooter: Pass, + DialogHeader: Pass, + DialogTitle: Pass, + }; +}); + +vi.mock("@/components/ui/select", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Select: ({ + value, + children, + }: { + value: string; + children?: React.ReactNode; + }) =>
{children}
, + SelectContent: Pass, + SelectItem: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + 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( + {node}, + ); +} + +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(" — 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).not.toContain('data-slot="inventory-item-dates"'); + }); +}); + +describe(" — editable dates", () => { + const item = { + ...BASE, + firstUseAt: "2026-06-12T00:00:00.000Z", + printedExpiry: "2027-06-01T00:00:00.000Z", + }; + + function renderDialog(over: Partial = {}): string { + return render( + {}} + />, + ); + } + + 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(" — 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( + {}} + />, + ); + expect(html).toContain('id="inventory-add-expiry"'); + }); +}); diff --git a/src/components/medications/__tests__/inventory-section-summary.test.tsx b/src/components/medications/__tests__/inventory-section-summary.test.tsx index 599244947..9c9fa416b 100644 --- a/src/components/medications/__tests__/inventory-section-summary.test.tsx +++ b/src/components/medications/__tests__/inventory-section-summary.test.tsx @@ -68,7 +68,11 @@ function seedItems( } describe(" — 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(" — summary availability", () => { 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"); + 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(" — 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( + , + ); + expect(html).toContain("0 von 0 Dosen übrig"); + expect(html).not.toContain("Kein nutzbarer Bestand"); }); }); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index d473fe8d5..aac3bba24 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -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(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({ >

- {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 && ( {t("medications.detail.bestand.unitsDetail", { remaining: formatUnitCount(remainingUnits), @@ -311,7 +364,7 @@ export function InventorySection({ })} )} - {expiredUnits > 0 && ( + {expiredUnits > 0 && !allExpired && ( + {/* 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) && ( + + {item.firstUseAt && ( + + {t("medications.detail.bestand.openedOn", { + date: formatDate( + toLocalDayInput(item.firstUseAt), + dateFormatPref, + locale, + ), + })} + + )} + {item.printedExpiry && ( + + {t("medications.detail.bestand.expiresOn", { + date: formatDate( + toLocalDayInput(item.printedExpiry), + dateFormatPref, + locale, + ), + })} + + )} + + )}

+
+ + +

+ {t("medications.detail.bestand.editExpiryHelper")} +

+
+
+ + +

+ {CLOCK_CONTAINER_TYPES.includes(item.containerType) + ? t("medications.detail.bestand.editOpenedHelperClock") + : t("medications.detail.bestand.editOpenedHelper")} +

+
); diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 4aa1a21f0..618b2ae7f 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -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({ diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 22f7f6f96..94d9458df 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -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