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