/* 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, Minus, 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 { DateInput, FormNumberStepper, MedicationEnrichmentSection, MobileEditModal } from "../components"; import { useAuth } from "../components/Auth"; import { MedicationDialogs } from "../components/medications/MedicationDialogs"; import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator"; import { MedicationListSection } from "../components/medications/MedicationListSection"; import { useAppContext, useUnsavedChanges } from "../context"; import { MEDICATION_ENRICHMENT_INITIAL_LIMIT, MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT, useMedicationEnrichmentController, useMedicationForm, useModalHistory, useUnsavedChangesWarning, } from "../hooks"; import type { DoseUnit, FormState, Medication, MedicationEnrichmentEnrichResponse, MedicationEnrichmentPackageOption, MedicationEnrichmentSearchResponse, MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption, PackageType, } from "../types"; import { allowsPillFormSelection, DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageProfile, getPackageSize, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, normalizePackageType, PACKAGE_PROFILES, } from "../types"; import { combineDateAndTime, formatNumber } from "../utils/formatters"; import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload"; import { getIntakeScheduleMode, getWeekdayLabel, hasSelectedWeekdays, toggleWeekdaySelection, WEEKDAY_CODES, } from "../utils/intake-schedule"; import { log } from "../utils/logger"; import { countMedicationEnrichmentDisplayResults } from "../utils/medication-enrichment"; function userStorageKey(userId: number | undefined, key: string): string { return userId ? `user_${userId}_${key}` : key; } const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete"; const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi; function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null { if (unit === "IU") return "units"; if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit; return null; } function normalizeMedicationEnrichmentPackageText(value: string): string { return value.replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ").replace(/\s+/g, " ").trim(); } function hasMatchingMedicationEnrichmentPackageStructure( left: MedicationEnrichmentPackageOption, right: MedicationEnrichmentPackageOption ): boolean { return ( left.packageType === right.packageType && left.packCount === right.packCount && left.blistersPerPack === right.blistersPerPack && left.pillsPerBlister === right.pillsPerBlister && left.totalPills === right.totalPills && left.looseTablets === right.looseTablets && left.packageAmountValue === right.packageAmountValue && left.packageAmountUnit === right.packageAmountUnit ); } function matchesMedicationEnrichmentPackageOption( left: MedicationEnrichmentPackageOption, right: MedicationEnrichmentPackageOption ): boolean { const leftTexts = [left.label, left.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean); const rightTexts = [right.label, right.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean); const hasMatchingText = leftTexts.some((text) => rightTexts.includes(text)); return ( hasMatchingMedicationEnrichmentPackageStructure(left, right) || (hasMatchingText && left.packageType === right.packageType) ); } function applyMedicationEnrichmentSuggestions( form: FormState, suggestions: MedicationEnrichmentEnrichResponse["suggestions"] ): FormState { const nextForm: FormState = { ...form, name: suggestions.name, genericName: suggestions.genericName ?? "", }; if (suggestions.medicationForm === "tablet" || suggestions.medicationForm === "capsule") { return { ...nextForm, medicationForm: suggestions.medicationForm, pillForm: suggestions.medicationForm, }; } if (suggestions.medicationForm === "liquid" || suggestions.medicationForm === "topical") { return { ...nextForm, medicationForm: suggestions.medicationForm, }; } return nextForm; } function applyMedicationEnrichmentStrength( form: FormState, option: MedicationEnrichmentStrengthOption ): FormState | null { if (option.pillWeightMg === null) return null; const doseUnit = normalizeMedicationEnrichmentDoseUnit(option.doseUnit); if (!doseUnit) return null; return { ...form, pillWeightMg: `${option.pillWeightMg}`, doseUnit, }; } function applyMedicationEnrichmentPackage(form: FormState, option: MedicationEnrichmentPackageOption): FormState { const nextForm: FormState = { ...form, packageType: option.packageType, packCount: `${option.packCount}`, blistersPerPack: option.blistersPerPack !== null ? `${option.blistersPerPack}` : "1", pillsPerBlister: option.pillsPerBlister !== null ? `${option.pillsPerBlister}` : "1", packageAmountValue: option.packageAmountValue !== null ? `${option.packageAmountValue}` : "", packageAmountUnit: option.packageAmountUnit ?? form.packageAmountUnit, totalPills: option.totalPills !== null ? `${option.totalPills}` : "", looseTablets: option.looseTablets !== null ? `${option.looseTablets}` : "0", }; if (option.packageType === "blister") { return { ...nextForm, totalPills: "", looseTablets: "0", }; } if (option.packageType === "liquid_container") { return { ...nextForm, medicationForm: "liquid", }; } if (option.packageType === "tube") { return { ...nextForm, medicationForm: "topical", }; } return nextForm; } async function getMedicationEnrichmentErrorMessage( response: Response, fallback: string, unauthorizedFallback: string ): Promise { if (response.status === 401) { return unauthorizedFallback; } try { const errorBody = (await response.json()) as { error?: string; message?: string }; if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) { return errorBody.error; } if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) { return errorBody.message; } } catch { // keep translated fallback } return fallback; } 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, setEditingId, 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) // If navigating in with editMedId, suppress rendering until the edit form is ready const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId")); const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "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(pendingEditTransition && window.innerWidth <= 768); 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 [imageUploadError, setImageUploadError] = useState(null); const { medicationEnrichment, setMedicationEnrichment, medicationEnrichmentQueryRef, resetMedicationEnrichment, handleMedicationEnrichmentQueryChange, } = useMedicationEnrichmentController(); const performMedicationEnrichmentSearch = useCallback( async ( requestedLimit: number, preserveExistingResults = false, previousVisibleResultCount = countMedicationEnrichmentDisplayResults(medicationEnrichment.results), queryOverride?: string ) => { const trimmedQuery = (queryOverride ?? medicationEnrichmentQueryRef.current).trim(); if (!trimmedQuery) return; const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT); medicationEnrichmentQueryRef.current = trimmedQuery; setMedicationEnrichment((previous) => ({ ...previous, query: trimmedQuery, results: preserveExistingResults ? previous.results : [], hasMoreResults: preserveExistingResults ? previous.hasMoreResults : false, resultLimit: limit, isSearching: true, hasSearched: preserveExistingResults ? previous.hasSearched : false, searchError: null, applyingCode: null, applyingPackageLabel: null, ...(preserveExistingResults ? {} : { activeResultCode: null, appliedSelection: null, enrichError: null, meta: null, strengthOptions: [], packageOptions: [], appliedStrengthLabel: null, appliedPackageLabel: null, }), })); try { const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) }); const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, { credentials: "include", }); if (!response.ok) { throw new Error( await getMedicationEnrichmentErrorMessage( response, t("form.enrichment.searchError"), t("form.enrichment.authRequired") ) ); } const data = (await response.json()) as MedicationEnrichmentSearchResponse; const nextResults = Array.isArray(data.results) ? data.results : []; const nextVisibleResultCount = countMedicationEnrichmentDisplayResults(nextResults); const reachedResultLimitCap = limit >= MEDICATION_ENRICHMENT_MAX_LIMIT; const shouldLoadUntilVisibleResultChanges = preserveExistingResults && Boolean(data.hasMore) && !reachedResultLimitCap && nextVisibleResultCount <= previousVisibleResultCount; if (shouldLoadUntilVisibleResultChanges) { await performMedicationEnrichmentSearch( Math.min(limit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT), true, previousVisibleResultCount, trimmedQuery ); return; } setMedicationEnrichment((previous) => { const loadedAdditionalResults = !preserveExistingResults || nextResults.length > previous.results.length; return { ...previous, query: data.query, results: nextResults, hasMoreResults: Boolean(data.hasMore) && !reachedResultLimitCap && loadedAdditionalResults, resultLimit: limit, isSearching: false, hasSearched: true, searchError: null, }; }); medicationEnrichmentQueryRef.current = data.query; } catch (error) { const message = error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError"); setMedicationEnrichment((previous) => ({ ...previous, results: preserveExistingResults ? previous.results : [], hasMoreResults: false, resultLimit: limit, isSearching: false, hasSearched: true, searchError: message, })); } }, [medicationEnrichment.query, medicationEnrichment.results, t] ); const handlePendingMedicationImageSelection = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (file.size > MAX_IMAGE_UPLOAD_BYTES) { setImageUploadError(t("form.imageUploadErrors.tooLarge")); setPendingImage(null); setPendingImagePreview(null); return; } setImageUploadError(null); setPendingImage(file); const reader = new FileReader(); reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string); reader.readAsDataURL(file); }, [t] ); useEffect(() => { setImageUploadError(null); }, [editingId]); 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 handleMedicationEnrichmentSearch = useCallback(async () => { await performMedicationEnrichmentSearch( MEDICATION_ENRICHMENT_INITIAL_LIMIT, false, countMedicationEnrichmentDisplayResults(medicationEnrichment.results), medicationEnrichmentQueryRef.current ); }, [medicationEnrichment.results, performMedicationEnrichmentSearch]); 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]); const tryUploadMedImage = useCallback( async (medId: number, file: File) => { setImageUploadError(null); if (file.size > MAX_IMAGE_UPLOAD_BYTES) { setImageUploadError(t("form.imageUploadErrors.tooLarge")); return false; } try { await uploadMedImage(medId, file); void loadAllMeds(); setImageUploadError(null); return true; } catch (error) { const code = error instanceof Error ? error.message : "UNKNOWN"; setImageUploadError(resolveImageUploadError(code, t)); return false; } }, [t, uploadMedImage, loadAllMeds] ); const handleUploadMedImage = useCallback( async (medId: number, file: File) => { await tryUploadMedImage(medId, file); }, [tryUploadMedImage] ); const handleDeleteMedImage = useCallback( async (medId: number) => { await deleteMedImage(medId); void loadAllMeds(); }, [deleteMedImage, loadAllMeds] ); const applicableMedicationEnrichmentStrengthOptions = useMemo(() => { if (!allowsPillFormSelection(form.packageType)) return []; return [...medicationEnrichment.strengthOptions] .filter( (option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null ) .sort((left, right) => { const leftWeight = left.pillWeightMg ?? Number.POSITIVE_INFINITY; const rightWeight = right.pillWeightMg ?? Number.POSITIVE_INFINITY; if (leftWeight !== rightWeight) { return leftWeight - rightWeight; } return left.label.localeCompare(right.label, undefined, { numeric: true }); }); }, [form.packageType, medicationEnrichment.strengthOptions]); const handleMedicationEnrichmentLoadMore = useCallback(async () => { if ( medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults || medicationEnrichment.resultLimit >= MEDICATION_ENRICHMENT_MAX_LIMIT ) return; await performMedicationEnrichmentSearch( Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT), true ); }, [ medicationEnrichment.hasMoreResults, medicationEnrichment.isSearching, medicationEnrichment.resultLimit, performMedicationEnrichmentSearch, ]); const handleMedicationEnrichmentApply = useCallback( async (result: MedicationEnrichmentSearchResult, preferredPackageOption?: MedicationEnrichmentPackageOption) => { setMedicationEnrichment((previous) => ({ ...previous, applyingCode: result.code, applyingPackageLabel: preferredPackageOption?.label ?? null, activeResultCode: result.code, enrichError: null, appliedSelection: null, meta: null, strengthOptions: [], packageOptions: [], appliedStrengthLabel: null, appliedPackageLabel: null, })); try { const response = await fetch("/api/medication-enrichment/enrich", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: medicationEnrichment.query.trim() || result.name, name: result.name, genericName: result.genericName ?? null, code: result.code, source: result.source, }), credentials: "include", }); if (!response.ok) { throw new Error( await getMedicationEnrichmentErrorMessage( response, t("form.enrichment.applyError"), t("form.enrichment.authRequired") ) ); } const data = (await response.json()) as MedicationEnrichmentEnrichResponse; let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions); let appliedPackageLabel: string | null = null; let appliedStrengthLabel: string | null = null; const matchedPreferredPackageOption = preferredPackageOption ? (data.suggestions.packageOptions.find((option) => matchesMedicationEnrichmentPackageOption(option, preferredPackageOption) ) ?? null) : null; if (matchedPreferredPackageOption) { nextForm = applyMedicationEnrichmentPackage(nextForm, matchedPreferredPackageOption); appliedPackageLabel = matchedPreferredPackageOption.label; } else if (data.suggestions.packageOptions.length === 1) { nextForm = applyMedicationEnrichmentPackage(nextForm, data.suggestions.packageOptions[0]); appliedPackageLabel = data.suggestions.packageOptions[0].label; } if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) { const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]); if (autoAppliedForm) { nextForm = autoAppliedForm; appliedStrengthLabel = data.suggestions.strengthOptions[0].label; } } setForm(nextForm); setMedicationEnrichment((previous) => ({ ...previous, applyingCode: null, applyingPackageLabel: null, activeResultCode: result.code, appliedSelection: data.selection, enrichError: null, meta: data.meta, strengthOptions: data.suggestions.strengthOptions, packageOptions: data.suggestions.packageOptions, appliedStrengthLabel, appliedPackageLabel, })); } catch (error) { const message = error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.applyError"); setMedicationEnrichment((previous) => ({ ...previous, applyingCode: null, applyingPackageLabel: null, activeResultCode: null, appliedSelection: null, enrichError: message, meta: null, strengthOptions: [], packageOptions: [], appliedStrengthLabel: null, appliedPackageLabel: null, })); } }, [form, medicationEnrichment.query, setForm, t] ); const handleMedicationEnrichmentStrengthApply = useCallback( (option: MedicationEnrichmentStrengthOption) => { setForm((currentForm) => { const nextForm = applyMedicationEnrichmentStrength(currentForm, option); return nextForm ?? currentForm; }); setMedicationEnrichment((previous) => ({ ...previous, appliedStrengthLabel: option.label, })); }, [setForm] ); const handleMedicationEnrichmentPackageApply = useCallback( (option: MedicationEnrichmentPackageOption) => { setForm((currentForm) => applyMedicationEnrichmentPackage(currentForm, option)); setMedicationEnrichment((previous) => ({ ...previous, appliedPackageLabel: option.label, applyingPackageLabel: null, appliedStrengthLabel: null, })); }, [setForm] ); const medicationEnrichmentViewModel = useMemo( () => ({ ...medicationEnrichment, strengthOptions: applicableMedicationEnrichmentStrengthOptions, }), [applicableMedicationEnrichmentStrengthOptions, medicationEnrichment] ); // Calculate total tablets const totalTablets = useMemo(() => { if (isAmountBasedPackageType(form.packageType)) { // 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; 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); if (!conflictingIntake?.startDate) return null; return t("form.validation.startDateAfterIntake", { medicationStartDate, intakeDate: conflictingIntake.startDate, }); }, [form.medicationStartDate, form.medicationEndDate, form.intakes, t]); const allowFractionalIntake = useMemo(() => { if (isLiquidContainerPackageType(form.packageType)) return true; if (isTubePackageType(form.packageType)) return form.medicationForm === "liquid"; return form.pillForm === "tablet"; }, [form.packageType, form.medicationForm, form.pillForm]); const getUsageLabel = useCallback( (intakeUnit: "ml" | "tsp" | "tbsp") => { if (isLiquidContainerPackageType(form.packageType)) { if (intakeUnit === "tsp") return t("form.blisters.usageTsp"); if (intakeUnit === "tbsp") return t("form.blisters.usageTbsp"); return t("form.blisters.usageMl"); } if (isTubePackageType(form.packageType)) { 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 = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType); 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 weekdayOptions = useMemo( () => WEEKDAY_CODES.map((day) => ({ value: day, shortLabel: getWeekdayLabel(day, t, "short"), longLabel: getWeekdayLabel(day, t, "long"), })), [t] ); const hasWeekdaySelectionError = useCallback( (intake: (typeof form.intakes)[number]) => getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays), [] ); const hasWeekdayScheduleError = useMemo( () => form.intakes.some((intake) => hasWeekdaySelectionError(intake)), [form.intakes, hasWeekdaySelectionError] ); const getMedicationPackageTypeLabel = useCallback( (med: Medication) => { return t(getPackageProfile(med.packageType).labelKey); }, [t] ); const getMedicationStockSuffix = useCallback( (med: Medication) => { if (isTubePackageType(med.packageType)) return ""; if (isLiquidContainerPackageType(med.packageType)) return " ml"; return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`; }, [t] ); const getMedicationUsageUnitLabel = useCallback( (med: Medication, usage: number) => { if (isTubePackageType(med.packageType)) { return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication"); } if (isLiquidContainerPackageType(med.packageType)) return "ml"; if (usage === 1) return t("common.pill"); return t("common.pills"); }, [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(); resetMedicationEnrichment(); setReadOnlyView(false); pendingAction(); } else if (source === "mobile-edit" && showEditModal) { clearEditMedIdParam(); setShowEditModal(false); resetForm(); resetMedicationEnrichment(); 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(); resetMedicationEnrichment(); 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 || hasWeekdayScheduleError) { 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; 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 const intakes = form.intakes.map((intake) => ({ usage: Number(intake.usage) || 1, every: getIntakeScheduleMode(intake) === "weekdays" ? 1 : Number(intake.every) || 1, start: combineDateAndTime(intake.startDate, intake.startTime), scheduleMode: getIntakeScheduleMode(intake), weekdays: getIntakeScheduleMode(intake) === "weekdays" ? [...(intake.weekdays ?? [])] : [], intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null, 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); let derivedMedicationForm: string; if (isTubePackageType(form.packageType)) { derivedMedicationForm = form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical"; } else if (isLiquidContainerPackageType(form.packageType)) { derivedMedicationForm = "liquid"; } else { derivedMedicationForm = form.pillForm; } const tubeTotalAmount = isTubePackageType(form.packageType) ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null; let packageAmountUnit = form.packageAmountUnit ?? "ml"; if (isTubePackageType(form.packageType)) { packageAmountUnit = "g"; } else if (isLiquidContainerPackageType(form.packageType)) { packageAmountUnit = "ml"; } const body = { name: form.name.trim(), genericName: form.genericName.trim() || null, takenBy: form.takenBy.length > 0 ? form.takenBy : [], medicationForm: derivedMedicationForm, pillForm: isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType) ? null : form.pillForm, lifecycleCategory: form.lifecycleCategory, packageType: normalizePackageType(form.packageType), packCount: isTubePackageType(form.packageType) ? Math.max(1, Number(form.packCount) || 1) : Number(form.packCount) || 0, blistersPerPack: isTubePackageType(form.packageType) ? 1 : Number(form.blistersPerPack) || 1, pillsPerBlister: isTubePackageType(form.packageType) ? 1 : Number(form.pillsPerBlister) || 1, packageAmountValue: Number(form.packageAmountValue ?? 0) || 0, packageAmountUnit, totalPills: isTubePackageType(form.packageType) ? tubeTotalAmount : Number(form.totalPills) || null, looseTablets: isTubePackageType(form.packageType) ? 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, 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) { const uploaded = await tryUploadMedImage(saved.id, pendingImage); if (!uploaded) { // Keep user in edit mode so upload error stays visible and retry is immediate. setEditingId(saved.id); setFormSaved(true); setOriginalForm(form); setPendingImage(null); setPendingImagePreview(null); loadMeds(); void loadAllMeds(); setSaving(false); return; } 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(); resetMedicationEnrichment(); window.history.back(); setSaving(false); return; } resetForm(); resetMedicationEnrichment(); 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(); resetMedicationEnrichment(); } 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(); resetMedicationEnrichment(); 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(); resetMedicationEnrichment(); 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 scrollToTopForDesktopEdit() { if (window.innerWidth <= 768) return; window.requestAnimationFrame(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }); } const getLatestMedication = useCallback( (med: Medication) => meds.find((candidate) => candidate.id === med.id) ?? med, [meds] ); function handleEditClick(med: Medication) { const latestMedication = getLatestMedication(med); if (formChanged) { pendingActionRef.current = () => { setShowNameValidation(false); setReadOnlyView(false); resetMedicationEnrichment(latestMedication.name || latestMedication.genericName || ""); startEdit(latestMedication, openEditModal); setViewMode("form"); scrollToTopForDesktopEdit(); }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } setShowNameValidation(false); setReadOnlyView(false); setActiveTab("general"); resetMedicationEnrichment(latestMedication.name || latestMedication.genericName || ""); startEdit(latestMedication, openEditModal); setViewMode("form"); scrollToTopForDesktopEdit(); } function handleViewClick(med: Medication) { const latestMedication = getLatestMedication(med); if (formChanged) { pendingActionRef.current = () => { setShowNameValidation(false); setReadOnlyView(true); resetMedicationEnrichment(latestMedication.name || latestMedication.genericName || ""); startEdit(latestMedication, openEditModal); setViewMode("form"); scrollToTopForDesktopEdit(); }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } setShowNameValidation(false); setReadOnlyView(true); setActiveTab("general"); resetMedicationEnrichment(latestMedication.name || latestMedication.genericName || ""); startEdit(latestMedication, openEditModal); setViewMode("form"); scrollToTopForDesktopEdit(); } function handleNewEntryClick() { if (formChanged) { pendingActionRef.current = () => { resetForm(); resetMedicationEnrichment(); setShowNameValidation(false); setReadOnlyView(false); if (window.innerWidth <= 768) { openEditModal(); } else { setViewMode("form"); } }; setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form"); setShowUnsavedConfirm(true); return; } resetForm(); resetMedicationEnrichment(); 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 = meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId); if (!medicationToEdit) return; processedEditMedIdRef.current = editMedId; setShowNameValidation(false); setReadOnlyView(false); setActiveTab("general"); resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || ""); startEdit(medicationToEdit, openEditModal); setViewMode("form"); scrollToTopForDesktopEdit(); setPendingEditTransition(false); window.dispatchEvent(new Event("medassist:edit-transition-ready")); const nextParams = new URLSearchParams(searchParams); nextParams.delete("editMedId"); setSearchParams(nextParams, { replace: true }); }, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]); const selectedMedication = useMemo(() => { if (!editingId) return null; return allMeds.find((med) => med.id === editingId) ?? null; }, [allMeds, editingId]); // While navigating from detail modal to edit, render nothing until form is populated if (pendingEditTransition) { return null; } return (
setShowReportModal(true)} onEdit={handleEditClick} onView={handleViewClick} onMarkObsolete={requestMarkObsolete} onDelete={requestDeleteMed} onReactivate={reactivateMedication} onToggleObsolete={toggleObsoleteSection} onImagePreview={(med) => setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })} getMedicationPackageTypeLabel={getMedicationPackageTypeLabel} getMedicationStockSuffix={getMedicationStockSuffix} getMedicationUsageUnitLabel={getMedicationUsageUnitLabel} /> {/* ── Desktop Edit Panel: inline below medication list ── */}

{t("form.sections.general")}

{allowsPillFormSelection(form.packageType) && ( )} {isTubePackageType(form.packageType) && ( )} {isLiquidContainerPackageType(form.packageType) && ( )} {form.medicationEndDate && ( )}

{t("form.medicationImage")}

{(() => { if (editingId) { const currentMed = meds.find((m) => m.id === editingId); if (currentMed?.imageUrl) { return (
{currentMed.name}
); } return ( { const file = e.target.files?.[0]; e.target.value = ""; if (file) void tryUploadMedImage(editingId, file); }} disabled={uploadingImage} /> ); } if (pendingImagePreview) { return (
Preview
); } return ( ); })()} {imageUploadError && {imageUploadError}}
{/* end general tab */}

{t("form.sections.stock")}

{(() => { if (!isAmountBasedPackageType(form.packageType)) { return ( <> ); } if (isTubePackageType(form.packageType)) { return ( <> ); } return ( <> ); })()} {allowsPillFormSelection(form.packageType) && ( )} {isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
)} {isLiquidContainerPackageType(form.packageType) && ( )}