/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */ /* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */ import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { ConfirmModal, DateInput, FormNumberStepper, Lightbox, MedicationAvatar, MobileEditModal, ReportModal, } from "../components"; import { useAuth } from "../components/Auth"; import { useAppContext, useUnsavedChanges } from "../context"; import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks"; import type { DoseUnit, Medication } from "../types"; import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types"; import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters"; import { log } from "../utils/logger"; function userStorageKey(userId: number | undefined, key: string): string { return userId ? `user_${userId}_${key}` : key; } const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete"; export function MedicationsPage() { const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); const { user } = useAuth(); const { meds, saving, setSaving, loadMeds, deleteMed, uploadMedImage, deleteMedImage, uploadingImage, existingPeople, coverageByMed, } = useAppContext(); // Use the medication form hook const { form, setForm, setOriginalForm, editingId, formSaved, setFormSaved, formChanged, fieldErrors, hasValidationErrors, takenByInput, setTakenByInput, addTakenByPerson, removeTakenByPerson, handleTakenByKeyDown, handleValueChange, addBlister, removeBlister, setBlisterValue, addIntake, removeIntake, setIntakeValue, resetForm, startEdit, } = useMedicationForm(); // Warn user about unsaved changes when navigating away useUnsavedChangesWarning(formChanged); // View mode: grid (default) or form (edit/new) const [viewMode, setViewMode] = useState<"grid" | "form">("grid"); const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null); const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); // Mobile modal state (declared early because it's used in useEffect below) const [showEditModal, setShowEditModal] = useState(false); const showEditModalRef = useRef(false); useEffect(() => { showEditModalRef.current = showEditModal; }, [showEditModal]); const processedEditMedIdRef = useRef(null); const hasDesktopFormHistoryState = useRef(false); // Sync formChanged state to the global context for navigation blocking const { setHasUnsavedChanges } = useUnsavedChanges(); useEffect(() => { setHasUnsavedChanges(formChanged); return () => setHasUnsavedChanges(false); // Clear on unmount }, [formChanged, setHasUnsavedChanges]); // Push history state when form changes to capture browser back button const hasUnsavedHistoryState = useRef(false); useEffect(() => { if (formChanged && !hasUnsavedHistoryState.current && !showEditModal) { // Push a history state so we can intercept browser back window.history.pushState({ unsavedChanges: true }, ""); hasUnsavedHistoryState.current = true; } else if (!formChanged && hasUnsavedHistoryState.current) { // Clean up history state when form is saved/reset hasUnsavedHistoryState.current = false; } }, [formChanged, showEditModal]); // Push a history state when desktop form is open so browser back returns to grid view. useEffect(() => { const isDesktop = window.innerWidth > 768; if (isDesktop && viewMode === "form" && !showEditModal && !hasDesktopFormHistoryState.current) { window.history.pushState({ desktopForm: true }, ""); hasDesktopFormHistoryState.current = true; } if ((viewMode === "grid" || showEditModal) && hasDesktopFormHistoryState.current) { hasDesktopFormHistoryState.current = false; } }, [viewMode, showEditModal]); // Image state for new medications const [pendingImage, setPendingImage] = useState(null); const [pendingImagePreview, setPendingImagePreview] = useState(null); // Track if close was confirmed programmatically (to avoid double confirmation) const closeConfirmedRef = useRef(false); // Pending action to execute after user confirms "Leave" in unsaved changes modal const pendingActionRef = useRef<(() => void) | null>(null); // Confirmation modal for unsaved changes const [showUnsavedConfirm, setShowUnsavedConfirm] = useState(false); const [unsavedConfirmSource, setUnsavedConfirmSource] = useState<"mobile-edit" | "desktop-form" | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteCandidate, setDeleteCandidate] = useState(null); const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false); const [obsoleteCandidate, setObsoleteCandidate] = useState(null); const [allMeds, setAllMeds] = useState(meds); const [showObsolete, setShowObsolete] = useState(true); const [readOnlyView, setReadOnlyView] = useState(false); const [showReportModal, setShowReportModal] = useState(false); useModalHistory(showReportModal, "report", () => setShowReportModal(false)); const [showNameValidation, setShowNameValidation] = useState(false); useEffect(() => { const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY)); if (saved !== null) { setShowObsolete(saved === "true"); } }, [user?.id]); const toggleObsoleteSection = useCallback(() => { setShowObsolete((prev) => { const next = !prev; localStorage.setItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY), String(next)); return next; }); }, [user?.id]); const loadAllMeds = useCallback(async () => { try { const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" }); const data = (await res.json()) as unknown; setAllMeds(Array.isArray(data) ? (data as Medication[]) : []); } catch { setAllMeds([]); } }, []); useEffect(() => { void loadAllMeds(); }, [loadAllMeds]); // Calculate total tablets const totalTablets = useMemo(() => { if (form.packageType === "bottle") { // For bottle type, looseTablets is the current stock return Number(form.looseTablets) || 0; } // For blister type const packCount = Number(form.packCount) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0; const pillsPerBlister = Number(form.pillsPerBlister) || 1; return packCount * blistersPerPack * pillsPerBlister; }, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]); const decrementValueLabel = t("editStock.decreaseValue"); const incrementValueLabel = t("editStock.increaseValue"); const dateConsistencyError = useMemo(() => { const medicationStartDate = form.medicationStartDate; if (!medicationStartDate) return null; const conflictingIntake = form.intakes.find((intake) => intake.startDate && intake.startDate < medicationStartDate); if (!conflictingIntake?.startDate) return null; return t("form.validation.startDateAfterIntake", { medicationStartDate, intakeDate: conflictingIntake.startDate, }); }, [form.medicationStartDate, form.intakes, t]); const clearEditMedIdParam = useCallback(() => { setSearchParams( (prevParams) => { if (!prevParams.has("editMedId")) return prevParams; const nextParams = new URLSearchParams(prevParams); nextParams.delete("editMedId"); return nextParams; }, { replace: true } ); }, [setSearchParams]); // Open mobile edit modal function openEditModal() { if (showEditModalRef.current) return; showEditModalRef.current = true; setShowEditModal(true); window.history.pushState({ modal: "edit" }, ""); } // Close mobile edit modal function closeEditModal() { if (showEditModal) { // Check for unsaved changes before closing if (formChanged) { setUnsavedConfirmSource("mobile-edit"); setShowUnsavedConfirm(true); return; } clearEditMedIdParam(); // Mark as confirmed to avoid double confirmation in popstate handler closeConfirmedRef.current = true; window.history.back(); } } // Handle confirmed close (user clicked "Leave" in confirmation modal) function handleConfirmClose() { const source = unsavedConfirmSource; const pendingAction = pendingActionRef.current; setShowUnsavedConfirm(false); setUnsavedConfirmSource(null); pendingActionRef.current = null; closeConfirmedRef.current = true; hasUnsavedHistoryState.current = false; if (pendingAction) { // There's a pending action (e.g. switching to another medication) — reset and run it resetForm(); setReadOnlyView(false); pendingAction(); } else if (source === "mobile-edit" && showEditModal) { clearEditMedIdParam(); setShowEditModal(false); resetForm(); setReadOnlyView(false); window.history.back(); } else { // Desktop form — reset and go back to grid handleResetForm(); } } // Handle cancelled close (user clicked "Stay" in confirmation modal) function handleCancelClose() { setShowUnsavedConfirm(false); pendingActionRef.current = null; if (unsavedConfirmSource === "mobile-edit") { setShowEditModal(true); } setUnsavedConfirmSource(null); } // Helper to reset form and clear history state function handleResetForm() { hasDesktopFormHistoryState.current = false; if (hasUnsavedHistoryState.current) { hasUnsavedHistoryState.current = false; // Go back to remove the unsaved changes history entry window.history.back(); } resetForm(); setShowNameValidation(false); setActiveTab("general"); setReadOnlyView(false); setViewMode("grid"); } // Guard for desktop form Back/Cancel — shows unsaved changes modal if needed function handleDesktopFormLeave() { if (readOnlyView) { handleResetForm(); return; } if (formChanged) { setUnsavedConfirmSource("desktop-form"); setShowUnsavedConfirm(true); return; } handleResetForm(); } function requestDeleteMed(med: Medication) { setDeleteCandidate(med); setShowDeleteConfirm(true); window.history.pushState({ modal: "delete-confirm" }, ""); } async function handleConfirmDelete() { if (!deleteCandidate) return; await deleteMed(deleteCandidate.id, editingId, resetForm); await loadAllMeds(); setShowDeleteConfirm(false); setDeleteCandidate(null); // Pop the delete-confirm history entry window.history.back(); } function handleCancelDelete() { setShowDeleteConfirm(false); setDeleteCandidate(null); // Pop the delete-confirm history entry window.history.back(); } function requestMarkObsolete(med: Medication) { setObsoleteCandidate(med); setShowObsoleteConfirm(true); window.history.pushState({ modal: "obsolete-confirm" }, ""); } async function handleConfirmMarkObsolete() { if (!obsoleteCandidate) return; await markMedicationObsolete(obsoleteCandidate.id); setShowObsoleteConfirm(false); setObsoleteCandidate(null); window.history.back(); } function handleCancelMarkObsolete() { setShowObsoleteConfirm(false); setObsoleteCandidate(null); window.history.back(); } async function markMedicationObsolete(id: number) { try { await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" }); if (editingId === id) { handleResetForm(); } loadMeds(); await loadAllMeds(); } catch { // ignore } } async function reactivateMedication(id: number) { try { await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" }); loadMeds(); await loadAllMeds(); } catch { // ignore } } // Save medication async function saveMedication(e: React.FormEvent) { e.preventDefault(); if (readOnlyView) return; if (hasValidationErrors || dateConsistencyError) { setShowNameValidation(true); // Scroll to first visible error so the user sees what's wrong const firstError = document.querySelector(".field-error"); if (firstError) { firstError.scrollIntoView({ behavior: "smooth", block: "center" }); // Brief highlight pulse firstError.classList.add("error-pulse"); setTimeout(() => firstError.classList.remove("error-pulse"), 1500); } return; } if (saving) return; setSaving(true); // Prepare intakes data with per-intake takenBy const intakes = form.intakes.map((intake) => ({ usage: Number(intake.usage) || 1, every: Number(intake.every) || 1, start: combineDateAndTime(intake.startDate, intake.startTime), takenBy: intake.takenBy.trim() || null, // Empty string becomes null intakeRemindersEnabled: intake.intakeRemindersEnabled, })); // Also prepare legacy blisters for backward compatibility const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start, })); const authorizedRefills = Number(form.prescriptionAuthorizedRefills || 0); const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills); const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills); const body = { name: form.name.trim(), genericName: form.genericName.trim() || null, takenBy: form.takenBy.length > 0 ? form.takenBy : [], 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, pillWeightMg: Number(form.pillWeightMg) || null, doseUnit: form.doseUnit, medicationStartDate: form.medicationStartDate || null, expiryDate: form.expiryDate || null, notes: form.notes.trim() || null, intakeRemindersEnabled: form.intakeRemindersEnabled, prescriptionEnabled: form.prescriptionEnabled, prescriptionAuthorizedRefills: form.prescriptionEnabled ? authorizedRefills : null, prescriptionRemainingRefills: form.prescriptionEnabled ? remainingRefills : null, prescriptionLowRefillThreshold: form.prescriptionEnabled ? lowRefillThreshold : 1, prescriptionExpiryDate: form.prescriptionExpiryDate || null, blisters, // Legacy format for backward compatibility intakes, // New format with per-intake takenBy }; try { let url = "/api/medications"; let method = "POST"; if (editingId) { url = `/api/medications/${editingId}`; method = "PUT"; } const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), credentials: "include", }); if (!res.ok) { let errorMessage = t("common.saveFailed"); try { const errorBody = (await res.json()) as { error?: string; message?: string }; if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) { errorMessage = errorBody.error; } else if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) { errorMessage = errorBody.message; } } catch { // keep translated fallback } throw new Error(errorMessage); } const saved = await res.json(); // Upload image if pending (for new medications) if (!editingId && pendingImage && saved.id) { await uploadMedImage(saved.id, pendingImage); setPendingImage(null); setPendingImagePreview(null); } setFormSaved(true); loadMeds(); void loadAllMeds(); // Clean up history state if we had unsaved changes if (hasUnsavedHistoryState.current) { hasUnsavedHistoryState.current = false; // Don't go back here, just clear the flag - the state will be cleaned naturally } // Reset form after successful save if (!editingId) { const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768; if (shouldCloseMobileModal) { // Treat post-save close as confirmed so popstate does not trigger unsaved guards. closeConfirmedRef.current = true; clearEditMedIdParam(); setShowEditModal(false); setReadOnlyView(false); setActiveTab("general"); setViewMode("grid"); resetForm(); window.history.back(); setSaving(false); return; } resetForm(); setViewMode("grid"); } else { // Update originalForm so formChanged becomes false setOriginalForm(form); } } catch (err) { log.error("Save error:", err); alert(err instanceof Error && err.message ? err.message : t("common.saveFailed")); } setSaving(false); } // Handle browser back button for modals and unsaved changes useEffect(() => { const handlePopState = () => { const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId"); // Obsolete confirmation is open — dismiss it and stay where we are if (showObsoleteConfirm) { setShowObsoleteConfirm(false); setObsoleteCandidate(null); return; } // Delete confirmation is open — dismiss it and stay where we are if (showDeleteConfirm) { setShowDeleteConfirm(false); setDeleteCandidate(null); return; } // If close was already confirmed programmatically, allow navigation if (closeConfirmedRef.current) { closeConfirmedRef.current = false; if (currentEditMedId) { // Prevent URL popstate from immediately reopening mobile edit for the same id. processedEditMedIdRef.current = currentEditMedId; clearEditMedIdParam(); } if (showEditModal) { setShowEditModal(false); resetForm(); } return; } // Handle mobile edit modal if (showEditModal) { // Check for unsaved changes (user pressed browser back directly) if (formChanged) { // Re-push history state to stay in modal window.history.pushState({ modal: "edit" }, ""); // Show confirmation modal setUnsavedConfirmSource("mobile-edit"); setShowUnsavedConfirm(true); return; } if (currentEditMedId) { // Mark as handled before URL cleanup to avoid same-tick re-open races. processedEditMedIdRef.current = currentEditMedId; } clearEditMedIdParam(); setShowEditModal(false); resetForm(); return; } // Handle desktop form: browser back should return to medication overview grid. if (viewMode === "form" && hasDesktopFormHistoryState.current) { if (formChanged) { window.history.pushState({ desktopForm: true }, ""); setUnsavedConfirmSource("desktop-form"); setShowUnsavedConfirm(true); return; } hasDesktopFormHistoryState.current = false; resetForm(); setShowNameValidation(false); setActiveTab("general"); setReadOnlyView(false); setViewMode("grid"); return; } // Handle desktop form with unsaved changes if (formChanged && hasUnsavedHistoryState.current) { // Re-push history state to stay on page window.history.pushState({ unsavedChanges: true }, ""); // Show confirmation modal setUnsavedConfirmSource("desktop-form"); setShowUnsavedConfirm(true); } }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]); // Close modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && showEditModal) { closeEditModal(); } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [showEditModal, closeEditModal]); function handleEditClick(med: Medication) { if (formChanged) { pendingActionRef.current = () => { setShowNameValidation(false); setReadOnlyView(false); startEdit(med, openEditModal); setViewMode("form"); }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } setShowNameValidation(false); setReadOnlyView(false); setActiveTab("general"); startEdit(med, openEditModal); setViewMode("form"); } function handleViewClick(med: Medication) { if (formChanged) { pendingActionRef.current = () => { setShowNameValidation(false); setReadOnlyView(true); startEdit(med, openEditModal); setViewMode("form"); }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } setShowNameValidation(false); setReadOnlyView(true); setActiveTab("general"); startEdit(med, openEditModal); setViewMode("form"); } function handleNewEntryClick() { if (formChanged) { pendingActionRef.current = () => { resetForm(); setShowNameValidation(false); setReadOnlyView(false); if (window.innerWidth <= 768) { openEditModal(); } else { setViewMode("form"); } }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } resetForm(); setShowNameValidation(false); setReadOnlyView(false); if (window.innerWidth <= 768) { openEditModal(); } else { setViewMode("form"); } } const activeMeds = useMemo(() => allMeds.filter((med) => !med.isObsolete), [allMeds]); const obsoleteMeds = useMemo(() => allMeds.filter((med) => med.isObsolete), [allMeds]); const orderedMeds = useMemo(() => { if (!editingId) { return activeMeds; } const selectedMedication = activeMeds.find((med) => med.id === editingId); if (!selectedMedication) { return activeMeds; } return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)]; }, [activeMeds, editingId]); useEffect(() => { const editMedId = searchParams.get("editMedId"); if (!editMedId) { processedEditMedIdRef.current = null; return; } if (processedEditMedIdRef.current === editMedId) return; const parsedMedId = Number.parseInt(editMedId, 10); if (Number.isNaN(parsedMedId)) return; const medicationToEdit = allMeds.find((med) => med.id === parsedMedId); if (!medicationToEdit) return; processedEditMedIdRef.current = editMedId; setShowNameValidation(false); setReadOnlyView(false); setActiveTab("general"); startEdit(medicationToEdit, openEditModal); setViewMode("form"); const nextParams = new URLSearchParams(searchParams); nextParams.delete("editMedId"); setSearchParams(nextParams, { replace: true }); }, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]); const selectedMedication = useMemo(() => { if (!editingId) return null; return allMeds.find((med) => med.id === editingId) ?? null; }, [allMeds, editingId]); return (
{/* ── Grid View: always visible medication cards ── */}

{t("medications.list.title")}

{orderedMeds.map((med) => (
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }); } }} >
{med.name}
{med.genericName &&
{med.genericName}
}
{editingId !== med.id && ( )}
{t("medications.details.type")}:{" "} {med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")} {med.packageType === "blister" ? ( <> {t("medications.details.packs")}: {med.packCount} {t("medications.details.blisters")}: {med.blistersPerPack} {t("medications.details.pillsPerBlister")}: {med.pillsPerBlister} {t("medications.details.loose")}: {med.looseTablets} ) : ( {t("medications.details.totalCapacity")}:{" "} {med.totalPills ?? med.looseTablets} )}
{med.prescriptionEnabled && (
{t("prescription.remainingRefills")}: {med.prescriptionRemainingRefills ?? 0}
)}
{t("medications.details.stock")}:{" "} {coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "} {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")} {(coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)) > getPackageSize(med) && ( {" "} ⚠️ )}
{(med.intakes ?? med.blisters).map((s, idx) => (
{s.usage} {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.takenBy && · {s.takenBy}} {"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && ( {" "} )}
))}
))}
{obsoleteMeds.length > 0 && (
{showObsolete && (
{obsoleteMeds.map((med) => (
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }); } }} >
{med.name}
{med.genericName &&
{med.genericName}
}
{med.medicationStartDate && ( {t("medications.list.started")}: {formatDate(med.medicationStartDate)} )} {t("medications.list.obsoleteSince")}: {formatDate(med.obsoleteAt)}
))}
)}
)}
{/* ── Desktop Edit Panel: inline below medication list ── */}