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:
@@ -454,6 +454,11 @@ function AppContent() {
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onClearUser={() => {
|
||||
setSelectedUser(null);
|
||||
// Replace the userFilter history entry so it doesn't remain on the stack
|
||||
window.history.replaceState(null, "");
|
||||
}}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -270,15 +270,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Computed values - combine app language with timezone region for locale
|
||||
const systemLocale = getSystemLocale(i18n.language);
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, systemLocale, true),
|
||||
[medications.meds, systemLocale]
|
||||
);
|
||||
const activeMeds = useMemo(() => medications.meds.filter((m) => !m.isObsolete), [medications.meds]);
|
||||
const schedule = useMemo(() => buildSchedulePreview(activeMeds, systemLocale, true), [activeMeds, systemLocale]);
|
||||
|
||||
const coverage = useMemo(
|
||||
() =>
|
||||
calculateCoverage(
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -287,7 +285,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
doses.takenDoseTimestamps
|
||||
),
|
||||
[
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -430,8 +428,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(
|
||||
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
|
||||
);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
@@ -486,8 +484,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
share.openShareDialog(activeMeds);
|
||||
}, [share, activeMeds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -41,6 +41,7 @@ export const defaultForm = (): FormState => ({
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
@@ -230,6 +231,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications", { credentials: "include" })
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medikamentenliste",
|
||||
"entries": "{{count}} Einträge",
|
||||
"entries_one": "{{count}} Eintrag",
|
||||
"entries_other": "{{count}} Einträge"
|
||||
"entries_other": "{{count}} Einträge",
|
||||
"markObsolete": "Als obsolet markieren",
|
||||
"reactivate": "Reaktivieren",
|
||||
"obsoleteTitle": "Obsolet ({{count}})",
|
||||
"obsoleteSince": "Beendet",
|
||||
"started": "Gestartet"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packungen",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Medikament löschen",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Medikament als obsolet markieren",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich als obsolet markieren?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"viewEntry": "Medikament ansehen",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"medicationStartDate": "Medikations-Startdatum",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Einnahmeplan",
|
||||
"remind": "Erinnern",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"delete": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"reset": "Zurücksetzen",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Rezept verfolgen",
|
||||
"authorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Rezept-Nachfüllungen",
|
||||
"lowThreshold": "Schwelle für Rezept-Erinnerung",
|
||||
"expiryDate": "Rezeptablauf",
|
||||
"useForRefill": "Rezept-Nachfüllung verwenden"
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medication list",
|
||||
"entries": "{{count}} entries",
|
||||
"entries_one": "{{count}} entry",
|
||||
"entries_other": "{{count}} entries"
|
||||
"entries_other": "{{count}} entries",
|
||||
"markObsolete": "Mark obsolete",
|
||||
"reactivate": "Reactivate",
|
||||
"obsoleteTitle": "Obsolete ({{count}})",
|
||||
"obsoleteSince": "Stopped",
|
||||
"started": "Started"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packs",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Delete medication",
|
||||
"message": "Do you really want to delete \"{{name}}\"?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Mark medication as obsolete",
|
||||
"message": "Do you really want to mark \"{{name}}\" as obsolete?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit medication",
|
||||
"viewEntry": "View medication",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"medicationStartDate": "Medication Start Date",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Intake schedule",
|
||||
"remind": "Remind",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"reset": "Reset",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Track prescription",
|
||||
"authorizedRefills": "Authorized refills",
|
||||
"remainingRefills": "Remaining refills",
|
||||
"remainingRefills": "Remaining prescription refills",
|
||||
"lowThreshold": "Low-refill reminder threshold",
|
||||
"expiryDate": "Prescription expiry",
|
||||
"useForRefill": "Use prescription refill"
|
||||
|
||||
@@ -607,42 +607,34 @@ export function DashboardPage() {
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<span className="med-name-line">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy &&
|
||||
med.takenBy.length > 0 &&
|
||||
med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
<span className="med-name-block-dash">
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && (
|
||||
<span className="med-taken-by-line">
|
||||
{med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{(() => {
|
||||
const hasIntakeReminders =
|
||||
med?.intakes?.some((i) => i.intakeRemindersEnabled) ?? med?.intakeRemindersEnabled;
|
||||
return (
|
||||
(hasIntakeReminders || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{hasIntakeReminders && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
{med?.notes && (
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
{med?.notes && (
|
||||
<span className="med-icons">
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.stock")} className={textClass}>
|
||||
{med?.packageType === "bottle"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { PlannerRow } from "../types";
|
||||
@@ -158,8 +158,7 @@ export function PlannerPage() {
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t("planner.from")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
step="60"
|
||||
value={range.start}
|
||||
onChange={(e) => setRange({ ...range, start: e.target.value })}
|
||||
@@ -167,12 +166,7 @@ export function PlannerPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("planner.until")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.end}
|
||||
onChange={(e) => setRange({ ...range, end: e.target.value })}
|
||||
/>
|
||||
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<label className="planner-checkbox">
|
||||
<input
|
||||
|
||||
+472
-43
@@ -30,6 +30,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #3d94ff;
|
||||
--btn-ghost-hover: rgba(255, 255, 255, 0.08);
|
||||
--btn-danger-text: #2f0a0a;
|
||||
--btn-success-text: #0a2b1f;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f7d14a 0%, #f2b91a 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f9db72 0%, #f5c73c 100%);
|
||||
--btn-obsolete-text: #2b2205;
|
||||
--btn-obsolete-border: #f8e38a;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(252, 211, 77, 0.28);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -60,6 +67,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #1d4ed8;
|
||||
--btn-ghost-hover: rgba(0, 0, 0, 0.06);
|
||||
--btn-danger-text: #ffffff;
|
||||
--btn-success-text: #ffffff;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f5b52c 0%, #f59e0b 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f8c85b 0%, #f7ad2d 100%);
|
||||
--btn-obsolete-text: #ffffff;
|
||||
--btn-obsolete-border: #d48806;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -90,6 +104,7 @@ body.modal-open {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -491,8 +506,10 @@ body.modal-open {
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
margin-bottom: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -617,19 +634,128 @@ body.modal-open {
|
||||
.med-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-group {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.1rem 0.25rem;
|
||||
margin: -0.1rem -0.25rem 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle:hover .med-group-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.med-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.med-group-obsolete {
|
||||
border-color: var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.med-grid-obsolete {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.obsolete-row {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.med-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Flatten nested boxes on mobile to reclaim horizontal space */
|
||||
.med-grid-wrapper > .card {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-grid-wrapper .card-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-group,
|
||||
.med-groups {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-row {
|
||||
padding: 0.65rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.blister-row-simple {
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.med-details {
|
||||
gap: 0.2rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
|
||||
.med-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: span 2;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
@@ -646,9 +772,8 @@ body.modal-open {
|
||||
}
|
||||
.med-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.med-info {
|
||||
flex: 1;
|
||||
@@ -661,7 +786,7 @@ body.modal-open {
|
||||
}
|
||||
.med-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 0.25rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
@@ -690,8 +815,9 @@ body.modal-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
width: 100%;
|
||||
align-self: start;
|
||||
}
|
||||
.blister-row-simple {
|
||||
color: var(--text-muted);
|
||||
@@ -788,6 +914,8 @@ body.modal-open {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.med-actions button {
|
||||
padding: 0.5rem 0.9rem;
|
||||
@@ -800,6 +928,9 @@ body.modal-open {
|
||||
.med-actions {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.med-details {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.blister-list {
|
||||
display: flex;
|
||||
@@ -931,7 +1062,7 @@ button.secondary:hover {
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
color: var(--btn-success-text);
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
@@ -982,10 +1113,39 @@ button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
|
||||
/* Navigation button (Back): neutral and low visual urgency */
|
||||
button.btn-nav {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-nav:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Reversible status-change button (Mark obsolete): warning, not destructive */
|
||||
button.btn-obsolete {
|
||||
background: var(--btn-obsolete-bg);
|
||||
border: 1px solid var(--btn-obsolete-border);
|
||||
color: var(--btn-obsolete-text);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
button.btn-obsolete:hover {
|
||||
background: var(--btn-obsolete-hover);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-obsolete:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Danger button (Delete, etc.) */
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
color: var(--btn-danger-text);
|
||||
border: none;
|
||||
}
|
||||
button.danger:hover {
|
||||
@@ -1212,6 +1372,70 @@ textarea.auto-resize {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-error.error-pulse {
|
||||
animation: error-pulse-anim 1.5s ease;
|
||||
}
|
||||
|
||||
@keyframes error-pulse-anim {
|
||||
0%,
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
15% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
85% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button.has-validation-error {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.readonly-fieldset {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Subtle read-only styling for disabled fieldset inputs */
|
||||
.readonly-fieldset:disabled input,
|
||||
.readonly-fieldset:disabled select,
|
||||
.readonly-fieldset:disabled textarea,
|
||||
.readonly-fieldset:disabled .date-input-wrapper {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
background: var(--bg-input);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .date-input-display {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .static-value {
|
||||
opacity: 0.55;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag-input-container {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Dose input with unit selector */
|
||||
.dose-input-group {
|
||||
display: flex;
|
||||
@@ -2038,13 +2262,13 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.table-row span {
|
||||
.table-row > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
.table-row span::before {
|
||||
.table-row > span::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--accent-light);
|
||||
@@ -2055,44 +2279,44 @@ textarea.auto-resize {
|
||||
text-align: left;
|
||||
}
|
||||
/* First span (name cell) - centered horizontal layout */
|
||||
.table-row span:first-child {
|
||||
justify-content: center;
|
||||
.table-row > span:first-child {
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0.15rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.table-row span:first-child::before {
|
||||
.table-row > span:first-child::before {
|
||||
display: none; /* Hide "NAME" label on mobile */
|
||||
}
|
||||
/* Status chip in table row - left aligned */
|
||||
.table-row span.status-chip {
|
||||
.table-row > span.status-chip {
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row span.status-chip::before {
|
||||
.table-row > span.status-chip::before {
|
||||
margin-right: 0;
|
||||
}
|
||||
/* Avatar + name layout - centered */
|
||||
/* Avatar + name layout - left aligned */
|
||||
.table-row .cell-with-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Icons on separate line on mobile - centered under med name */
|
||||
/* Icons on separate line on mobile - left aligned */
|
||||
.table-row .cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2102,6 +2326,30 @@ textarea.auto-resize {
|
||||
.table-row .notes-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Prevent data-label ::before on nested spans inside name cell */
|
||||
.table-row .cell-with-avatar span::before {
|
||||
display: none;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge,
|
||||
.table-row .cell-with-avatar .med-name-text {
|
||||
display: inline !important;
|
||||
justify-content: initial;
|
||||
}
|
||||
.table-row .med-taken-by-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
.table-4 .table-head,
|
||||
.table-4 .table-row,
|
||||
.table-5 .table-head,
|
||||
@@ -2116,7 +2364,17 @@ textarea.auto-resize {
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page {
|
||||
padding: 1rem 0.75rem 2rem;
|
||||
padding: 0.75rem 0.4rem 2rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.65rem;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -3389,6 +3647,20 @@ textarea.auto-resize {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.med-avatar-clickable {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
.med-avatar-clickable .med-avatar {
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.med-avatar-clickable:hover .med-avatar {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table/Timeline cells with avatar */
|
||||
.cell-with-avatar {
|
||||
display: flex;
|
||||
@@ -3403,6 +3675,21 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.med-taken-by-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.med-name-block-dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3444,12 +3731,26 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.med-name-block {
|
||||
min-width: 0;
|
||||
}
|
||||
.med-generic-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-preview button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
@@ -3509,19 +3810,24 @@ textarea.auto-resize {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close,
|
||||
.lightbox-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--btn-ghost-hover);
|
||||
border: 1px solid var(--border-secondary);
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3738,6 +4044,19 @@ textarea.auto-resize {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-med-intakes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.user-med-intake-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-meds-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -3828,6 +4147,16 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-item {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(2.6em, auto) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-value {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.med-detail-item {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
@@ -3961,19 +4290,19 @@ textarea.auto-resize {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
min-height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -3981,11 +4310,11 @@ textarea.auto-resize {
|
||||
transition: background 150ms ease;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
@@ -5645,6 +5974,37 @@ a.about-version-link:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Desktop Edit Panel (two-column layout) ── */
|
||||
.edit-sidebar {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.med-grid-wrapper.desktop-edit-open {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(380px, 46%);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid,
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid-obsolete {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.edit-sidebar.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-sidebar .card {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Desktop only - hide on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
@@ -5657,7 +6017,7 @@ a.about-version-link:hover {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
@@ -5687,8 +6047,9 @@ a.about-version-link:hover {
|
||||
|
||||
.mobile-edit-form .form-category {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.8rem;
|
||||
gap: 0.75rem 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-color: color-mix(in srgb, var(--border-primary) 50%, transparent);
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-prescription-row {
|
||||
@@ -5848,6 +6209,74 @@ a.about-version-link:hover {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Custom DateInput / DateTimeInput
|
||||
========================================== */
|
||||
.date-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input-display {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
right: 2.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-input-native {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure native text stays invisible on focus/selection */
|
||||
.date-input-native:focus,
|
||||
.date-input-native:active {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
|
||||
.date-input-native::selection {
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-datetime-edit {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Keep the calendar/clock picker icon visible and clickable */
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
filter: invert(0.8);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme: don't invert icon */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
filter: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-card {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,9 +14,16 @@ const defaultForm: FormState = {
|
||||
looseTablets: "0",
|
||||
totalPills: "",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: "",
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
blisters: [
|
||||
{
|
||||
usage: "1",
|
||||
@@ -47,6 +54,8 @@ const defaultProps = {
|
||||
formSaved: false,
|
||||
formChanged: false,
|
||||
hasValidationErrors: false,
|
||||
dateConsistencyError: null,
|
||||
readOnlyMode: false,
|
||||
takenByInput: "",
|
||||
onTakenByInputChange: vi.fn(),
|
||||
existingPeople: [],
|
||||
@@ -108,7 +117,7 @@ describe("MobileEditModal", () => {
|
||||
it("renders close button", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -116,7 +125,7 @@ describe("MobileEditModal", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
if (closeBtn) {
|
||||
fireEvent.click(closeBtn);
|
||||
}
|
||||
@@ -191,7 +200,7 @@ describe("MobileEditModal", () => {
|
||||
render(<MobileEditModal {...defaultProps} hasValidationErrors={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
expect(saveBtn).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("renders add intake button", () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -63,6 +64,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -81,6 +83,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -100,6 +103,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -118,6 +122,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -136,6 +141,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -154,6 +160,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -175,6 +182,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -187,8 +195,9 @@ describe("UserFilterModal", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose and onOpenMedDetail when medication clicked", () => {
|
||||
it("calls onClearUser and onOpenMedDetail when medication clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
const onClearUser = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -198,6 +207,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={onClearUser}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -207,7 +217,7 @@ describe("UserFilterModal", () => {
|
||||
fireEvent.click(medItem);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onClearUser).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication);
|
||||
});
|
||||
|
||||
@@ -222,6 +232,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -243,6 +254,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -272,6 +284,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useMedications", () => {
|
||||
expect(result.current.meds).toEqual(mockMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
@@ -123,7 +123,7 @@ describe("useMedications", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
expect(mockResetForm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -607,10 +607,7 @@ describe("DashboardPage with medications", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has intakeRemindersEnabled and notes
|
||||
const reminderIcons = document.querySelectorAll(".reminder-icon");
|
||||
expect(reminderIcons.length).toBeGreaterThan(0);
|
||||
|
||||
// Aspirin has notes
|
||||
const notesIcons = document.querySelectorAll(".notes-icon");
|
||||
expect(notesIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,8 @@ const createMockFormHook = (overrides = {}) => ({
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
medicationStartDate: "",
|
||||
doseUnit: "mg" as const,
|
||||
},
|
||||
setForm: vi.fn(),
|
||||
editingId: null,
|
||||
@@ -132,6 +134,10 @@ vi.mock("../../context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/Auth", () => ({
|
||||
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -156,7 +162,8 @@ describe("MedicationsPage", () => {
|
||||
it("renders list-first view with new button", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText(/medications\.list\.title/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument();
|
||||
// Button text and form heading both contain "form.newEntry" in the DOM
|
||||
expect(screen.getAllByText(/form\.newEntry/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("opens form after clicking new button", () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type Medication = {
|
||||
lastStockCorrectionAt?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate?: string | null;
|
||||
blisters: Blister[]; // Legacy array format
|
||||
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||
imageUrl?: string | null;
|
||||
@@ -58,6 +59,8 @@ export type Medication = {
|
||||
prescriptionLowRefillThreshold?: number;
|
||||
prescriptionExpiryDate?: string | null;
|
||||
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
|
||||
isObsolete?: boolean;
|
||||
obsoleteAt?: string | null;
|
||||
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
@@ -114,6 +117,7 @@ export type FormState = {
|
||||
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
||||
pillWeightMg: string;
|
||||
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
prescriptionEnabled: boolean;
|
||||
|
||||
@@ -71,11 +71,56 @@ export function getRegionFromTimezone(): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for formatting based on app language and timezone region.
|
||||
* Combines app language (en/de) with region from timezone (DE/US/etc.)
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE (English text, German format)
|
||||
* Map region code to the region's primary language for date/number formatting.
|
||||
* This ensures dates use regional conventions (e.g., dots in Germany)
|
||||
* regardless of the app's UI language.
|
||||
*/
|
||||
const REGION_TO_LANG: Record<string, string> = {
|
||||
DE: "de",
|
||||
AT: "de",
|
||||
CH: "de",
|
||||
GB: "en",
|
||||
IE: "en",
|
||||
FR: "fr",
|
||||
ES: "es",
|
||||
IT: "it",
|
||||
NL: "nl",
|
||||
BE: "nl",
|
||||
PL: "pl",
|
||||
CZ: "cs",
|
||||
SE: "sv",
|
||||
NO: "nb",
|
||||
DK: "da",
|
||||
FI: "fi",
|
||||
GR: "el",
|
||||
PT: "pt",
|
||||
RU: "ru",
|
||||
UA: "uk",
|
||||
HU: "hu",
|
||||
RO: "ro",
|
||||
US: "en",
|
||||
CA: "en",
|
||||
MX: "es",
|
||||
BR: "pt",
|
||||
AR: "es",
|
||||
JP: "ja",
|
||||
CN: "zh",
|
||||
HK: "zh",
|
||||
SG: "en",
|
||||
KR: "ko",
|
||||
AE: "ar",
|
||||
IN: "en",
|
||||
AU: "en",
|
||||
NZ: "en",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get locale for text-based date formatting (weekday names, month names).
|
||||
* Uses the app's UI language + timezone region so text appears in the app language
|
||||
* while regional conventions (day-first order) are respected.
|
||||
*
|
||||
* @param appLanguage - The app's UI language (e.g., 'en', 'de')
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE
|
||||
* → "Thu, 05. Feb." (English names, German order)
|
||||
*/
|
||||
export function getSystemLocale(appLanguage?: string): string {
|
||||
const region = getRegionFromTimezone();
|
||||
@@ -89,6 +134,25 @@ export function getSystemLocale(appLanguage?: string): string {
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for purely numeric date/number formatting.
|
||||
* Uses the region's native language so separators match regional conventions
|
||||
* (e.g., dots in Germany: 14.02.2026, slashes in US: 02/14/2026).
|
||||
*
|
||||
* Only use this for numeric-only output (2-digit day/month/year, no text).
|
||||
* For output that includes weekday or month names, use getSystemLocale() instead.
|
||||
*/
|
||||
export function getNumericLocale(): string {
|
||||
const region = getRegionFromTimezone();
|
||||
|
||||
if (region) {
|
||||
const regionLang = REGION_TO_LANG[region] || "en";
|
||||
return `${regionLang}-${region}`;
|
||||
}
|
||||
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using the current locale with optional decimal places
|
||||
*/
|
||||
@@ -114,7 +178,7 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
if (!match) return "-";
|
||||
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const effectiveLocale = locale ?? getSystemLocale();
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
|
||||
// Create a date object for formatting, but use local timezone interpretation
|
||||
// by creating the date without the Z suffix
|
||||
@@ -136,6 +200,20 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date-only string (YYYY-MM-DD) or ISO datetime to a localized date (no time).
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined, locale?: string): string {
|
||||
if (!dateStr) return "-";
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) return "-";
|
||||
const [, year, month, day] = match;
|
||||
const d = new Date(`${year}-${month}-${day}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return "-";
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
return d.toLocaleDateString(effectiveLocale, { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number to 2 digits with leading zero
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user