/** * MobileEditModal - Full-screen edit form for medications (mobile-optimized) * Handles new medication creation and editing existing medications */ /* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */ import { Bell, Minus, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEscapeKey } from "../hooks/useEscapeKey"; import { useScrollLock } from "../hooks/useScrollLock"; import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; import { allowsPillFormSelection, DOSE_UNITS, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, PACKAGE_PROFILES, } from "../types"; import { deriveTotal } from "../utils"; import { DateInput } from "./DateInput"; import { FormNumberStepper } from "./FormNumberStepper"; // Field limits for validation const FIELD_LIMITS = { name: { max: 100 }, genericName: { max: 100 }, takenBy: { max: 50 }, notes: { max: 1000 }, }; const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const; type MobileTab = (typeof MOBILE_TAB_ORDER)[number]; export interface MobileEditModalProps { show: boolean; editingId: number | null; form: FormState; onFormChange: (form: FormState) => void; fieldErrors: FieldErrors; saving: boolean; formSaved: boolean; formChanged: boolean; hasValidationErrors: boolean; dateConsistencyError: string | null; readOnlyMode: boolean; // TakenBy tag input takenByInput: string; onTakenByInputChange: (value: string) => void; existingPeople: string[]; onAddTakenByPerson: (person: string) => void; onRemoveTakenByPerson: (person: string) => void; onTakenByKeyDown: (e: React.KeyboardEvent) => void; // Blister helpers (legacy) onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void; onAddBlister: () => void; onRemoveBlister: (idx: number) => void; // Intake helpers (new - with per-intake takenBy) onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void; onAddIntake: (takenBy?: string) => void; onRemoveIntake: (idx: number) => void; // Value change handler for numeric fields onHandleValueChange: (field: K, value: FormState[K]) => void; // Image handling meds: Medication[]; onUploadMedImage: (medId: number, file: File) => Promise; onDeleteMedImage: (medId: number) => Promise; imageUploadError: string | null; // Actions onClose: () => void; onResetForm: () => void; onSaveMedication: (e: React.FormEvent) => void; } /** Calculate total pills from form state */ function deriveTotalFromForm(form: FormState) { if (isAmountBasedPackageType(form.packageType)) { // For bottle type, looseTablets is the current stock return Number(form.looseTablets) || 0; } const packCount = Number(form.packCount) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0; const pillsPerBlister = Number(form.pillsPerBlister) || 1; return deriveTotal(packCount, blistersPerPack, pillsPerBlister, 0); } export function MobileEditModal({ show, editingId, form, onFormChange, fieldErrors, saving, formSaved, formChanged, hasValidationErrors, dateConsistencyError, readOnlyMode, takenByInput, onTakenByInputChange, existingPeople, onAddTakenByPerson, onRemoveTakenByPerson, onTakenByKeyDown, onSetBlisterValue: _onSetBlisterValue, onAddBlister: _onAddBlister, onRemoveBlister: _onRemoveBlister, onSetIntakeValue, onAddIntake, onRemoveIntake, onHandleValueChange, meds, onUploadMedImage, onDeleteMedImage, imageUploadError, onClose, onResetForm: _onResetForm, onSaveMedication, }: MobileEditModalProps) { const { t } = useTranslation(); const decrementValueLabel = t("editStock.decreaseValue"); const incrementValueLabel = t("editStock.increaseValue"); const [activeTab, setActiveTab] = useState("general"); const fieldsetRef = useRef(null); const tabStripRef = useRef(null); const tabViewportRef = useRef(null); const swipeStartRef = useRef<{ x: number; y: number } | null>(null); const swipeAxisRef = useRef<"x" | "y" | null>(null); const [swipeDeltaX, setSwipeDeltaX] = useState(0); const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false); const [showNameValidation, setShowNameValidation] = useState(false); const activeTabIndexRef = useRef(0); 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( (intake: (typeof form.intakes)[number]) => { if (isLiquidContainerPackageType(form.packageType)) { if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp"); if (intake.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"); // Reset tab when modal opens useEffect(() => { if (show) { setActiveTab("general"); setShowNameValidation(false); } }, [show]); useEffect(() => { if (show && (hasValidationErrors || !!fieldErrors.name)) { setShowNameValidation(true); } }, [show, hasValidationErrors, fieldErrors.name]); useEscapeKey(show, onClose); // Lock background scroll while modal is open. useScrollLock(show); // Keep activeTabIndex ref in sync for native listeners const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab); activeTabIndexRef.current = activeTabIndex; // Auto-scroll tab strip to keep active tab visible useEffect(() => { const strip = tabStripRef.current; if (!strip) return; const btn = strip.children[activeTabIndex] as HTMLElement | undefined; if (btn) btn.scrollIntoView?.({ behavior: "smooth", inline: "nearest", block: "nearest" }); }, [activeTabIndex]); // Non-passive touch listeners for reliable horizontal swipe detection. // React's onTouchMove is passive, so e.preventDefault() is a no-op there. // With native { passive: false } we can block the browser's vertical scroll // when a horizontal swipe is detected, making tab swiping reliable. useEffect(() => { const fieldset = fieldsetRef.current; if (!show || !fieldset) return; const AXIS_LOCK_THRESHOLD = 6; function resetSwipe() { swipeStartRef.current = null; swipeAxisRef.current = null; setIsHorizontalSwiping(false); setSwipeDeltaX(0); } function onTouchStart(e: TouchEvent) { if (e.touches.length !== 1) { resetSwipe(); return; } const touch = e.touches[0]; swipeStartRef.current = { x: touch.clientX, y: touch.clientY }; swipeAxisRef.current = null; setIsHorizontalSwiping(false); setSwipeDeltaX(0); } function onTouchMove(e: TouchEvent) { if (!swipeStartRef.current || e.touches.length !== 1) return; const touch = e.touches[0]; const dx = touch.clientX - swipeStartRef.current.x; const dy = touch.clientY - swipeStartRef.current.y; const ax = Math.abs(dx); const ay = Math.abs(dy); if (!swipeAxisRef.current) { if (ax < AXIS_LOCK_THRESHOLD && ay < AXIS_LOCK_THRESHOLD) return; swipeAxisRef.current = ax >= ay ? "x" : "y"; } if (swipeAxisRef.current === "y") return; // Horizontal swipe — block native vertical scroll e.preventDefault(); setIsHorizontalSwiping(true); let nextDelta = dx; const idx = activeTabIndexRef.current; if ((idx === 0 && nextDelta > 0) || (idx === MOBILE_TAB_ORDER.length - 1 && nextDelta < 0)) { nextDelta *= 0.35; } setSwipeDeltaX(nextDelta); } function onTouchEnd(e: TouchEvent) { if (!swipeStartRef.current || e.changedTouches.length !== 1) { resetSwipe(); return; } if (swipeAxisRef.current === "x") { const touch = e.changedTouches[0]; const dx = touch.clientX - swipeStartRef.current.x; const minSwipe = Math.max(36, (tabViewportRef.current?.clientWidth ?? 360) * 0.1); if (Math.abs(dx) >= minSwipe) { const direction = dx < 0 ? 1 : -1; const idx = activeTabIndexRef.current; const next = Math.min(Math.max(idx + direction, 0), MOBILE_TAB_ORDER.length - 1); if (next !== idx) { setActiveTab(MOBILE_TAB_ORDER[next]); } } } resetSwipe(); } fieldset.addEventListener("touchstart", onTouchStart, { passive: true }); fieldset.addEventListener("touchmove", onTouchMove, { passive: false }); fieldset.addEventListener("touchend", onTouchEnd, { passive: true }); fieldset.addEventListener("touchcancel", resetSwipe, { passive: true }); return () => { fieldset.removeEventListener("touchstart", onTouchStart); fieldset.removeEventListener("touchmove", onTouchMove); fieldset.removeEventListener("touchend", onTouchEnd); fieldset.removeEventListener("touchcancel", resetSwipe); }; }, [show]); if (!show) return null; const currentMed = editingId ? meds.find((m) => m.id === editingId) : null; const mobileTitle = (() => { if (!editingId) return t("form.newEntry"); if (readOnlyMode) return t("form.viewEntry"); const medicationName = (currentMed ? currentMed.name?.trim() || currentMed.genericName?.trim() : null) || form.name.trim() || form.genericName.trim(); if (!medicationName) return t("form.editEntry"); return t("form.editEntryWithName", { name: medicationName }); })(); return (
{ if (e.key !== "Escape") e.stopPropagation(); }} >
e.stopPropagation()} onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }} >

{mobileTitle}

{ // Check native HTML5 validation first const formElement = e.currentTarget; if (!formElement.checkValidity()) { // Let browser show native validation messages formElement.reportValidity(); e.preventDefault(); return; } onSaveMedication(e); }} >

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

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

{t("form.medicationImage")}

{currentMed?.imageUrl ? (
{currentMed.name}
) : ( { const file = e.target.files?.[0]; e.target.value = ""; if (file) void onUploadMedImage(editingId, file); }} /> )} {imageUploadError && {imageUploadError}}
)}

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

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

{totalLabel}: {deriveTotalFromForm(form)} {` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}

)} {allowsPillFormSelection(form.packageType) && ( )}