Files
medassist-ng/frontend/src/hooks/useMedicationForm.ts
T
2026-03-20 14:58:25 +01:00

525 lines
18 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import {
FIELD_LIMITS,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
export const defaultBlister = (): FormBlister => {
const now = new Date();
return {
usage: "1",
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
};
};
/**
* Create a new intake with optional per-intake takenBy
*/
export const defaultIntake = (takenBy: string = ""): FormIntake => {
const now = new Date();
return {
usage: "1",
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
scheduleMode: "interval",
weekdays: [],
intakeUnit: "ml",
takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false,
};
};
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,
prescriptionAuthorizedRefills: "",
prescriptionRemainingRefills: "",
prescriptionLowRefillThreshold: "1",
prescriptionExpiryDate: "",
intakeRemindersEnabled: false,
blisters: [defaultBlister()],
intakes: [defaultIntake()],
});
export interface UseMedicationFormReturn {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
originalForm: FormState;
setOriginalForm: React.Dispatch<React.SetStateAction<FormState>>;
editingId: number | null;
setEditingId: React.Dispatch<React.SetStateAction<number | null>>;
showEditModal: boolean;
setShowEditModal: React.Dispatch<React.SetStateAction<boolean>>;
fieldErrors: FieldErrors;
formSaved: boolean;
setFormSaved: React.Dispatch<React.SetStateAction<boolean>>;
hasValidationErrors: boolean;
formChanged: boolean;
pendingImage: File | null;
setPendingImage: React.Dispatch<React.SetStateAction<File | null>>;
pendingImagePreview: string | null;
setPendingImagePreview: React.Dispatch<React.SetStateAction<string | null>>;
takenByInput: string;
setTakenByInput: React.Dispatch<React.SetStateAction<string>>;
validateField: (field: keyof FieldErrors, value: string | string[]) => string | undefined;
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
addBlister: () => void;
removeBlister: (idx: number) => void;
// Intake management with per-intake takenBy
setIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
addIntake: (takenBy?: string) => void;
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
addTakenByPerson: (name: string) => void;
removeTakenByPerson: (name: string) => void;
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
export function useMedicationForm(): UseMedicationFormReturn {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(defaultForm());
const [originalForm, setOriginalForm] = useState<FormState>(defaultForm());
const [editingId, setEditingId] = useState<number | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [formSaved, setFormSaved] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
const [takenByInput, setTakenByInput] = useState("");
const parseNonNegativeInt = useCallback((value: string): number => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) return 0;
return parsed;
}, []);
// Validate form fields
const validateField = useCallback(
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
const limits = FIELD_LIMITS[field];
// Skip validation for takenBy array (individual items validated on add)
if (field === "takenBy") return undefined;
const strValue = typeof value === "string" ? value : "";
if ("max" in limits && strValue.length > limits.max) {
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
}
return undefined;
},
[t]
);
// Check if form has any errors
const hasValidationErrors = useMemo(() => {
return Object.values(fieldErrors).some((error) => error !== undefined);
}, [fieldErrors]);
// Check if form has been modified from original state
const formChanged = useMemo(() => {
return JSON.stringify(form) !== JSON.stringify(originalForm);
}, [form, originalForm]);
// Reset formSaved when form changes
useEffect(() => {
if (formChanged) {
setFormSaved(false);
}
}, [formChanged]);
// Validate all fields when form changes
useEffect(() => {
const errors: FieldErrors = {};
(["name", "genericName", "notes"] as const).forEach((f) => {
const error = validateField(f, form[f]);
if (error) errors[f] = error;
});
// Cross-field validation: at least one of name or genericName is required
const hasName = form.name && form.name.trim().length > 0;
const hasGenericName = form.genericName && form.genericName.trim().length > 0;
if (!hasName && !hasGenericName) {
const msg = t("common.validation.nameOrGenericRequired");
errors.name = errors.name || msg;
errors.genericName = errors.genericName || msg;
}
setFieldErrors(errors);
}, [form.name, form.genericName, form.notes, validateField, form, t]);
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
setForm((prev) => {
const next = [...prev.blisters];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, blisters: next };
});
}, []);
const addBlister = useCallback(() => {
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
}, []);
const removeBlister = useCallback((idx: number) => {
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
}, []);
// Intake management with per-intake takenBy
const setIntakeValue = useCallback(<K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => {
setForm((prev) => {
const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, intakes: next };
});
}, []);
const addIntake = useCallback((takenBy: string = "") => {
setForm((prev) => ({ ...prev, intakes: [...prev.intakes, defaultIntake(takenBy)] }));
}, []);
const removeIntake = useCallback((idx: number) => {
setForm((prev) => ({ ...prev, intakes: prev.intakes.filter((_, i) => i !== idx) }));
}, []);
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved
setFieldErrors({}); // Prevent one-frame stale error highlight from previous/default form state
// Parse intakes - prefer new format, fallback to legacy blisters
const intakesFromApi =
med.intakes && med.intakes.length > 0
? med.intakes.map((i) => ({
usage: String(i.usage),
every: String(i.every),
startDate: toDateValue(i.start),
startTime: toTimeValue(i.start),
scheduleMode: (i.scheduleMode === "weekdays" ? "weekdays" : "interval") as FormIntake["scheduleMode"],
weekdays: normalizeWeekdays(i.weekdays),
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled,
}))
: med.blisters.map((s) => ({
usage: String(s.usage),
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
scheduleMode: "interval" as const,
weekdays: [],
intakeUnit: "ml" as const,
takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
const packageType = normalizePackageType(med.packageType);
const isTubeOrLiquidPackage = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
let normalizedPackCount = String(med.packCount);
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
if (isTubeOrLiquidPackage) {
const safePackCount = isTubePackageType(packageType) ? 1 : Math.max(1, med.packCount || 1);
normalizedPackCount = String(safePackCount);
const rawPackageAmount = Number(med.packageAmountValue ?? 0);
const legacyKnownAmount = Math.max(0, Number(med.totalPills ?? 0), Number(med.looseTablets ?? 0));
if (isTubePackageType(packageType)) {
normalizedPackageAmountValue = String(
legacyKnownAmount > 0 ? legacyKnownAmount : Math.max(1, rawPackageAmount)
);
} else if (rawPackageAmount > 0) {
normalizedPackageAmountValue = String(rawPackageAmount);
} else {
normalizedPackageAmountValue = String(legacyKnownAmount);
}
}
const normalizedDerivedTotal = isTubeOrLiquidPackage
? Math.max(0, (Number(normalizedPackCount) || 0) * (Number(normalizedPackageAmountValue) || 0))
: null;
const bottleTotalPills = isAmountBasedPackageType(packageType) && med.looseTablets ? String(med.looseTablets) : "";
let resolvedForm = med.medicationForm;
if (!resolvedForm) {
if (isTubePackageType(packageType)) {
resolvedForm = "topical";
} else if (isLiquidContainerPackageType(packageType)) {
resolvedForm = "liquid";
} else {
resolvedForm = med.pillForm ?? "tablet";
}
}
const resolvedPillForm = med.pillForm ?? (resolvedForm === "capsule" ? "capsule" : "tablet");
let normalizedPackageAmountUnit = med.packageAmountUnit ?? "ml";
if (isTubePackageType(packageType)) {
normalizedPackageAmountUnit = "g";
} else if (isLiquidContainerPackageType(packageType)) {
normalizedPackageAmountUnit = "ml";
}
let resolvedTotalPills = bottleTotalPills;
if (normalizedDerivedTotal != null) {
resolvedTotalPills = String(normalizedDerivedTotal);
} else if (med.totalPills) {
resolvedTotalPills = String(med.totalPills);
}
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,
packCount: normalizedPackCount,
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
packageAmountValue: normalizedPackageAmountValue,
packageAmountUnit: normalizedPackageAmountUnit,
totalPills: resolvedTotalPills,
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : 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,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills != null ? String(authorizedRefills) : "",
prescriptionRemainingRefills: med.prescriptionRemainingRefills != null ? String(remainingRefills) : "",
prescriptionLowRefillThreshold: String(lowRefillThreshold),
prescriptionExpiryDate: med.prescriptionExpiryDate ? med.prescriptionExpiryDate.slice(0, 10) : "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
blisters: med.blisters.map((s) => ({
usage: String(s.usage),
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
})),
intakes: intakesFromApi,
};
setForm(editForm);
setOriginalForm(editForm);
// Show modal on mobile
if (window.innerWidth <= 768) {
openEditModal();
}
}, []);
const resetForm = useCallback(() => {
setEditingId(null);
setShowEditModal(false);
setPendingImage(null);
setPendingImagePreview(null);
setTakenByInput("");
setFieldErrors({});
setFormSaved(false);
const newForm = defaultForm();
setForm(newForm);
setOriginalForm(newForm);
}, []);
const handleValueChange = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => {
const next = { ...prev, [key]: value } as FormState;
if (key === "packageType") {
const nextPackageType = value as FormState["packageType"];
if (isTubePackageType(nextPackageType)) {
next.packCount = "1";
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
next.medicationForm = "topical";
next.lifecycleCategory = "treatment_period";
next.doseUnit = "units";
next.packageAmountUnit = "g";
} else if (isLiquidContainerPackageType(nextPackageType)) {
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
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 (isTubePackageType(next.packageType)) {
next.medicationForm = "topical";
next.lifecycleCategory = "treatment_period";
next.doseUnit = "units";
next.packageAmountUnit = "g";
} else if (isLiquidContainerPackageType(next.packageType)) {
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 (isTubePackageType(next.packageType)) {
next.packCount = "1";
next.packageAmountUnit = "g";
} else if (isLiquidContainerPackageType(next.packageType)) {
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));
}
if (key === "prescriptionRemainingRefills") {
const raw = String(value);
next.prescriptionRemainingRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (key === "prescriptionLowRefillThreshold") {
const raw = String(value);
next.prescriptionLowRefillThreshold = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (!next.prescriptionEnabled) {
return next;
}
const authorizedRefills = parseNonNegativeInt(next.prescriptionAuthorizedRefills);
if (key === "prescriptionAuthorizedRefills") {
next.prescriptionRemainingRefills = String(
Math.min(parseNonNegativeInt(next.prescriptionRemainingRefills), authorizedRefills)
);
next.prescriptionLowRefillThreshold = String(
Math.min(parseNonNegativeInt(next.prescriptionLowRefillThreshold), authorizedRefills)
);
}
if (key === "prescriptionRemainingRefills") {
next.prescriptionRemainingRefills = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
if (key === "prescriptionLowRefillThreshold") {
next.prescriptionLowRefillThreshold = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
return next;
});
},
[parseNonNegativeInt]
);
// Tag input helpers for "Taken By" field
const addTakenByPerson = useCallback(
(name: string) => {
const trimmed = name.trim();
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
}
setTakenByInput("");
},
[form.takenBy]
);
const removeTakenByPerson = useCallback((name: string) => {
setForm((prev) => ({ ...prev, takenBy: prev.takenBy.filter((p) => p !== name) }));
}, []);
const handleTakenByKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTakenByPerson(takenByInput);
} else if (e.key === "Backspace" && !takenByInput && form.takenBy.length > 0) {
// Remove last tag on backspace when input is empty
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
}
},
[takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]
);
return {
form,
setForm,
originalForm,
setOriginalForm,
editingId,
setEditingId,
showEditModal,
setShowEditModal,
fieldErrors,
formSaved,
setFormSaved,
hasValidationErrors,
formChanged,
pendingImage,
setPendingImage,
pendingImagePreview,
setPendingImagePreview,
takenByInput,
setTakenByInput,
validateField,
setBlisterValue,
addBlister,
removeBlister,
setIntakeValue,
addIntake,
removeIntake,
startEdit,
resetForm,
handleValueChange,
addTakenByPerson,
removeTakenByPerson,
handleTakenByKeyDown,
};
}