feat(medications): packaging economics editable from the supply tab

Units-per-dose and the default pack size were settable only through the
wizard's dose step; a manufacturer switch with a different blister size
happens while looking at the supply, so the supply tab now carries a
packaging row with an edit dialog that writes sparsely onto the
medication.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 21:08:37 +02:00
1 parent cac9a32756
commit 57eafabb9d
7 files changed
+280 -7

No files matched your search

+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} Einheiten",
"quantityInDoses": "≈ {doses} Dosen",
"quantityUnderOneDose": "< 1 Dosis",
"expiredSuffix": "+ {units} Einheiten abgelaufen"
"expiredSuffix": "+ {units} Einheiten abgelaufen",
"packagingTitle": "Packung & Dosis",
"packagingUnitsPerDose": "1 Dosis = {units} Einheiten",
"packagingDefaultPack": "Standard-Packungsgröße: {units} Einheiten",
"packagingNoDefaultPack": "Keine Standard-Packungsgröße hinterlegt",
"packagingEdit": "Bearbeiten",
"packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.",
"packagingUnitsPerDoseLabel": "Einheiten pro Dosis",
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis (1–100). Bereits erfasste Einnahmen bleiben unverändert.",
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
"packagingSuccess": "Packungsangaben gespeichert",
"packagingFailed": "Speichern fehlgeschlagen"
},
"erweitert": {
"group": {
+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} units",
"quantityInDoses": "≈ {doses} doses",
"quantityUnderOneDose": "< 1 dose",
"expiredSuffix": "+ {units} units expired"
"expiredSuffix": "+ {units} units expired",
"packagingTitle": "Packaging & dose",
"packagingUnitsPerDose": "1 dose = {units} units",
"packagingDefaultPack": "Default pack size: {units} units",
"packagingNoDefaultPack": "No default pack size set",
"packagingEdit": "Edit",
"packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.",
"packagingUnitsPerDoseLabel": "Units per dose",
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose (1–100). Doses already recorded stay unchanged.",
"packagingDefaultPackLabel": "Default pack size (units)",
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
"packagingSuccess": "Packaging details saved",
"packagingFailed": "Saving failed"
},
"erweitert": {
"group": {
+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} unidades",
"quantityInDoses": "≈ {doses} dosis",
"quantityUnderOneDose": "< 1 dosis",
"expiredSuffix": "+ {units} unidades caducadas"
"expiredSuffix": "+ {units} unidades caducadas",
"packagingTitle": "Envase y dosis",
"packagingUnitsPerDose": "1 dosis = {units} unidades",
"packagingDefaultPack": "Tamaño de envase predeterminado: {units} unidades",
"packagingNoDefaultPack": "Sin tamaño de envase predeterminado",
"packagingEdit": "Editar",
"packagingDescription": "Define cuántas unidades consume una dosis y el tamaño habitual de un envase nuevo, p. ej. tras un cambio de fabricante con otro tamaño de blíster.",
"packagingUnitsPerDoseLabel": "Unidades por dosis",
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg (1–100). Las tomas ya registradas no cambian.",
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
"packagingSuccess": "Datos del envase guardados",
"packagingFailed": "Error al guardar"
},
"erweitert": {
"group": {
+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} unités",
"quantityInDoses": "≈ {doses} doses",
"quantityUnderOneDose": "< 1 dose",
"expiredSuffix": "+ {units} unités périmées"
"expiredSuffix": "+ {units} unités périmées",
"packagingTitle": "Conditionnement et dose",
"packagingUnitsPerDose": "1 dose = {units} unités",
"packagingDefaultPack": "Taille de boîte par défaut : {units} unités",
"packagingNoDefaultPack": "Aucune taille de boîte par défaut",
"packagingEdit": "Modifier",
"packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.",
"packagingUnitsPerDoseLabel": "Unités par dose",
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg (1–100). Les prises déjà enregistrées restent inchangées.",
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
"packagingSuccess": "Conditionnement enregistré",
"packagingFailed": "Échec de l’enregistrement"
},
"erweitert": {
"group": {
+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} unità",
"quantityInDoses": "≈ {doses} dosi",
"quantityUnderOneDose": "< 1 dose",
"expiredSuffix": "+ {units} unità scadute"
"expiredSuffix": "+ {units} unità scadute",
"packagingTitle": "Confezione e dose",
"packagingUnitsPerDose": "1 dose = {units} unità",
"packagingDefaultPack": "Dimensione confezione predefinita: {units} unità",
"packagingNoDefaultPack": "Nessuna dimensione confezione predefinita",
"packagingEdit": "Modifica",
"packagingDescription": "Imposta quante unità consuma una dose e la dimensione abituale di una nuova confezione, ad es. dopo un cambio di produttore con blister diverso.",
"packagingUnitsPerDoseLabel": "Unità per dose",
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg (1–100). Le assunzioni già registrate restano invariate.",
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
"packagingSuccess": "Dati confezione salvati",
"packagingFailed": "Salvataggio non riuscito"
},
"erweitert": {
"group": {
+13 -1
View File
@@ -1635,7 +1635,19 @@
"quantityInUnits": "= {units} jednostek",
"quantityInDoses": "Dawki: ≈ {doses}",
"quantityUnderOneDose": "< 1 dawka",
"expiredSuffix": "+ przeterminowane jednostki: {units}"
"expiredSuffix": "+ przeterminowane jednostki: {units}",
"packagingTitle": "Opakowanie i dawka",
"packagingUnitsPerDose": "1 dawka = {units} jedn.",
"packagingDefaultPack": "Domyślny rozmiar opakowania: {units} jedn.",
"packagingNoDefaultPack": "Brak domyślnego rozmiaru opakowania",
"packagingEdit": "Edytuj",
"packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.",
"packagingUnitsPerDoseLabel": "Jednostki na dawkę",
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg (1–100). Zarejestrowane dawki pozostają bez zmian.",
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
"packagingSuccess": "Zapisano dane opakowania",
"packagingFailed": "Zapis nie powiódł się"
},
"erweitert": {
"group": {
@@ -63,7 +63,13 @@ import {
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
@@ -123,6 +129,7 @@ export function InventorySection({
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const [packagingOpen, setPackagingOpen] = useState(false);
async function deleteItem(item: InventoryItem) {
try {
@@ -194,6 +201,14 @@ export function InventorySection({
onClose={() => setAdjustItem(null)}
/>
)}
{packagingOpen && (
<PackagingDialog
medicationId={medicationId}
unitsPerDose={perDose}
dosesPerUnit={dosesPerUnit ?? null}
onClose={() => setPackagingOpen(false)}
/>
)}
</>
);
@@ -258,6 +273,39 @@ export function InventorySection({
</div>
</SettingsGroup>
{/* v1.16.11 — packaging economics surfaced where the stock lives:
the wizard's dose step owns these on create/edit, but a
manufacturer switch (a different blister size) happens while
looking at the supply, so the supply tab carries them too. */}
<SettingsGroup
label={t("medications.detail.bestand.packagingTitle")}
dataSlot="inventory-packaging-group"
>
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.packagingUnitsPerDose", {
units: perDose,
})}
<span className="text-muted-foreground block text-xs font-normal">
{dosesPerUnit != null
? t("medications.detail.bestand.packagingDefaultPack", {
units: dosesPerUnit,
})
: t("medications.detail.bestand.packagingNoDefaultPack")}
</span>
</p>
<Button
variant="outline"
size="sm"
onClick={() => setPackagingOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="inventory-packaging-edit"
>
{t("medications.detail.bestand.packagingEdit")}
</Button>
</div>
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.bestand.itemsTitle")}
dataSlot="inventory-items-group"
@@ -651,3 +699,156 @@ function AdjustInventoryDialog({
</Dialog>
);
}
/**
* 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 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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.packagingTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.packagingDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!perDoseValid || !packValid || busy}>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}