68ab79c713
Closes #463
525 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|