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; +}