Files
medassist-ng/frontend/src/components/MobileEditModal.tsx
T

985 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<HTMLInputElement>) => 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: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
// Image handling
meds: Medication[];
onUploadMedImage: (medId: number, file: File) => Promise<void>;
onDeleteMedImage: (medId: number) => Promise<void>;
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<MobileTab>("general");
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
const tabStripRef = useRef<HTMLDivElement | null>(null);
const tabViewportRef = useRef<HTMLDivElement | null>(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 (
<div
className="modal-overlay mobile-edit-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
>
<div
className="modal-content edit-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
>
<div className="edit-modal-header">
<button type="button" className="ghost small btn-nav" onClick={onClose}>
{t("common.back")}
</button>
<h2>{mobileTitle}</h2>
</div>
<form
className="form-grid mobile-edit-form"
autoComplete="off"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
onSubmit={(e) => {
// 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);
}}
>
<div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")} ref={tabStripRef}>
<button
type="button"
role="tab"
aria-selected={activeTab === "general"}
className={`form-tab${activeTab === "general" ? " active" : ""}`}
onClick={() => setActiveTab("general")}
>
{t("form.sections.general")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "stock"}
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
onClick={() => setActiveTab("stock")}
>
{t("form.sections.stock")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "schedule"}
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
onClick={() => setActiveTab("schedule")}
>
{t("form.sections.schedule")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "prescription"}
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
onClick={() => setActiveTab("prescription")}
>
{t("form.sections.prescription")}
</button>
</div>
<fieldset
ref={fieldsetRef}
className={`readonly-fieldset${isHorizontalSwiping ? " swiping-horizontal" : ""}`}
disabled={readOnlyMode}
>
<div className="mobile-tab-viewport" ref={tabViewportRef}>
<div
className={`mobile-tab-track${isHorizontalSwiping ? " is-swiping" : ""}`}
style={{ transform: `translateX(calc(${-activeTabIndex * 100}% + ${swipeDeltaX}px))` }}
>
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.general")}</h4>
<label
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.name ? "has-error" : ""}`}
>
{t("form.commercialName")}
<input
value={form.name}
onChange={(e) => {
setShowNameValidation(true);
onFormChange({ ...form, name: e.target.value });
}}
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
/>
{!readOnlyMode && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label>
<label
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}
>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => {
setShowNameValidation(true);
onFormChange({ ...form, genericName: e.target.value });
}}
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{!readOnlyMode && showNameValidation && fieldErrors.genericName && (
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label className="full">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
>
{PACKAGE_PROFILES.map((profile) => (
<option key={profile.value} value={profile.value}>
{t(profile.labelKey)}
</option>
))}
</select>
</label>
<label className="full">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{allowsPillFormSelection(form.packageType) && (
<label className="full">
{t("form.pillForm")}
<select
value={form.pillForm}
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
>
<option value="tablet">{t("form.medicationFormTablet")}</option>
<option value="capsule">{t("form.medicationFormCapsule")}</option>
</select>
</label>
)}
{isTubePackageType(form.packageType) && (
<label className="full">
{t("form.medicationForm")}
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
<option value="topical">{t("form.medicationFormTopical")}</option>
</select>
</label>
)}
{isLiquidContainerPackageType(form.packageType) && (
<label className="full">
{t("form.medicationForm")}
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
<option value="liquid">{t("form.medicationFormLiquid")}</option>
</select>
</label>
)}
{form.medicationEndDate && (
<label className="full">
{t("form.autoMarkObsoleteAfterEndDate")}
<span className="toggle-switch small">
<input
type="checkbox"
checked={form.autoMarkObsoleteAfterEndDate}
onChange={(e) => onHandleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
/>
<span className="toggle-slider"></span>
</span>
</label>
)}
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0
? t("form.placeholders.takenBy")
: t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
</div>
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => onDeleteMedImage(editingId)}
aria-label={t("form.removeImage")}
data-tooltip={t("form.removeImage")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void onUploadMedImage(editingId, file);
}}
/>
)}
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
</div>
)}
</div>
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
{(() => {
if (!isAmountBasedPackageType(form.packageType)) {
return (
<>
<label>
{t("form.packs")}
<FormNumberStepper
value={form.packCount}
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.blistersPerPack")}
<FormNumberStepper
value={form.blistersPerPack}
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<FormNumberStepper
value={form.pillsPerBlister}
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.total")}
<div className="static-value">{deriveTotalFromForm(form)}</div>
</label>
</>
);
}
if (isTubePackageType(form.packageType)) {
return (
<>
<label>
{t("form.tubes")}
<div className="static-value">1</div>
</label>
<label className="full">
{t("form.packageAmountPerTube")}
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.packageAmountValue ?? "0"}
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
placeholder="0"
/>
<select
value="g"
disabled
className="dose-unit-select"
aria-label={t("form.packageAmountUnitG")}
>
<option value="g">{t("form.packageAmountUnitG")}</option>
</select>
</div>
</label>
<label>
{t("form.totalAmount")}
<div className="static-value">
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
{t("form.packageAmountUnitG")}
</div>
</label>
</>
);
}
if (isLiquidContainerPackageType(form.packageType)) {
return (
<>
<label>
{t("form.bottles")}
<FormNumberStepper
value={form.packCount}
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="full">
{t("form.packageAmountPerBottle")}
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.packageAmountValue ?? "0"}
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
placeholder="0"
/>
<select
value="ml"
disabled
className="dose-unit-select"
aria-label={t("form.packageAmountUnitMl")}
>
<option value="ml">{t("form.packageAmountUnitMl")}</option>
</select>
</div>
</label>
<label>
{t("form.totalAmount")}
<div className="static-value">
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
{t("form.packageAmountUnitMl")}
</div>
</label>
</>
);
}
return (
<>
<label>
{totalCapacityLabel}
<FormNumberStepper
value={form.totalPills}
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{currentStockLabel}
<FormNumberStepper
value={form.looseTablets}
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
</>
);
})()}
{isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
<div className="full stock-total-row">
<div className="stock-total-field">
<p className="sub">
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
</p>
</div>
</div>
)}
{allowsPillFormSelection(form.packageType) && (
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label>
)}
<label className="full">
{t("form.expiryDate")}
<DateInput
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
</div>
</div>
<div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
<div className="full form-category intake-section">
<div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
{!readOnlyMode && (
<button
type="button"
className="ghost add-blister icon-only tooltip-trigger"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
aria-label={t("form.blisters.addIntake")}
data-tooltip={t("form.blisters.addIntake")}
>
<Plus size={18} aria-hidden="true" />
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
className="blister-row"
>
<label className="compact">
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label className="compact full-row">
<span>{t("form.blisters.intakeUnit")}</span>
<select
value={intake.intakeUnit}
onChange={(e) =>
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
))}
</div>
</div>
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full">
{t("prescription.enabled")}
<span className="toggle-switch small">
<input
type="checkbox"
checked={form.prescriptionEnabled}
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</span>
</label>
{form.prescriptionEnabled && (
<>
<label className="prescription-field">
{t("prescription.authorizedRefills")}
<FormNumberStepper
value={form.prescriptionAuthorizedRefills}
onChange={(nextValue) => onHandleValueChange("prescriptionAuthorizedRefills", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="prescription-field">
{t("prescription.remainingRefills")}
<FormNumberStepper
value={form.prescriptionRemainingRefills}
onChange={(nextValue) => onHandleValueChange("prescriptionRemainingRefills", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="prescription-field">
{t("prescription.lowThreshold")}
<FormNumberStepper
value={form.prescriptionLowRefillThreshold}
onChange={(nextValue) => onHandleValueChange("prescriptionLowRefillThreshold", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="prescription-field">
{t("prescription.expiryDate")}
<DateInput
value={form.prescriptionExpiryDate}
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
/>
</label>
</>
)}
</div>
</div>
</div>
</div>
</fieldset>
<div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}>
{readOnlyMode || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
</button>
{!readOnlyMode && (
<button
type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
)}
</div>
</form>
</div>
</div>
);
}