/** * 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 { useEffect, 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 { DOSE_UNITS } 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 (form.packageType === "bottle") { // 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); // 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")}

{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")}

{form.packageType === "blister" ? ( <> ) : ( <> )} {form.packageType === "bottle" && (

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

)}