From 832a269ab3de4942e939162ac953281bd89ebf97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 14:25:13 +0200 Subject: [PATCH] refactor(medications): split the supply tab at the dialog boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bestand tab was 1,139 lines of one client file holding two concerns that only meet at the row shape: the list of containers a person holds, and the three dialogs that change it. Registration, per-item correction, packaging economics, the summary and the row rendering all shared a scope, so reading any one of them meant scrolling past the rest. Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input conversions their fields produce; the row shape and the cache-invalidation they both need go to `inventory-shared.ts`; the section keeps the summary and the list at 458 lines. Every export the tab offered is still offered here, so no caller changes. No copy changes, no new keys. Two suites pin these contracts by reading the file's own source — the register dialog's unit conversion and the correction dialog's sparse patch. Their `readFileSync` now names the file the code moved to. The assertions are unchanged, byte for byte; only the path they read is. --- .../__tests__/add-inventory-dialog.test.tsx | 2 +- .../inventory-section-dates.test.tsx | 2 +- .../sections/inventory-dialogs.tsx | 666 ++++++++++++++++ .../sections/inventory-section.tsx | 717 +----------------- .../medications/sections/inventory-shared.ts | 63 ++ 5 files changed, 753 insertions(+), 697 deletions(-) create mode 100644 src/components/medications/sections/inventory-dialogs.tsx create mode 100644 src/components/medications/sections/inventory-shared.ts diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx index 949394327..d6861fec3 100644 --- a/src/components/medications/__tests__/add-inventory-dialog.test.tsx +++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx @@ -77,7 +77,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "de"): string { } const src = readFileSync( - resolve(__dirname, "../sections/inventory-section.tsx"), + resolve(__dirname, "../sections/inventory-dialogs.tsx"), "utf8", ); diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx index fe9475895..2e52e26dc 100644 --- a/src/components/medications/__tests__/inventory-section-dates.test.tsx +++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx @@ -21,7 +21,7 @@ import { I18nProvider } from "@/lib/i18n/context"; const src = readFileSync( resolve( process.cwd(), - "src/components/medications/sections/inventory-section.tsx", + "src/components/medications/sections/inventory-dialogs.tsx", ), "utf8", ); diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx new file mode 100644 index 000000000..37e586af2 --- /dev/null +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -0,0 +1,666 @@ +"use client"; + +/** + * The supply tab's write dialogs: register a container, correct one, and edit + * the medication-level packaging the register flow prefills from. + * + * They sit beside the list rather than inside it because they are the other + * half of the tab: the list answers "what do I hold", these answer "change + * it". Sharing one file made a single client component out of two concerns + * that only meet at the row shape, and the shape now lives in + * `./inventory-shared`. + * + * Both dates a container carries are entered — and stored — as a calendar day + * at the browser's local midnight, so the conversions live here too, next to + * the fields that produce them. + */ + +import { useId, useState, type FormEvent } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types"; +import { + CONTAINER_TYPES, + invalidateSupplyQueries, + type ContainerType, + type InventoryItem, +} from "./inventory-shared"; + +/** + * 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. + */ +export 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. */ +export 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(); +} + +/** Default container kind for the register flow, from the delivery form. */ +export function defaultContainerType( + deliveryForm: string | undefined, +): ContainerType { + if (deliveryForm === "INJECTION") return "PEN"; + if (deliveryForm === "ORAL") return "BLISTER"; + return "OTHER"; +} + +/** + * Register a new pack / container. Quantity (units), container type and + * optional printed expiry → Dialog per ui-guidelines §2.3. The quantity + * prefills from the medication's `dosesPerUnit` so the common case is + * one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented + * Dosen | Einheiten control converts live — the POST always carries + * units. + */ +export function AddInventoryDialog({ + medicationId, + defaultUnitsTotal, + unitsPerDose, + initialContainerType, + onClose, +}: { + medicationId: string; + defaultUnitsTotal: number | null; + unitsPerDose: number; + initialContainerType: ContainerType; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [quantity, setQuantity] = useState( + defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000 + ? String(defaultUnitsTotal) + : "", + ); + // The unit the typed quantity is read in. Only surfaced when a dose + // spans several units; the stored value is ALWAYS units. + const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units"); + const [containerType, setContainerType] = + useState(initialContainerType); + const [expiry, setExpiry] = useState(""); + // Carton labelling. Only offered for a PEN: the native pen list is the + // one surface that renders them, and asking for a maker + strength on a + // blister pack would be noise on the far commoner path. + const [manufacturer, setManufacturer] = useState(""); + const [doseStrength, setDoseStrength] = useState(""); + const [busy, setBusy] = useState(false); + const formId = useId(); + + const parsed = Number(quantity); + const effectiveMode = unitsPerDose > 1 ? quantityMode : "units"; + const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed; + const quantityValid = + Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!quantityValid || busy) return; + setBusy(true); + try { + await apiPost(`/api/medications/${medicationId}/inventory`, { + // The wire field carries UNITS (v1.16.10 symmetric naming). + unitsTotal: units, + containerType, + printedExpiry: expiry + ? new Date(`${expiry}T00:00:00`).toISOString() + : null, + // Trimmed to null so a field the user opened and left blank stores + // absence rather than an empty string. + manufacturer: manufacturer.trim() || null, + doseStrength: doseStrength.trim() || null, + }); + await invalidateSupplyQueries(queryClient, medicationId); + toast.success(t("medications.detail.bestand.addSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.addFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.addTitle")} + description={t("medications.detail.bestand.addDescription")} + footer={ + <> + + + + } + > +
+
+ + +
+
+ + {unitsPerDose > 1 && ( +
+ + +
+ )} + setQuantity(e.target.value)} + aria-describedby="inventory-add-quantity-helper" + /> + {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( +

+ {effectiveMode === "doses" + ? t("medications.detail.bestand.quantityInUnits", { units }) + : // A unit count below one dose must not read "≈ 0 doses" — + // it is simply less than one dose. + Math.floor(parsed / unitsPerDose) === 0 + ? t("medications.detail.bestand.quantityUnderOneDose") + : t("medications.detail.bestand.quantityInDoses", { + doses: Math.floor(parsed / unitsPerDose), + })} +

+ )} +

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

+
+
+ + +
+ {containerType === "PEN" && ( + <> +
+ + setManufacturer(e.target.value)} + /> +
+
+ + setDoseStrength(e.target.value)} + /> +

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

+
+ + )} +
+
+ ); +} + +/** + * 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. + */ +export function AdjustInventoryDialog({ + medicationId, + item, + onClose, +}: { + medicationId: string; + item: InventoryItem; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + // v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty, + // not the literal "null"; the operator types the corrected figure. + 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(); + + const parsed = Number(value); + // v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5). + // v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness. + const valid = + value.trim() !== "" && + Number.isFinite(parsed) && + parsed >= 0 && + (item.unitsTotal == null || parsed <= item.unitsTotal); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!valid || busy) return; + setBusy(true); + try { + 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")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.adjustFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.adjustTitle")} + description={t("medications.detail.bestand.adjustDescription")} + footer={ + <> + + + + } + > +
+
+ + setValue(e.target.value)} + aria-describedby="inventory-adjust-helper" + /> +

+ {t("medications.detail.bestand.adjustHelper", { + total: item.unitsTotal ?? t("medications.detail.bestand.unknown"), + })} +

+
+
+ + +

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

+
+
+ + +

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

+
+
+
+ ); +} + +/** + * Edit the medication-level packaging economics from the supply tab: + * units one dose consumes, and the default container size the register + * flow prefills. Both PUT sparsely onto the medication — the wizard's + * dose step stays the source on create; this is the correction surface + * for a manufacturer switch (same medication, different blister size). + */ +export function PackagingDialog({ + medicationId, + unitsPerDose, + dosesPerUnit, + onClose, +}: { + medicationId: string; + unitsPerDose: number; + dosesPerUnit: number | null; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose)); + const [packValue, setPackValue] = useState( + dosesPerUnit === null ? "" : String(dosesPerUnit), + ); + const [busy, setBusy] = useState(false); + const formId = useId(); + + const parsedPerDose = Number(perDoseValue); + const perDoseValid = + Number.isInteger(parsedPerDose) && + parsedPerDose >= 1 && + parsedPerDose <= 100; + const parsedPack = packValue.trim() === "" ? null : Number(packValue); + const packValid = + parsedPack === null || + (Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000); + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!perDoseValid || !packValid || busy) return; + setBusy(true); + try { + await apiPut(`/api/medications/${medicationId}`, { + unitsPerDose: parsedPerDose, + dosesPerUnit: parsedPack, + }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationDetail(medicationId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medications(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }), + ]); + toast.success(t("medications.detail.bestand.packagingSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.packagingFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()} + title={t("medications.detail.bestand.packagingTitle")} + description={t("medications.detail.bestand.packagingDescription")} + footer={ + <> + + + + } + > +
+
+ + setPerDoseValue(e.target.value)} + aria-describedby="packaging-units-per-dose-helper" + /> +

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

+
+
+ + setPackValue(e.target.value)} + aria-describedby="packaging-default-pack-helper" + /> +

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

+
+
+
+ ); +} diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index c31979542..11e493eb6 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -37,118 +37,45 @@ * doses remaining across every non-terminal item — plus a per-item list * with its container type and state badge. Shown for ALL medications * (pill packs count too). + * + * This file is the read half: the summary, the packaging line, and the + * per-item rows. The three write dialogs live in `./inventory-dialogs`, + * and the row shape they both speak in `./inventory-shared`. They are + * re-exported here so the tab keeps one import surface. */ -import { useId, useState, type FormEvent } from "react"; +import { useState } from "react"; import Link from "next/link"; -import { - useQuery, - useQueryClient, - type QueryClient, -} from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DeleteButton } from "@/components/data-list/delete-button"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { DateField } from "@/components/ui/date-field"; import { EmptyState } from "@/components/ui/empty-state"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { SettingsGroup } from "@/components/medications/settings-group"; 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 { - apiDelete, - apiGet, - apiPatch, - apiPost, - apiPut, -} from "@/lib/api/api-fetch"; -import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { SupplySummary } from "@/lib/medications/inventory/summary"; +import { + AddInventoryDialog, + AdjustInventoryDialog, + PackagingDialog, + defaultContainerType, + toLocalDayInput, +} from "./inventory-dialogs"; +import { + invalidateSupplyQueries, + type InventoryItem, + type InventoryState, +} from "./inventory-shared"; -/** - * Invalidate every read key whose payload reflects a medication's supply - * after a container write (register / adjust / delete). - * - * The per-medication inventory read (`medicationInventory`) is the supply - * tab's own list. The medications LIST read (`medications`) carries the - * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that - * the card and table render — without invalidating it, the card kept - * showing the pre-write stock until an unrelated refetch landed (the - * supply-staleness bug). Both keys must drop together. - */ -export async function invalidateSupplyQueries( - queryClient: QueryClient, - medicationId: string, -): Promise { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medications(), - }), - ]); -} - -type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - -const CONTAINER_TYPES = [ - "PEN", - "AMPOULE", - "BLISTER", - "INHALER", - "BOTTLE", - "OTHER", -] as const; -type ContainerType = (typeof CONTAINER_TYPES)[number]; - -interface InventoryItem { - id: string; - state: InventoryState; - containerType: ContainerType; - // v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row); - // 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(); -} +export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs"; +export { invalidateSupplyQueries } from "./inventory-shared"; interface InventoryResponse { items: InventoryItem[]; @@ -170,13 +97,6 @@ const STATE_BADGE: Record< USED_UP: "outline", }; -/** Default container kind for the register flow, from the delivery form. */ -function defaultContainerType(deliveryForm: string | undefined): ContainerType { - if (deliveryForm === "INJECTION") return "PEN"; - if (deliveryForm === "ORAL") return "BLISTER"; - return "OTHER"; -} - export function InventorySection({ medicationId, dosesPerUnit, @@ -536,596 +456,3 @@ export function InventorySection({ ); } - -/** - * Register a new pack / container. Quantity (units), container type and - * optional printed expiry → Dialog per ui-guidelines §2.3. The quantity - * prefills from the medication's `dosesPerUnit` so the common case is - * one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented - * Dosen | Einheiten control converts live — the POST always carries - * units. - */ -export function AddInventoryDialog({ - medicationId, - defaultUnitsTotal, - unitsPerDose, - initialContainerType, - onClose, -}: { - medicationId: string; - defaultUnitsTotal: number | null; - unitsPerDose: number; - initialContainerType: ContainerType; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [quantity, setQuantity] = useState( - defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000 - ? String(defaultUnitsTotal) - : "", - ); - // The unit the typed quantity is read in. Only surfaced when a dose - // spans several units; the stored value is ALWAYS units. - const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units"); - const [containerType, setContainerType] = - useState(initialContainerType); - const [expiry, setExpiry] = useState(""); - // Carton labelling. Only offered for a PEN: the native pen list is the - // one surface that renders them, and asking for a maker + strength on a - // blister pack would be noise on the far commoner path. - const [manufacturer, setManufacturer] = useState(""); - const [doseStrength, setDoseStrength] = useState(""); - const [busy, setBusy] = useState(false); - const formId = useId(); - - const parsed = Number(quantity); - const effectiveMode = unitsPerDose > 1 ? quantityMode : "units"; - const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed; - const quantityValid = - Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000; - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!quantityValid || busy) return; - setBusy(true); - try { - await apiPost(`/api/medications/${medicationId}/inventory`, { - // The wire field carries UNITS (v1.16.10 symmetric naming). - unitsTotal: units, - containerType, - printedExpiry: expiry - ? new Date(`${expiry}T00:00:00`).toISOString() - : null, - // Trimmed to null so a field the user opened and left blank stores - // absence rather than an empty string. - manufacturer: manufacturer.trim() || null, - doseStrength: doseStrength.trim() || null, - }); - await invalidateSupplyQueries(queryClient, medicationId); - toast.success(t("medications.detail.bestand.addSuccess")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.addFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.addTitle")} - description={t("medications.detail.bestand.addDescription")} - footer={ - <> - - - - } - > -
-
- - -
-
- - {unitsPerDose > 1 && ( -
- - -
- )} - setQuantity(e.target.value)} - aria-describedby="inventory-add-quantity-helper" - /> - {unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && ( -

- {effectiveMode === "doses" - ? t("medications.detail.bestand.quantityInUnits", { units }) - : // A unit count below one dose must not read "≈ 0 doses" — - // it is simply less than one dose. - Math.floor(parsed / unitsPerDose) === 0 - ? t("medications.detail.bestand.quantityUnderOneDose") - : t("medications.detail.bestand.quantityInDoses", { - doses: Math.floor(parsed / unitsPerDose), - })} -

- )} -

- {t("medications.detail.bestand.addQuantityHelper")} -

-
-
- - -
- {containerType === "PEN" && ( - <> -
- - setManufacturer(e.target.value)} - /> -
-
- - setDoseStrength(e.target.value)} - /> -

- {t("medications.detail.bestand.addDoseStrengthHint")} -

-
- - )} -
-
- ); -} - -/** - * 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. - */ -export function AdjustInventoryDialog({ - medicationId, - item, - onClose, -}: { - medicationId: string; - item: InventoryItem; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - // v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty, - // not the literal "null"; the operator types the corrected figure. - 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(); - - const parsed = Number(value); - // v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5). - // v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness. - const valid = - value.trim() !== "" && - Number.isFinite(parsed) && - parsed >= 0 && - (item.unitsTotal == null || parsed <= item.unitsTotal); - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!valid || busy) return; - setBusy(true); - try { - 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")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.adjustFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.adjustTitle")} - description={t("medications.detail.bestand.adjustDescription")} - footer={ - <> - - - - } - > -
-
- - setValue(e.target.value)} - aria-describedby="inventory-adjust-helper" - /> -

- {t("medications.detail.bestand.adjustHelper", { - total: item.unitsTotal ?? t("medications.detail.bestand.unknown"), - })} -

-
-
- - -

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

-
-
- - -

- {startsInUseClock(item.containerType) - ? t("medications.detail.bestand.editOpenedHelperClock") - : t("medications.detail.bestand.editOpenedHelper")} -

-
-
-
- ); -} - -/** - * Edit the medication-level packaging economics from the supply tab: - * units one dose consumes, and the default container size the register - * flow prefills. Both PUT sparsely onto the medication — the wizard's - * dose step stays the source on create; this is the correction surface - * for a manufacturer switch (same medication, different blister size). - */ -function PackagingDialog({ - medicationId, - unitsPerDose, - dosesPerUnit, - onClose, -}: { - medicationId: string; - unitsPerDose: number; - dosesPerUnit: number | null; - onClose: () => void; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose)); - const [packValue, setPackValue] = useState( - dosesPerUnit === null ? "" : String(dosesPerUnit), - ); - const [busy, setBusy] = useState(false); - const formId = useId(); - - const parsedPerDose = Number(perDoseValue); - const perDoseValid = - Number.isInteger(parsedPerDose) && - parsedPerDose >= 1 && - parsedPerDose <= 100; - const parsedPack = packValue.trim() === "" ? null : Number(packValue); - const packValid = - parsedPack === null || - (Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000); - - async function submit(e: FormEvent) { - e.preventDefault(); - if (!perDoseValid || !packValid || busy) return; - setBusy(true); - try { - await apiPut(`/api/medications/${medicationId}`, { - unitsPerDose: parsedPerDose, - dosesPerUnit: parsedPack, - }); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationDetail(medicationId), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medications(), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }), - ]); - toast.success(t("medications.detail.bestand.packagingSuccess")); - onClose(); - } catch { - toast.error(t("medications.detail.bestand.packagingFailed")); - } finally { - setBusy(false); - } - } - - return ( - !open && onClose()} - title={t("medications.detail.bestand.packagingTitle")} - description={t("medications.detail.bestand.packagingDescription")} - footer={ - <> - - - - } - > -
-
- - setPerDoseValue(e.target.value)} - aria-describedby="packaging-units-per-dose-helper" - /> -

- {t("medications.detail.bestand.packagingUnitsPerDoseHelper")} -

-
-
- - setPackValue(e.target.value)} - aria-describedby="packaging-default-pack-helper" - /> -

- {t("medications.detail.bestand.packagingDefaultPackHelper")} -

-
-
-
- ); -} diff --git a/src/components/medications/sections/inventory-shared.ts b/src/components/medications/sections/inventory-shared.ts new file mode 100644 index 000000000..3233ccea8 --- /dev/null +++ b/src/components/medications/sections/inventory-shared.ts @@ -0,0 +1,63 @@ +/** + * The facts the supply tab's two halves both need. + * + * The tab is a list of containers on one side and three write dialogs on the + * other, and they meet at exactly this much: the row shape the API returns, + * the container kinds it can carry, and the cache keys a write has to drop. + * Keeping that here is what lets the two halves live in their own files + * without either importing the other's rendering. + */ +import type { QueryClient } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * Invalidate every read key whose payload reflects a medication's supply + * after a container write (register / adjust / delete). + * + * The per-medication inventory read (`medicationInventory`) is the supply + * tab's own list. The medications LIST read (`medications`) carries the + * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that + * the card and table render — without invalidating it, the card kept + * showing the pre-write stock until an unrelated refetch landed (the + * supply-staleness bug). Both keys must drop together. + */ +export async function invalidateSupplyQueries( + queryClient: QueryClient, + medicationId: string, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medications(), + }), + ]); +} + +export type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; + +export const CONTAINER_TYPES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +export type ContainerType = (typeof CONTAINER_TYPES)[number]; + +export interface InventoryItem { + id: string; + state: InventoryState; + containerType: ContainerType; + // v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row); + // 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; +}