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, isDiscreteCountPackageType, isLiquidContainerPackageType, isPackageAmountPackageType, isTubePackageType, normalizePackageType, } from "../types"; import { toDateValue, toTimeValue } from "../utils/formatters"; import { normalizeWeekdays } from "../utils/intake-schedule"; import { personTagsMatch } from "../utils/person-tags"; 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>; originalForm: FormState; setOriginalForm: React.Dispatch>; editingId: number | null; setEditingId: React.Dispatch>; showEditModal: boolean; setShowEditModal: React.Dispatch>; fieldErrors: FieldErrors; formSaved: boolean; setFormSaved: React.Dispatch>; hasValidationErrors: boolean; formChanged: boolean; pendingImage: File | null; setPendingImage: React.Dispatch>; pendingImagePreview: string | null; setPendingImagePreview: React.Dispatch>; takenByInput: string; setTakenByInput: React.Dispatch>; 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: (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: (key: K, value: FormState[K]) => void; addTakenByPerson: (name: string) => void; removeTakenByPerson: (name: string) => void; handleTakenByKeyDown: (e: React.KeyboardEvent) => void; } export function useMedicationForm(): UseMedicationFormReturn { const { t } = useTranslation(); const [form, setForm] = useState(defaultForm()); const [originalForm, setOriginalForm] = useState(defaultForm()); const [editingId, setEditingId] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [formSaved, setFormSaved] = useState(false); const [pendingImage, setPendingImage] = useState(null); const [pendingImagePreview, setPendingImagePreview] = useState(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((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 = isPackageAmountPackageType(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 resolvedDoseUnit = med.doseUnit ?? "mg"; if (!med.doseUnit) { if (packageType === "inhaler") { resolvedDoseUnit = "puffs"; } else if (packageType === "injection") { resolvedDoseUnit = "injections"; } } 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: resolvedDoseUnit, 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( (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 if (nextPackageType === "inhaler") { next.medicationForm = "tablet"; next.pillForm = "tablet"; next.lifecycleCategory = "refill_when_empty"; next.doseUnit = "puffs"; } else if (nextPackageType === "injection") { next.medicationForm = "tablet"; next.pillForm = "tablet"; next.lifecycleCategory = "refill_when_empty"; next.doseUnit = "injections"; } else { next.medicationForm = next.pillForm; next.lifecycleCategory = "refill_when_empty"; if (next.doseUnit === "puffs" || next.doseUnit === "injections") { next.doseUnit = "mg"; } } } 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"; } else if (isDiscreteCountPackageType(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(); const alreadyExists = form.takenBy.some((person) => personTagsMatch(person, trimmed)); if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !alreadyExists) { 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) => { 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, }; }