mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
cac9a32756
commit
57eafabb9d
7 files changed
+280
-7
No files matched your search
+13
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user