feat: simplify tube stock editing UI (#357)
* feat: add package amount persistence and backend route support * test: align backend test schemas with medication metadata fields * fix(backend): restore intake usage normalizer for planner endpoint * fix(backend): keep export typing compatible before liquid-unit stack step * feat: simplify tube stock editing in desktop and mobile forms
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
@@ -68,7 +68,7 @@ export interface MobileEditModalProps {
|
||||
|
||||
/** Calculate total pills from form state */
|
||||
function deriveTotalFromForm(form: FormState) {
|
||||
if (form.packageType === "bottle") {
|
||||
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
||||
// For bottle type, looseTablets is the current stock
|
||||
return Number(form.looseTablets) || 0;
|
||||
}
|
||||
@@ -125,6 +125,28 @@ export function MobileEditModal({
|
||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||
const activeTabIndexRef = useRef(0);
|
||||
|
||||
const allowFractionalIntake = useMemo(() => {
|
||||
if (form.packageType === "liquid_container") return true;
|
||||
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
||||
return form.pillForm === "tablet";
|
||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||
|
||||
const usageLabel = useMemo(() => {
|
||||
if (form.packageType === "liquid_container") {
|
||||
return t("form.blisters.usageMl");
|
||||
}
|
||||
if (form.packageType === "tube") {
|
||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||
}
|
||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||
return t("form.blisters.usageTablets");
|
||||
}, [form.packageType, form.medicationForm, form.pillForm, t]);
|
||||
|
||||
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
|
||||
// Reset tab when modal opens
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
@@ -392,6 +414,7 @@ export function MobileEditModal({
|
||||
<DateInput
|
||||
value={form.medicationStartDate}
|
||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
/>
|
||||
{!readOnlyMode && dateConsistencyError && (
|
||||
<span className="field-error">{dateConsistencyError}</span>
|
||||
@@ -406,8 +429,59 @@ export function MobileEditModal({
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
<option value="tube">{t("form.packageTypeTube")}</option>
|
||||
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
value={form.pillForm}
|
||||
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
<option value="tablet">{t("form.medicationFormTablet")}</option>
|
||||
<option value="capsule">{t("form.medicationFormCapsule")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.packageType === "tube" && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="full">
|
||||
{t("form.medicationEndDate")}
|
||||
<DateInput
|
||||
value={form.medicationEndDate}
|
||||
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
/>
|
||||
</label>
|
||||
{form.medicationEndDate && (
|
||||
<label className="full">
|
||||
{t("form.autoMarkObsoleteAfterEndDate")}
|
||||
<span className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.autoMarkObsoleteAfterEndDate}
|
||||
onChange={(e) => onHandleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
@@ -480,101 +554,179 @@ export function MobileEditModal({
|
||||
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.total")}
|
||||
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{form.packageType === "bottle" && (
|
||||
{(() => {
|
||||
if (form.packageType === "blister") {
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.total")}
|
||||
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (form.packageType === "tube") {
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
{t("form.tubes")}
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.packageAmountPerTube")}
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.packageAmountValue ?? "0"}
|
||||
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.totalAmount")}
|
||||
<div className="static-value">
|
||||
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
|
||||
{t("form.packageAmountUnitG")}
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
{totalCapacityLabel}
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{currentStockLabel}
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
|
||||
<div className="full stock-total-row">
|
||||
<div className="stock-total-field">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
|
||||
{form.packageType !== "tube" && form.packageType !== "liquid_container"
|
||||
? ` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.packageAmount")}
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.packageAmountValue ?? "0"}
|
||||
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<DateInput
|
||||
@@ -630,13 +782,13 @@ export function MobileEditModal({
|
||||
className="blister-row"
|
||||
>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<span>{usageLabel}</span>
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
@@ -666,6 +818,21 @@ export function MobileEditModal({
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.intakeUnit")}</span>
|
||||
<select
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row taken-by-field">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now),
|
||||
intakeUnit: "ml",
|
||||
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
||||
intakeRemindersEnabled: false,
|
||||
};
|
||||
@@ -33,15 +34,22 @@ export const defaultForm = (): FormState => ({
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
medicationForm: "tablet",
|
||||
pillForm: "tablet",
|
||||
lifecycleCategory: "refill_when_empty",
|
||||
packageType: "blister",
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
packageAmountValue: "0",
|
||||
packageAmountUnit: "ml",
|
||||
totalPills: "",
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
medicationEndDate: "",
|
||||
autoMarkObsoleteAfterEndDate: true,
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
@@ -205,6 +213,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(i.every),
|
||||
startDate: toDateValue(i.start),
|
||||
startTime: toTimeValue(i.start),
|
||||
intakeUnit: i.intakeUnit ?? "ml",
|
||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||
}))
|
||||
@@ -213,6 +222,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start),
|
||||
intakeUnit: "ml",
|
||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
@@ -221,20 +231,48 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||
|
||||
const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : "";
|
||||
const bottleTotalPills =
|
||||
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
||||
med.looseTablets
|
||||
? String(med.looseTablets)
|
||||
: "";
|
||||
let resolvedForm = med.medicationForm;
|
||||
if (!resolvedForm) {
|
||||
if (med.packageType === "tube") {
|
||||
resolvedForm = "topical";
|
||||
} else if (med.packageType === "liquid_container") {
|
||||
resolvedForm = "liquid";
|
||||
} else {
|
||||
resolvedForm = med.pillForm ?? "tablet";
|
||||
}
|
||||
}
|
||||
const resolvedPillForm = med.pillForm ?? (resolvedForm === "capsule" ? "capsule" : "tablet");
|
||||
let normalizedPackageAmountUnit = med.packageAmountUnit ?? "ml";
|
||||
if (med.packageType === "tube") {
|
||||
normalizedPackageAmountUnit = "g";
|
||||
} else if (med.packageType === "liquid_container") {
|
||||
normalizedPackageAmountUnit = "ml";
|
||||
}
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
takenBy: med.takenBy || [], // Already an array from API
|
||||
medicationForm: resolvedForm,
|
||||
pillForm: resolvedPillForm,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: med.packageType ?? "blister",
|
||||
packCount: String(med.packCount),
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
packageAmountValue: String(med.packageAmountValue ?? 0),
|
||||
packageAmountUnit: normalizedPackageAmountUnit,
|
||||
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
medicationEndDate: med.medicationEndDate ?? "",
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
@@ -277,6 +315,58 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [key]: value } as FormState;
|
||||
|
||||
if (key === "packageType") {
|
||||
if (value === "tube") {
|
||||
next.medicationForm = "topical";
|
||||
next.lifecycleCategory = "treatment_period";
|
||||
next.doseUnit = "units";
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (value === "liquid_container") {
|
||||
next.medicationForm = "liquid";
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
next.doseUnit = "ml";
|
||||
next.packageAmountUnit = "ml";
|
||||
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
|
||||
} else {
|
||||
next.medicationForm = next.pillForm;
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "medicationForm") {
|
||||
if (next.packageType === "tube") {
|
||||
next.medicationForm = "topical";
|
||||
next.lifecycleCategory = "treatment_period";
|
||||
next.doseUnit = "units";
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (next.packageType === "liquid_container") {
|
||||
next.medicationForm = "liquid";
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
next.doseUnit = "ml";
|
||||
next.packageAmountUnit = "ml";
|
||||
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
|
||||
}
|
||||
}
|
||||
|
||||
if (next.packageType === "tube") {
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (next.packageType === "liquid_container") {
|
||||
next.packageAmountUnit = "ml";
|
||||
}
|
||||
|
||||
if (key === "pillForm" && value === "capsule") {
|
||||
next.medicationForm = "capsule";
|
||||
next.intakes = next.intakes.map((intake) => {
|
||||
const parsedUsage = Number.parseFloat(intake.usage);
|
||||
const rounded = Number.isFinite(parsedUsage) ? Math.max(0, Math.round(parsedUsage)) : 1;
|
||||
return { ...intake, usage: String(rounded || 1) };
|
||||
});
|
||||
}
|
||||
|
||||
if (key === "pillForm" && value === "tablet") {
|
||||
next.medicationForm = "tablet";
|
||||
}
|
||||
|
||||
if (key === "prescriptionAuthorizedRefills") {
|
||||
const raw = String(value);
|
||||
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
||||
|
||||
@@ -167,18 +167,39 @@
|
||||
"commercialName": "Handelsname",
|
||||
"genericName": "Wirkstoff",
|
||||
"takenBy": "Eingenommen von",
|
||||
"medicationForm": "Medikationsform",
|
||||
"medicationFormCapsule": "Kapsel",
|
||||
"medicationFormTablet": "Tablette",
|
||||
"medicationFormLiquid": "Fluessigkeit",
|
||||
"medicationFormTopical": "Topisch",
|
||||
"pillForm": "Pillenform",
|
||||
"lifecycleCategory": "Lebenszyklus",
|
||||
"lifecycleRefillWhenEmpty": "Nachfuellen wenn leer",
|
||||
"lifecycleTreatmentPeriod": "Behandlungszeitraum",
|
||||
"packageType": "Verpackungsart",
|
||||
"packageTypeBlister": "Blisterpackung",
|
||||
"packageTypeBottle": "Pillendose",
|
||||
"packageTypeTube": "Tube",
|
||||
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
|
||||
"packs": "Packungen",
|
||||
"tubes": "Tuben",
|
||||
"blistersPerPack": "Blister pro Packung",
|
||||
"pillsPerBlister": "Tabletten pro Blister",
|
||||
"totalCapacity": "Gesamtkapazität",
|
||||
"currentPills": "Aktuelle Tabletten",
|
||||
"totalAmount": "Gesamtmenge",
|
||||
"currentAmount": "Aktuelle Menge",
|
||||
"totalAmountLabel": "Gesamt (Menge)",
|
||||
"packageAmount": "Packungsinhalt",
|
||||
"packageAmountPerTube": "Inhalt pro Tube",
|
||||
"packageAmountUnitMl": "ml",
|
||||
"packageAmountUnitG": "g",
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"medicationStartDate": "Startdatum der Medikation",
|
||||
"medicationEndDate": "Enddatum der Medikation",
|
||||
"autoMarkObsoleteAfterEndDate": "Nach Enddatum automatisch als obsolet markieren",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
@@ -198,15 +219,25 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
||||
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Einnahmeplan",
|
||||
"remind": "Erinnern",
|
||||
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
|
||||
"addIntake": "Einnahme",
|
||||
"usage": "Dosis (Tabletten)",
|
||||
"usage": "Dosis",
|
||||
"usageTablets": "Dosis (Tabletten)",
|
||||
"usageCapsules": "Dosis (Kapseln)",
|
||||
"usageMl": "Dosis (ml)",
|
||||
"usageApplication": "Dosis (Anwendungen)",
|
||||
"intakeUnit": "Einnahmeeinheit",
|
||||
"intakeUnitMl": "Milliliter (ml)",
|
||||
"intakeUnitTsp": "Teeloeffel (5 ml)",
|
||||
"intakeUnitTbsp": "Essloeffel (15 ml)",
|
||||
"applications": "Anwendungen",
|
||||
"everyDays": "Alle (Tage)",
|
||||
"every": "alle",
|
||||
"from": "ab",
|
||||
@@ -639,6 +670,7 @@
|
||||
"docPackageType": "Verpackungsart",
|
||||
"docBlister": "Blisterpackung",
|
||||
"docBottle": "Pillendose",
|
||||
"docTube": "Tube",
|
||||
"docPacks": "Packungen",
|
||||
"docBlistersPerPack": "Blister pro Packung",
|
||||
"docPillsPerBlister": "Tabletten pro Blister",
|
||||
|
||||
@@ -167,18 +167,39 @@
|
||||
"commercialName": "Commercial Name",
|
||||
"genericName": "Generic Name",
|
||||
"takenBy": "Taken by",
|
||||
"medicationForm": "Medication Form",
|
||||
"medicationFormCapsule": "Capsule",
|
||||
"medicationFormTablet": "Tablet",
|
||||
"medicationFormLiquid": "Liquid",
|
||||
"medicationFormTopical": "Topical",
|
||||
"pillForm": "Pill Form",
|
||||
"lifecycleCategory": "Lifecycle",
|
||||
"lifecycleRefillWhenEmpty": "Refill when empty",
|
||||
"lifecycleTreatmentPeriod": "Treatment period",
|
||||
"packageType": "Package Type",
|
||||
"packageTypeBlister": "Blister Pack",
|
||||
"packageTypeBottle": "Pill Bottle",
|
||||
"packageTypeTube": "Tube",
|
||||
"packageTypeLiquidContainer": "Liquid Container",
|
||||
"packs": "Packs",
|
||||
"tubes": "Tubes",
|
||||
"blistersPerPack": "Blisters per pack",
|
||||
"pillsPerBlister": "Pills per blister",
|
||||
"totalCapacity": "Total Capacity",
|
||||
"currentPills": "Current Pills",
|
||||
"totalAmount": "Total Amount",
|
||||
"currentAmount": "Current Amount",
|
||||
"totalAmountLabel": "Total (amount)",
|
||||
"packageAmount": "Package amount",
|
||||
"packageAmountPerTube": "Amount per tube",
|
||||
"packageAmountUnitMl": "ml",
|
||||
"packageAmountUnitG": "g",
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"medicationStartDate": "Medication Start Date",
|
||||
"medicationEndDate": "Medication End Date",
|
||||
"autoMarkObsoleteAfterEndDate": "Automatically mark obsolete after end date",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
@@ -199,14 +220,24 @@
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
||||
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Intake schedule",
|
||||
"remind": "Remind",
|
||||
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
|
||||
"addIntake": "Intake",
|
||||
"usage": "Usage (pills)",
|
||||
"usage": "Usage",
|
||||
"usageTablets": "Usage (tablets)",
|
||||
"usageCapsules": "Usage (capsules)",
|
||||
"usageMl": "Usage (ml)",
|
||||
"usageApplication": "Usage (applications)",
|
||||
"intakeUnit": "Intake unit",
|
||||
"intakeUnitMl": "Milliliters (ml)",
|
||||
"intakeUnitTsp": "Teaspoon (5 ml)",
|
||||
"intakeUnitTbsp": "Tablespoon (15 ml)",
|
||||
"applications": "applications",
|
||||
"everyDays": "Every (days)",
|
||||
"every": "every",
|
||||
"from": "from",
|
||||
@@ -639,6 +670,7 @@
|
||||
"docPackageType": "Package Type",
|
||||
"docBlister": "Blister Pack",
|
||||
"docBottle": "Pill Bottle",
|
||||
"docTube": "Tube",
|
||||
"docPacks": "Packs",
|
||||
"docBlistersPerPack": "Blisters per pack",
|
||||
"docPillsPerBlister": "Pills per blister",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import type { DoseUnit, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
@@ -239,7 +239,7 @@ export function MedicationsPage() {
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
if (form.packageType === "bottle") {
|
||||
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
||||
// For bottle type, looseTablets is the current stock
|
||||
return Number(form.looseTablets) || 0;
|
||||
}
|
||||
@@ -254,6 +254,14 @@ export function MedicationsPage() {
|
||||
|
||||
const dateConsistencyError = useMemo(() => {
|
||||
const medicationStartDate = form.medicationStartDate;
|
||||
const medicationEndDate = form.medicationEndDate;
|
||||
if (medicationStartDate && medicationEndDate && medicationEndDate < medicationStartDate) {
|
||||
return t("form.validation.endDateBeforeStart", {
|
||||
medicationStartDate,
|
||||
medicationEndDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (!medicationStartDate) return null;
|
||||
|
||||
const conflictingIntake = form.intakes.find((intake) => intake.startDate && intake.startDate < medicationStartDate);
|
||||
@@ -263,7 +271,29 @@ export function MedicationsPage() {
|
||||
medicationStartDate,
|
||||
intakeDate: conflictingIntake.startDate,
|
||||
});
|
||||
}, [form.medicationStartDate, form.intakes, t]);
|
||||
}, [form.medicationStartDate, form.medicationEndDate, form.intakes, t]);
|
||||
|
||||
const allowFractionalIntake = useMemo(() => {
|
||||
if (form.packageType === "liquid_container") return true;
|
||||
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
||||
return form.pillForm === "tablet";
|
||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||
|
||||
const usageLabel = useMemo(() => {
|
||||
if (form.packageType === "liquid_container") {
|
||||
return t("form.blisters.usageMl");
|
||||
}
|
||||
if (form.packageType === "tube") {
|
||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||
}
|
||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||
return t("form.blisters.usageTablets");
|
||||
}, [form.packageType, form.medicationForm, form.pillForm, t]);
|
||||
|
||||
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
|
||||
const clearEditMedIdParam = useCallback(() => {
|
||||
setSearchParams(
|
||||
@@ -450,6 +480,10 @@ export function MedicationsPage() {
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
if (form.pillForm === "capsule" && form.intakes.some((i) => !Number.isInteger(Number(i.usage)))) {
|
||||
setShowNameValidation(true);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
|
||||
// Prepare intakes data with per-intake takenBy
|
||||
@@ -457,6 +491,7 @@ export function MedicationsPage() {
|
||||
usage: Number(intake.usage) || 1,
|
||||
every: Number(intake.every) || 1,
|
||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||
intakeUnit: form.packageType === "liquid_container" ? intake.intakeUnit : null,
|
||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
}));
|
||||
@@ -472,19 +507,43 @@ export function MedicationsPage() {
|
||||
const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
||||
|
||||
const derivedMedicationForm =
|
||||
form.packageType === "tube"
|
||||
? form.medicationForm === "liquid" || form.medicationForm === "topical"
|
||||
? form.medicationForm
|
||||
: "topical"
|
||||
: form.packageType === "liquid_container"
|
||||
? "liquid"
|
||||
: form.pillForm;
|
||||
|
||||
const tubeTotalAmount =
|
||||
form.packageType === "tube" ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null;
|
||||
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
genericName: form.genericName.trim() || null,
|
||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||
medicationForm: derivedMedicationForm,
|
||||
pillForm: form.packageType === "tube" || form.packageType === "liquid_container" ? null : form.pillForm,
|
||||
lifecycleCategory: form.lifecycleCategory,
|
||||
packageType: form.packageType,
|
||||
packCount: Number(form.packCount) || 0,
|
||||
blistersPerPack: Number(form.blistersPerPack) || 1,
|
||||
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
||||
totalPills: Number(form.totalPills) || null,
|
||||
looseTablets: Number(form.looseTablets) || 0,
|
||||
packCount: form.packageType === "tube" ? Math.max(1, Number(form.packCount) || 1) : Number(form.packCount) || 0,
|
||||
blistersPerPack: form.packageType === "tube" ? 1 : Number(form.blistersPerPack) || 1,
|
||||
pillsPerBlister: form.packageType === "tube" ? 1 : Number(form.pillsPerBlister) || 1,
|
||||
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
||||
packageAmountUnit:
|
||||
form.packageType === "tube"
|
||||
? "g"
|
||||
: form.packageType === "liquid_container"
|
||||
? "ml"
|
||||
: (form.packageAmountUnit ?? "ml"),
|
||||
totalPills: form.packageType === "tube" ? tubeTotalAmount : Number(form.totalPills) || null,
|
||||
looseTablets: form.packageType === "tube" ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||
doseUnit: form.doseUnit,
|
||||
medicationStartDate: form.medicationStartDate || null,
|
||||
medicationEndDate: form.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: form.autoMarkObsoleteAfterEndDate,
|
||||
expiryDate: form.expiryDate || null,
|
||||
notes: form.notes.trim() || null,
|
||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||
@@ -883,7 +942,13 @@ export function MedicationsPage() {
|
||||
<span>
|
||||
{t("medications.details.type")}:{" "}
|
||||
<strong>
|
||||
{med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
|
||||
{med.packageType === "bottle"
|
||||
? t("form.packageTypeBottle")
|
||||
: med.packageType === "tube"
|
||||
? t("form.packageTypeTube")
|
||||
: med.packageType === "liquid_container"
|
||||
? t("form.packageTypeLiquidContainer")
|
||||
: t("form.packageTypeBlister")}
|
||||
</strong>
|
||||
</span>
|
||||
{med.packageType === "blister" ? (
|
||||
@@ -918,7 +983,12 @@ export function MedicationsPage() {
|
||||
{coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)}{" "}
|
||||
/ {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
||||
/ {getPackageSize(med)}
|
||||
{med.packageType === "tube"
|
||||
? ""
|
||||
: med.packageType === "liquid_container"
|
||||
? " ml"
|
||||
: ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{(coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)) > getPackageSize(med) && (
|
||||
@@ -936,8 +1006,17 @@ export function MedicationsPage() {
|
||||
<div className="blister-list">
|
||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{s.usage}{" "}
|
||||
{med.packageType === "tube"
|
||||
? med.medicationForm === "liquid"
|
||||
? "ml"
|
||||
: t("form.blisters.usageApplication")
|
||||
: med.packageType === "liquid_container"
|
||||
? "ml"
|
||||
: s.usage === 1
|
||||
? t("common.pill")
|
||||
: t("common.pills")}{" "}
|
||||
· {s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||
@@ -1143,6 +1222,7 @@ export function MedicationsPage() {
|
||||
<DateInput
|
||||
value={form.medicationStartDate}
|
||||
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
/>
|
||||
{!readOnlyView && dateConsistencyError && (
|
||||
<span className="field-error">{dateConsistencyError}</span>
|
||||
@@ -1159,8 +1239,59 @@ export function MedicationsPage() {
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
<option value="tube">{t("form.packageTypeTube")}</option>
|
||||
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||
<label>
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
value={form.pillForm}
|
||||
onChange={(e) => handleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
<option value="tablet">{t("form.medicationFormTablet")}</option>
|
||||
<option value="capsule">{t("form.medicationFormCapsule")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.packageType === "tube" && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t("form.medicationEndDate")}
|
||||
<DateInput
|
||||
value={form.medicationEndDate}
|
||||
onChange={(e) => handleValueChange("medicationEndDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
/>
|
||||
</label>
|
||||
{form.medicationEndDate && (
|
||||
<label className="full">
|
||||
{t("form.autoMarkObsoleteAfterEndDate")}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.autoMarkObsoleteAfterEndDate}
|
||||
onChange={(e) => handleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
)}
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
@@ -1311,10 +1442,51 @@ export function MedicationsPage() {
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
</>
|
||||
) : form.packageType === "tube" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.tubes")}
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.packageAmountPerTube")}
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.packageAmountValue ?? "0"}
|
||||
onChange={(e) => handleValueChange("packageAmountValue", e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.totalAmount")}
|
||||
<div className="static-value">
|
||||
{formatNumber((Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0))}
|
||||
{t("form.packageAmountUnitG")}
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
{totalCapacityLabel}
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
||||
@@ -1324,7 +1496,7 @@ export function MedicationsPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
{currentStockLabel}
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
||||
@@ -1335,38 +1507,63 @@ export function MedicationsPage() {
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
{form.packageType === "bottle" && (
|
||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
|
||||
<div className="full stock-total-row">
|
||||
<label className="stock-total-field">
|
||||
{t("form.total")}
|
||||
{totalLabel}
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label className="full">
|
||||
{t("form.packageAmount")}
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.packageAmountValue ?? "0"}
|
||||
onChange={(e) => handleValueChange("packageAmountValue", e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t("form.expiryDate")}
|
||||
<DateInput
|
||||
@@ -1482,13 +1679,13 @@ export function MedicationsPage() {
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t("form.blisters.usage")}
|
||||
{usageLabel}
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
@@ -1518,6 +1715,21 @@ export function MedicationsPage() {
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{form.packageType === "liquid_container" && (
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
<select
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
|
||||
@@ -500,6 +500,8 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -8,6 +8,9 @@ const defaultForm: FormState = {
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
medicationForm: "tablet",
|
||||
pillForm: "tablet",
|
||||
lifecycleCategory: "refill_when_empty",
|
||||
packageType: "blister",
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
@@ -17,6 +20,8 @@ const defaultForm: FormState = {
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
medicationEndDate: "",
|
||||
autoMarkObsoleteAfterEndDate: true,
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
@@ -235,6 +240,54 @@ describe("MobileEditModal", () => {
|
||||
const header = document.querySelector(".edit-modal-header");
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses plain numeric input for tube amount without stepper controls", () => {
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
form={{
|
||||
...defaultForm,
|
||||
packageType: "tube",
|
||||
medicationForm: "topical",
|
||||
packageAmountValue: "150",
|
||||
packageAmountUnit: "g",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const amountInput = screen.getByLabelText("form.packageAmountPerTube") as HTMLInputElement;
|
||||
expect(amountInput).toBeInTheDocument();
|
||||
expect(amountInput.tagName).toBe("INPUT");
|
||||
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
||||
|
||||
const unitSelect = screen.getByLabelText("form.packageAmountUnitG") as HTMLSelectElement;
|
||||
expect(unitSelect).toBeDisabled();
|
||||
expect(unitSelect.value).toBe("g");
|
||||
});
|
||||
|
||||
it("uses plain numeric input for liquid container package amount", () => {
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
form={{
|
||||
...defaultForm,
|
||||
packageType: "liquid_container",
|
||||
medicationForm: "liquid",
|
||||
packageAmountValue: "250",
|
||||
packageAmountUnit: "ml",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const amountInput = screen.getByLabelText("form.packageAmount") as HTMLInputElement;
|
||||
expect(amountInput).toBeInTheDocument();
|
||||
expect(amountInput.tagName).toBe("INPUT");
|
||||
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
||||
|
||||
const unitSelect = screen.getByLabelText("form.packageAmountUnitMl") as HTMLSelectElement;
|
||||
expect(unitSelect).toBeDisabled();
|
||||
expect(unitSelect.value).toBe("ml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileEditModal with existing people", () => {
|
||||
|
||||
@@ -155,6 +155,78 @@ describe("useMedicationForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces liquid defaults when packageType is liquid_container", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
act(() => {
|
||||
result.current.handleValueChange("packageType", "liquid_container");
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe("liquid_container");
|
||||
expect(result.current.form.medicationForm).toBe("liquid");
|
||||
expect(result.current.form.lifecycleCategory).toBe("refill_when_empty");
|
||||
expect(result.current.form.doseUnit).toBe("ml");
|
||||
expect(result.current.form.packageAmountUnit).toBe("ml");
|
||||
});
|
||||
|
||||
it("keeps liquid settings locked when editing medicationForm under liquid_container", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
act(() => {
|
||||
result.current.handleValueChange("packageType", "liquid_container");
|
||||
result.current.handleValueChange("medicationForm", "tablet");
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe("liquid_container");
|
||||
expect(result.current.form.medicationForm).toBe("liquid");
|
||||
expect(result.current.form.doseUnit).toBe("ml");
|
||||
expect(result.current.form.packageAmountUnit).toBe("ml");
|
||||
});
|
||||
|
||||
it("enforces tube defaults and locks amount unit to grams", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
act(() => {
|
||||
result.current.handleValueChange("packageType", "tube");
|
||||
result.current.handleValueChange("medicationForm", "liquid");
|
||||
result.current.handleValueChange("packageAmountUnit", "ml");
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe("tube");
|
||||
expect(result.current.form.medicationForm).toBe("topical");
|
||||
expect(result.current.form.lifecycleCategory).toBe("treatment_period");
|
||||
expect(result.current.form.doseUnit).toBe("units");
|
||||
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||
});
|
||||
|
||||
it("normalizes legacy tube records to grams in startEdit", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
const openEditModal = vi.fn();
|
||||
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
|
||||
|
||||
const med: Medication = {
|
||||
id: 12,
|
||||
name: "Topical Gel",
|
||||
takenBy: [],
|
||||
packageType: "tube",
|
||||
packageAmountUnit: "ml",
|
||||
packageAmountValue: 150,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.startEdit(med, openEditModal);
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe("tube");
|
||||
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||
});
|
||||
|
||||
it("adds, edits and removes blister rows", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user