feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements - Add soft-archive (obsolete) for medications with dedicated section and toggle - Add medication start date field with date picker and validation - Add obsolete/reactivate API endpoints with proper auth - Filter obsolete meds from schedule, coverage, planner, and notifications - Improve UserFilterModal with intake schedules, stock badges, and click-to-open - Improve dashboard taken-by badges with per-intake bell icons - Add Escape key support to ConfirmModal and MobileEditModal - Fix Lightbox close button positioning near image - Add read-only mode support for MobileEditModal - DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date) - All user-facing text uses i18n keys (en + de) * test: fix tests for obsolete medications and UI changes - Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas - Backend: add test medication inserts in planner tests for active-med filtering - Frontend: update useMedications URL to include includeObsolete param - Frontend: fix MobileEditModal selectors and validation assertions - Frontend: add onClearUser prop to UserFilterModal test renders - Frontend: fix MedicationsPage and DashboardPage test assertions
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -27,6 +27,17 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* DateInput - Custom date input that displays dates in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted date string on top of a native <input type="date">,
|
||||
* so the browser calendar popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDate, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateInput({ value, placeholder, className, ...rest }: DateInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
const displayValue = value ? formatDate(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="date" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DateTimeInput - Custom datetime input that displays date+time in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted datetime string on top of a native <input type="datetime-local">,
|
||||
* so the browser datetime popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026, 20:30 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDateTime, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateTimeInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, placeholder, className, ...rest }: DateTimeInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
// datetime-local value is "YYYY-MM-DDTHH:MM" — formatDateTime handles this format
|
||||
const displayValue = value ? formatDateTime(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="datetime-local" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface LightboxProps {
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
@@ -19,10 +20,12 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="lightbox-container">
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,16 @@ export function MedDetailModal({
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")} {selectedMed.takenBy.join(", ")}
|
||||
{t("modal.for")}{" "}
|
||||
{selectedMed.takenBy.map((person, index) => (
|
||||
<span key={person}>
|
||||
{index > 0 && ", "}
|
||||
{person}
|
||||
{selectedMed.intakes?.some(
|
||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||
) && <span className="taken-by-badge">🔔</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -287,7 +296,7 @@ export function MedDetailModal({
|
||||
{selectedMed.prescriptionEnabled && (
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("form.sections.prescription")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-grid prescription-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.authorizedRefills")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionAuthorizedRefills ?? "—"}</span>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS } from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
import { DateInput } from "./DateInput";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -25,6 +27,8 @@ export interface MobileEditModalProps {
|
||||
formSaved: boolean;
|
||||
formChanged: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
dateConsistencyError: string | null;
|
||||
readOnlyMode: boolean;
|
||||
// TakenBy tag input
|
||||
takenByInput: string;
|
||||
onTakenByInputChange: (value: string) => void;
|
||||
@@ -84,6 +88,8 @@ export function MobileEditModal({
|
||||
formSaved,
|
||||
formChanged,
|
||||
hasValidationErrors,
|
||||
dateConsistencyError,
|
||||
readOnlyMode,
|
||||
takenByInput,
|
||||
onTakenByInputChange,
|
||||
existingPeople,
|
||||
@@ -114,6 +120,18 @@ export function MobileEditModal({
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [show, onClose]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||
@@ -121,14 +139,11 @@ export function MobileEditModal({
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<button type="button" className="ghost small" onClick={onClose}>
|
||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||
← {t("common.back")}
|
||||
</button>
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form
|
||||
className="form-grid mobile-edit-form"
|
||||
@@ -144,458 +159,473 @@ export function MobileEditModal({
|
||||
onSaveMedication(e);
|
||||
}}
|
||||
>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</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>
|
||||
))}
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<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"
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required={!readOnlyMode}
|
||||
/>
|
||||
<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>
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<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")}
|
||||
<input
|
||||
type="date"
|
||||
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 className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||
<label className="full">
|
||||
{t("prescription.enabled")}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.prescriptionEnabled}
|
||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
</label>
|
||||
{form.prescriptionEnabled && (
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{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)}
|
||||
/>
|
||||
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</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
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
<>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="full">
|
||||
{t("refill.pillsToAdd")}
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="refill-submit-row full">
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(() => {
|
||||
const totalRefill =
|
||||
form.packageType === "blister"
|
||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
||||
refillLoose
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{form.prescriptionEnabled && (
|
||||
<div className="refill-prescription-row full">
|
||||
<label className="refill-prescription-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePrescriptionRefill}
|
||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
||||
/>
|
||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
||||
</label>
|
||||
<span className="refill-remaining-badge">
|
||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="refill-unavailable">{t("refill.saveFirst", "Save medication first to enable refill")}</p>
|
||||
)}
|
||||
</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" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="full form-category intake-section">
|
||||
<div className="form-category-header">
|
||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost add-blister"
|
||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input
|
||||
type="date"
|
||||
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>
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row">
|
||||
<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">🔔</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>
|
||||
<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>
|
||||
{form.intakes.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</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 className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||
<label className="full">
|
||||
{t("prescription.enabled")}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.prescriptionEnabled}
|
||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
{form.prescriptionEnabled && (
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<DateInput
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
<>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="full">
|
||||
{t("refill.pillsToAdd")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="refill-submit-row full">
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(() => {
|
||||
const totalRefill =
|
||||
form.packageType === "blister"
|
||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
||||
refillLoose
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{form.prescriptionEnabled && (
|
||||
<div className="refill-prescription-row full">
|
||||
<label className="refill-prescription-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePrescriptionRefill}
|
||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
||||
/>
|
||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
||||
</label>
|
||||
<span className="refill-remaining-badge">
|
||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="refill-unavailable">
|
||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
||||
</p>
|
||||
)}
|
||||
</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" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row">
|
||||
<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">🔔</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" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
{readOnlyMode ? 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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -15,6 +16,7 @@ export interface UserFilterModalProps {
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onClearUser: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
@@ -24,13 +26,14 @@ export function UserFilterModal({
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onClearUser,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
@@ -47,15 +50,29 @@ export function UserFilterModal({
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||
const status = medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
||||
: getStockStatus(null, getMedTotal(med), settings);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onClearUser();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
>
|
||||
@@ -63,6 +80,25 @@ export function UserFilterModal({
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
{personIntakes.length > 0 && (
|
||||
<div className="user-med-intakes">
|
||||
{personIntakes.map((intake, idx) => {
|
||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<span key={idx} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{t("form.blisters.every")} {intake.every}{" "}
|
||||
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user