feat: track number of prescription repeats (#193)
* feat: track prescription repeats and refill reminders * test: align backend and frontend suites with current prescription and UI behavior * test: update frontend and backend expectations for latest reminders and refill flow
This commit is contained in:
@@ -127,6 +127,8 @@ function AppContent() {
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
usePrescriptionRefill,
|
||||
setUsePrescriptionRefill,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
@@ -355,8 +357,8 @@ function AppContent() {
|
||||
};
|
||||
|
||||
// For MedDetailModal: refill without form update (not editing)
|
||||
const handleSubmitRefill = async (medId: number) => {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds);
|
||||
const handleSubmitRefill = async (medId: number, usePrescription: boolean = false) => {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||
};
|
||||
|
||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
||||
@@ -430,6 +432,8 @@ function AppContent() {
|
||||
onRefillPacksChange={setRefillPacks}
|
||||
refillLoose={refillLoose}
|
||||
onRefillLooseChange={setRefillLoose}
|
||||
usePrescriptionRefill={usePrescriptionRefill}
|
||||
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
|
||||
refillSaving={refillSaving}
|
||||
refillHistory={refillHistory}
|
||||
refillHistoryExpanded={refillHistoryExpanded}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lightbox, MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
@@ -83,11 +84,13 @@ export interface MedDetailModalProps {
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
usePrescriptionRefill: boolean;
|
||||
onUsePrescriptionRefillChange: (value: boolean) => void;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
onRefillHistoryExpandedChange: (value: boolean) => void;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
onSubmitRefill: (medId: number, usePrescription?: boolean) => Promise<void>;
|
||||
// Edit stock state
|
||||
editStockFullBlisters: number;
|
||||
onEditStockFullBlistersChange: (value: number) => void;
|
||||
@@ -115,6 +118,8 @@ export function MedDetailModal({
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
usePrescriptionRefill,
|
||||
onUsePrescriptionRefillChange,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
@@ -128,6 +133,20 @@ export function MedDetailModal({
|
||||
onSubmitStockCorrection,
|
||||
}: MedDetailModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [editStockFullInput, setEditStockFullInput] = useState("0");
|
||||
const [editStockPartialInput, setEditStockPartialInput] = useState("0");
|
||||
|
||||
const parseStockInput = (value: string): number => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditStockModal) {
|
||||
setEditStockFullInput(String(editStockFullBlisters));
|
||||
setEditStockPartialInput(String(editStockPartialBlisterPills));
|
||||
}
|
||||
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills]);
|
||||
|
||||
if (!selectedMed) return null;
|
||||
|
||||
@@ -138,6 +157,7 @@ export function MedDetailModal({
|
||||
const textClass =
|
||||
status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
const fullForBounds = Math.max(0, parseStockInput(editStockFullInput));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
@@ -263,6 +283,42 @@ export function MedDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prescription Details Section */}
|
||||
{selectedMed.prescriptionEnabled && (
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("form.sections.prescription")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.authorizedRefills")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionAuthorizedRefills ?? "—"}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.remainingRefills")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionRemainingRefills ?? "—"}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.lowThreshold")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionLowRefillThreshold ?? "—"}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.expiryDate")}</span>
|
||||
<span className="med-detail-value">
|
||||
{selectedMed.prescriptionExpiryDate
|
||||
? new Date(selectedMed.prescriptionExpiryDate).toLocaleDateString(
|
||||
getSystemLocale(i18n.language),
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}
|
||||
)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intake Schedule Section */}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
@@ -373,6 +429,12 @@ export function MedDetailModal({
|
||||
entry.loosePillsAdded;
|
||||
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
})()}
|
||||
{entry.usedPrescription && (
|
||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||
{" "}
|
||||
📋
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -459,6 +521,23 @@ export function MedDetailModal({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedMed.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(selectedMed.prescriptionRemainingRefills) || 0) <= 0}
|
||||
/>
|
||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
||||
</label>
|
||||
<span className="refill-remaining-badge">
|
||||
{t("prescription.remainingRefills")}: {Number(selectedMed.prescriptionRemainingRefills) || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
@@ -468,7 +547,7 @@ export function MedDetailModal({
|
||||
<div className="refill-footer-right">
|
||||
<button
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(selectedMed.id)}
|
||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
@@ -525,8 +604,17 @@ export function MedDetailModal({
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editStockPartialBlisterPills}
|
||||
onChange={(e) => onEditStockPartialBlisterPillsChange(parseInt(e.target.value, 10) || 0)}
|
||||
value={editStockPartialInput}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setEditStockPartialInput(raw);
|
||||
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.max(0, parseStockInput(raw)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const normalized = Math.max(0, parseStockInput(editStockPartialInput));
|
||||
onEditStockPartialBlisterPillsChange(normalized);
|
||||
setEditStockPartialInput(String(normalized));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
@@ -537,23 +625,45 @@ export function MedDetailModal({
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editStockFullBlisters}
|
||||
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
|
||||
value={editStockFullInput}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setEditStockFullInput(raw);
|
||||
onEditStockFullBlistersChange(raw === "" ? 0 : Math.max(0, parseStockInput(raw)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const normalized = Math.max(0, parseStockInput(editStockFullInput));
|
||||
onEditStockFullBlistersChange(normalized);
|
||||
setEditStockFullInput(String(normalized));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("editStock.partialBlisterPills")}
|
||||
<input
|
||||
type="number"
|
||||
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
|
||||
min={fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
|
||||
max={selectedMed.pillsPerBlister}
|
||||
value={editStockPartialBlisterPills}
|
||||
value={editStockPartialInput}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10) || 0;
|
||||
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const raw = e.target.value;
|
||||
setEditStockPartialInput(raw);
|
||||
if (raw === "") {
|
||||
onEditStockPartialBlisterPillsChange(0);
|
||||
return;
|
||||
}
|
||||
const val = parseStockInput(raw);
|
||||
const min = fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const max = selectedMed.pillsPerBlister;
|
||||
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const min = fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const max = selectedMed.pillsPerBlister;
|
||||
const normalized = Math.max(min, Math.min(parseStockInput(editStockPartialInput), max));
|
||||
onEditStockPartialBlisterPillsChange(normalized);
|
||||
setEditStockPartialInput(String(normalized));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
|
||||
@@ -41,12 +41,14 @@ export interface MobileEditModalProps {
|
||||
onAddIntake: (takenBy?: string) => void;
|
||||
onRemoveIntake: (idx: number) => void;
|
||||
// Value change handler for numeric fields
|
||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
||||
// Refill state (for edit mode)
|
||||
refillPacks: number;
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
usePrescriptionRefill: boolean;
|
||||
onUsePrescriptionRefillChange: (value: boolean) => void;
|
||||
refillSaving: boolean;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
// Image handling
|
||||
@@ -99,6 +101,8 @@ export function MobileEditModal({
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
usePrescriptionRefill,
|
||||
onUsePrescriptionRefillChange,
|
||||
refillSaving,
|
||||
onSubmitRefill,
|
||||
meds,
|
||||
@@ -137,184 +141,276 @@ export function MobileEditModal({
|
||||
onSaveMedication(e);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<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={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
|
||||
/>
|
||||
<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>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
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">
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => onTakenByInputChange(e.target.value)}
|
||||
onKeyDown={onTakenByKeyDown}
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
/>
|
||||
<datalist id="takenby-suggestions-modal">
|
||||
{existingPeople
|
||||
.filter((p) => !form.takenBy.includes(p))
|
||||
.map((person) => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Refill section - only shown when editing (mobile) */}
|
||||
{editingId && (
|
||||
<div className="full refill-section">
|
||||
<h4 className="refill-title">{t("refill.title")}</h4>
|
||||
<div className="refill-form-inline">
|
||||
<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>
|
||||
</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")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
</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="number"
|
||||
min="0"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
@@ -322,102 +418,109 @@ export function MobileEditModal({
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label>
|
||||
<label className="full">
|
||||
{t("refill.pillsToAdd")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<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")}
|
||||
<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>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{editingId && currentMed?.imageUrl ? (
|
||||
<div className="full image-field">
|
||||
<span className="field-label">{t("form.medicationImage")}</span>
|
||||
<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>
|
||||
<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>
|
||||
) : editingId ? (
|
||||
<label className="full">
|
||||
{t("form.medicationImage")}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<fieldset className="full blister-section">
|
||||
<legend>{t("form.blisters.title")}</legend>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
/>
|
||||
@@ -425,8 +528,9 @@ export function MobileEditModal({
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
@@ -477,14 +581,7 @@ export function MobileEditModal({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost add-blister"
|
||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
|
||||
@@ -17,6 +17,7 @@ export type DoseInfo = {
|
||||
when: number;
|
||||
usage: number;
|
||||
takenBy: string[];
|
||||
intakeRemindersEnabled: boolean;
|
||||
};
|
||||
|
||||
export type DayMedEntry = {
|
||||
@@ -58,7 +59,7 @@ export interface AppContextValue {
|
||||
testingShoutrrr: boolean;
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
saveSettings: (e?: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
|
||||
@@ -105,6 +106,8 @@ export interface AppContextValue {
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
usePrescriptionRefill: boolean;
|
||||
setUsePrescriptionRefill: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: ReturnType<typeof useRefill>["refillHistory"];
|
||||
refillHistoryExpanded: boolean;
|
||||
@@ -121,7 +124,8 @@ export interface AppContextValue {
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<any>>,
|
||||
loadMeds: () => void
|
||||
loadMeds: () => void,
|
||||
usePrescription?: boolean
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
@@ -382,6 +386,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
when: event.when,
|
||||
usage: event.usage,
|
||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
||||
});
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
@@ -602,6 +607,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
settings.notificationEmail !== savedSettings.notificationEmail ||
|
||||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
|
||||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
|
||||
settings.emailPrescriptionReminders !== savedSettings.emailPrescriptionReminders ||
|
||||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
|
||||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
|
||||
settings.lowStockDays !== savedSettings.lowStockDays ||
|
||||
@@ -611,6 +617,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl ||
|
||||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
|
||||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
|
||||
settings.shoutrrrPrescriptionReminders !== savedSettings.shoutrrrPrescriptionReminders ||
|
||||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
|
||||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
|
||||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
|
||||
@@ -735,6 +742,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setRefillPacks: refill.setRefillPacks,
|
||||
refillLoose: refill.refillLoose,
|
||||
setRefillLoose: refill.setRefillLoose,
|
||||
usePrescriptionRefill: refill.usePrescriptionRefill,
|
||||
setUsePrescriptionRefill: refill.setUsePrescriptionRefill,
|
||||
refillSaving: refill.refillSaving,
|
||||
refillHistory: refill.refillHistory,
|
||||
refillHistoryExpanded: refill.refillHistoryExpanded,
|
||||
|
||||
@@ -43,6 +43,11 @@ export const defaultForm = (): FormState => ({
|
||||
doseUnit: "mg",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: "",
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [defaultBlister()],
|
||||
intakes: [defaultIntake()],
|
||||
@@ -78,7 +83,7 @@ export interface UseMedicationFormReturn {
|
||||
removeIntake: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
resetForm: () => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
|
||||
addTakenByPerson: (name: string) => void;
|
||||
removeTakenByPerson: (name: string) => void;
|
||||
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
@@ -96,6 +101,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
|
||||
const parseNonNegativeInt = useCallback((value: string): number => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
}, []);
|
||||
|
||||
// Validate form fields
|
||||
const validateField = useCallback(
|
||||
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
@@ -199,6 +210,10 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
|
||||
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
@@ -217,6 +232,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills != null ? String(authorizedRefills) : "",
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills != null ? String(remainingRefills) : "",
|
||||
prescriptionLowRefillThreshold: String(lowRefillThreshold),
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate ? med.prescriptionExpiryDate.slice(0, 10) : "",
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
blisters: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
@@ -246,9 +266,54 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setOriginalForm(newForm);
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
const handleValueChange = useCallback(
|
||||
<K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [key]: value } as FormState;
|
||||
|
||||
if (key === "prescriptionAuthorizedRefills") {
|
||||
const raw = String(value);
|
||||
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
||||
}
|
||||
|
||||
if (key === "prescriptionRemainingRefills") {
|
||||
const raw = String(value);
|
||||
next.prescriptionRemainingRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
||||
}
|
||||
|
||||
if (key === "prescriptionLowRefillThreshold") {
|
||||
const raw = String(value);
|
||||
next.prescriptionLowRefillThreshold = raw === "" ? "" : String(parseNonNegativeInt(raw));
|
||||
}
|
||||
|
||||
if (!next.prescriptionEnabled) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const authorizedRefills = parseNonNegativeInt(next.prescriptionAuthorizedRefills);
|
||||
|
||||
if (key === "prescriptionAuthorizedRefills") {
|
||||
next.prescriptionRemainingRefills = String(
|
||||
Math.min(parseNonNegativeInt(next.prescriptionRemainingRefills), authorizedRefills)
|
||||
);
|
||||
next.prescriptionLowRefillThreshold = String(
|
||||
Math.min(parseNonNegativeInt(next.prescriptionLowRefillThreshold), authorizedRefills)
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "prescriptionRemainingRefills") {
|
||||
next.prescriptionRemainingRefills = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
|
||||
}
|
||||
|
||||
if (key === "prescriptionLowRefillThreshold") {
|
||||
next.prescriptionLowRefillThreshold = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[parseNonNegativeInt]
|
||||
);
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
const addTakenByPerson = useCallback(
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface UseRefillReturn {
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
usePrescriptionRefill: boolean;
|
||||
setUsePrescriptionRefill: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
@@ -30,7 +32,8 @@ export interface UseRefillReturn {
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
loadMeds: () => void,
|
||||
usePrescription?: boolean
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
@@ -44,6 +47,7 @@ export function useRefill(): UseRefillReturn {
|
||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||
const [refillPacks, setRefillPacks] = useState(1);
|
||||
const [refillLoose, setRefillLoose] = useState(0);
|
||||
const [usePrescriptionRefill, setUsePrescriptionRefill] = useState(false);
|
||||
const [refillSaving, setRefillSaving] = useState(false);
|
||||
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||
@@ -75,7 +79,8 @@ export function useRefill(): UseRefillReturn {
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
loadMeds: () => void,
|
||||
usePrescription: boolean = false
|
||||
) => {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
@@ -84,7 +89,7 @@ export function useRefill(): UseRefillReturn {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -94,11 +99,16 @@ export function useRefill(): UseRefillReturn {
|
||||
...f,
|
||||
packCount: String(data.newStock.packCount),
|
||||
looseTablets: String(data.newStock.looseTablets),
|
||||
prescriptionRemainingRefills:
|
||||
data.prescription?.remainingRefills != null
|
||||
? String(data.prescription.remainingRefills)
|
||||
: f.prescriptionRemainingRefills,
|
||||
}));
|
||||
}
|
||||
// Reset refill form
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
setUsePrescriptionRefill(false);
|
||||
// Close refill modal via history back for proper back-button support
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
@@ -217,6 +227,8 @@ export function useRefill(): UseRefillReturn {
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
usePrescriptionRefill,
|
||||
setUsePrescriptionRefill,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// useSettings Hook - Settings state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface Settings {
|
||||
@@ -26,19 +26,24 @@ export interface Settings {
|
||||
hasSmtpPassword: boolean;
|
||||
lastAutoEmailSent: string | null;
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
lastReminderMedName: string | null;
|
||||
lastReminderTakenBy: string | null;
|
||||
lastStockReminderSent: string | null;
|
||||
lastStockReminderChannel: "email" | "push" | "both" | null;
|
||||
lastStockReminderMedNames: string | null;
|
||||
lastPrescriptionReminderSent: string | null;
|
||||
lastPrescriptionReminderChannel: "email" | "push" | "both" | null;
|
||||
lastPrescriptionReminderMedNames: string | null;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
emailPrescriptionReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
shoutrrrPrescriptionReminders: boolean;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
expiryWarningDays: number;
|
||||
@@ -72,12 +77,17 @@ const defaultSettings: Settings = {
|
||||
lastStockReminderSent: null,
|
||||
lastStockReminderChannel: null,
|
||||
lastStockReminderMedNames: null,
|
||||
lastPrescriptionReminderSent: null,
|
||||
lastPrescriptionReminderChannel: null,
|
||||
lastPrescriptionReminderMedNames: null,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
expiryWarningDays: 30,
|
||||
@@ -97,7 +107,7 @@ export interface UseSettingsReturn {
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
saveSettings: (e?: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
hasUnsavedChanges: boolean;
|
||||
@@ -152,6 +162,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
|
||||
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
|
||||
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
|
||||
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
|
||||
lastPrescriptionReminderChannel:
|
||||
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
|
||||
lastPrescriptionReminderMedNames:
|
||||
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
|
||||
}));
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -163,6 +178,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
|
||||
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
|
||||
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
|
||||
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
|
||||
lastPrescriptionReminderChannel:
|
||||
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
|
||||
lastPrescriptionReminderMedNames:
|
||||
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -172,54 +192,45 @@ export function useSettings(): UseSettingsReturn {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Internal save function (no event needed)
|
||||
const performSave = useCallback(
|
||||
async (settingsToSave: Settings) => {
|
||||
// Auto-disable email if no recipient is set
|
||||
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
|
||||
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
|
||||
// Auto-disable push if no URL is set
|
||||
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
|
||||
|
||||
// Validate email if email notifications are enabled
|
||||
if (effectiveEmailEnabled && settings.notificationEmail) {
|
||||
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
||||
if (!emailRegex.test(settings.notificationEmail)) {
|
||||
setTestEmailResult({ success: false, message: "Invalid email address" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
|
||||
|
||||
setSettingsSaving(true);
|
||||
setTestEmailResult(null);
|
||||
|
||||
const payload = {
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
notificationEmail: settingsToSave.notificationEmail,
|
||||
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
||||
repeatDailyReminders: settingsToSave.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settingsToSave.lowStockDays,
|
||||
normalStockDays: settingsToSave.normalStockDays,
|
||||
highStockDays: settingsToSave.highStockDays,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareStockStatus: settings.shareStockStatus,
|
||||
shoutrrrUrl: settingsToSave.shoutrrrUrl,
|
||||
emailStockReminders: settingsToSave.emailStockReminders,
|
||||
emailIntakeReminders: settingsToSave.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
|
||||
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
|
||||
stockCalculationMode: settingsToSave.stockCalculationMode,
|
||||
shareStockStatus: settingsToSave.shareStockStatus,
|
||||
language: i18n.language,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
smtpUser: settings.smtpUser,
|
||||
smtpPass: settings.smtpPass || undefined,
|
||||
smtpFrom: settings.smtpFrom,
|
||||
smtpSecure: settings.smtpSecure,
|
||||
smtpHost: settingsToSave.smtpHost,
|
||||
smtpPort: settingsToSave.smtpPort,
|
||||
smtpUser: settingsToSave.smtpUser,
|
||||
smtpPass: settingsToSave.smtpPass || undefined,
|
||||
smtpFrom: settingsToSave.smtpFrom,
|
||||
smtpSecure: settingsToSave.smtpSecure,
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
@@ -230,7 +241,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
...settingsToSave,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
};
|
||||
@@ -239,7 +250,62 @@ export function useSettings(): UseSettingsReturn {
|
||||
setSavedSettings(updatedSettings);
|
||||
setSettingsSaved(true);
|
||||
},
|
||||
[settings, i18n.language]
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
// Debounced auto-save: fires whenever settings change
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip auto-save during initial load
|
||||
if (!initialLoadDone.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't save if nothing changed
|
||||
if (JSON.stringify(settings) === JSON.stringify(savedSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't save if thresholds are invalid
|
||||
if (settings.reminderDaysBefore >= settings.lowStockDays || settings.lowStockDays >= settings.highStockDays) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
performSave(settings);
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings, savedSettings, performSave]);
|
||||
|
||||
// Mark initial load as done after first settings load completes
|
||||
useEffect(() => {
|
||||
if (!settingsLoading && !initialLoadDone.current) {
|
||||
// Use a small delay to ensure savedSettings is set
|
||||
const t = setTimeout(() => {
|
||||
initialLoadDone.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [settingsLoading]);
|
||||
|
||||
// Legacy saveSettings wrapper (kept for compatibility)
|
||||
const saveSettings = useCallback(
|
||||
async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
await performSave(settings);
|
||||
},
|
||||
[settings, performSave]
|
||||
);
|
||||
|
||||
const testEmail = useCallback(async () => {
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
"criticalMeds_other": "{{count}} Medikamente kritisch",
|
||||
"lowMeds": "{{count}} Medikament knapp",
|
||||
"lowMeds_other": "{{count}} Medikamente knapp",
|
||||
"prescriptionNeeds": "Rezept knapp",
|
||||
"prescriptionLowMeds": "{{count}} Rezept knapp",
|
||||
"prescriptionLowMeds_other": "{{count}} Rezepte knapp",
|
||||
"prescriptionCriticalMeds": "{{count}} Rezept aufgebraucht",
|
||||
"prescriptionCriticalMeds_other": "{{count}} Rezepte aufgebraucht",
|
||||
"needsPrescriptionRefill": "Rezept nachfüllen nötig",
|
||||
"usedBy": "Verwendet von",
|
||||
"refillsLeft": "{{count}} Nachfüllung übrig",
|
||||
"refillsLeft_other": "{{count}} Nachfüllungen übrig",
|
||||
"daysLeft": "{{days}} Tag übrig",
|
||||
"daysLeft_other": "{{days}} Tage übrig",
|
||||
"needsRefill": "Nachfüllen nötig"
|
||||
@@ -133,6 +142,11 @@
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"sections": {
|
||||
"general": "Allgemein",
|
||||
"stock": "Bestand & Dosis",
|
||||
"prescription": "Rezept"
|
||||
},
|
||||
"commercialName": "Handelsname",
|
||||
"genericName": "Wirkstoff",
|
||||
"takenBy": "Eingenommen von",
|
||||
@@ -206,6 +220,7 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Bestands-Erinnerungen",
|
||||
"intakeReminders": "Einnahme-Erinnerungen",
|
||||
"prescriptionReminders": "Rezept-Erinnerungen",
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
@@ -228,7 +243,7 @@
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Erinnerungsplan",
|
||||
"stockCheck": "Bestandsprüfung",
|
||||
"stockCheck": "Bestands- & Rezeptprüfung",
|
||||
"dailyAt6": "Täglich um 6:00 Uhr",
|
||||
"intakeCheck": "Einnahmeprüfung",
|
||||
"15minBefore": "15 Min. vor geplanter Zeit",
|
||||
@@ -236,6 +251,7 @@
|
||||
"lastSent": "Letzte Benachrichtigung",
|
||||
"lastStockSent": "Letzte Bestands-Erinnerung",
|
||||
"lastIntakeSent": "Letzte Einnahme-Erinnerung",
|
||||
"lastPrescriptionSent": "Letzte Rezept-Erinnerung",
|
||||
"envHint": "Diese Werte können über REMINDER_HOUR und REMINDER_MINUTES_BEFORE in .env konfiguriert werden"
|
||||
},
|
||||
"stock": {
|
||||
@@ -258,7 +274,8 @@
|
||||
},
|
||||
"stockReminder": {
|
||||
"title": "Bestands-Erinnerung",
|
||||
"description": "Benachrichtigung wenn Medikamentenbestand erreicht",
|
||||
"description": "Bestands-Erinnerungen aktivieren",
|
||||
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Niedrig: Bestand wird knapp. Kritisch: Bestand ist kritisch niedrig — bald nachbestellen.",
|
||||
"repeatDaily": "Täglich wiederholen",
|
||||
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
|
||||
},
|
||||
@@ -364,6 +381,7 @@
|
||||
"sending": "Wird gesendet...",
|
||||
"sent": "Gesendet!",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"networkError": "Netzwerkfehler",
|
||||
"saving": "Wird gespeichert...",
|
||||
"unsavedChanges": {
|
||||
@@ -468,6 +486,7 @@
|
||||
"downloadFilename": "medassist-export"
|
||||
},
|
||||
"refill": {
|
||||
"saveFirst": "Speichere das Medikament zuerst, um Nachfüllen zu aktivieren",
|
||||
"title": "Nachfüllen",
|
||||
"packs": "Packungen hinzufügen",
|
||||
"pillsToAdd": "Tabletten hinzufügen",
|
||||
@@ -482,7 +501,16 @@
|
||||
"packsAdded_other": "{{count}} Packungen",
|
||||
"pillsAdded": "{{count}} Tablette",
|
||||
"pillsAdded_other": "{{count}} Tabletten",
|
||||
"button": "Nachfüllen"
|
||||
"button": "Nachfüllen",
|
||||
"viaPrescription": "Rezept"
|
||||
},
|
||||
"prescription": {
|
||||
"enabled": "Rezept verfolgen",
|
||||
"authorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Nachfüllungen",
|
||||
"lowThreshold": "Schwelle für Rezept-Erinnerung",
|
||||
"expiryDate": "Rezeptablauf",
|
||||
"useForRefill": "Rezept-Nachfüllung verwenden"
|
||||
},
|
||||
"editStock": {
|
||||
"title": "Bestand korrigieren",
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
"criticalMeds_other": "{{count}} medications critical",
|
||||
"lowMeds": "{{count}} medication low",
|
||||
"lowMeds_other": "{{count}} medications low",
|
||||
"prescriptionNeeds": "Prescription low",
|
||||
"prescriptionLowMeds": "{{count}} prescription low",
|
||||
"prescriptionLowMeds_other": "{{count}} prescriptions low",
|
||||
"prescriptionCriticalMeds": "{{count}} prescription empty",
|
||||
"prescriptionCriticalMeds_other": "{{count}} prescriptions empty",
|
||||
"needsPrescriptionRefill": "Needs prescription refill",
|
||||
"usedBy": "Used by",
|
||||
"refillsLeft": "{{count}} refill left",
|
||||
"refillsLeft_other": "{{count}} refills left",
|
||||
"daysLeft": "{{days}} day left",
|
||||
"daysLeft_other": "{{days}} days left",
|
||||
"needsRefill": "Needs refill"
|
||||
@@ -133,6 +142,11 @@
|
||||
"editEntry": "Edit medication",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"stock": "Stock & Dose",
|
||||
"prescription": "Prescription"
|
||||
},
|
||||
"commercialName": "Commercial Name",
|
||||
"genericName": "Generic Name",
|
||||
"takenBy": "Taken by",
|
||||
@@ -206,6 +220,7 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Stock Reminders",
|
||||
"intakeReminders": "Intake Reminders",
|
||||
"prescriptionReminders": "Prescription Reminders",
|
||||
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||
"skipTakenDoses": "Skip reminders for taken doses",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||
@@ -228,7 +243,7 @@
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Reminder Schedule",
|
||||
"stockCheck": "Stock check",
|
||||
"stockCheck": "Stock & prescription check",
|
||||
"dailyAt6": "Daily at 6:00 AM",
|
||||
"intakeCheck": "Intake check",
|
||||
"15minBefore": "15 min before scheduled time",
|
||||
@@ -236,6 +251,7 @@
|
||||
"lastSent": "Last notification sent",
|
||||
"lastStockSent": "Last stock reminder",
|
||||
"lastIntakeSent": "Last intake reminder",
|
||||
"lastPrescriptionSent": "Last prescription reminder",
|
||||
"envHint": "These values can be configured via REMINDER_HOUR and REMINDER_MINUTES_BEFORE in .env"
|
||||
},
|
||||
"stock": {
|
||||
@@ -258,7 +274,8 @@
|
||||
},
|
||||
"stockReminder": {
|
||||
"title": "Stock Reminder",
|
||||
"description": "Sends notification when medication stock reaches",
|
||||
"description": "Enable stock reminders",
|
||||
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Low: stock is running low. Critical: stock is critically low — reorder soon.",
|
||||
"repeatDaily": "Repeat daily",
|
||||
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
|
||||
},
|
||||
@@ -364,6 +381,7 @@
|
||||
"sending": "Sending...",
|
||||
"sent": "Sent!",
|
||||
"sendFailed": "Failed to send",
|
||||
"saveFailed": "Failed to save",
|
||||
"networkError": "Network error",
|
||||
"saving": "Saving...",
|
||||
"unsavedChanges": {
|
||||
@@ -468,6 +486,7 @@
|
||||
"downloadFilename": "medassist-export"
|
||||
},
|
||||
"refill": {
|
||||
"saveFirst": "Save medication first to enable refill",
|
||||
"title": "Refill",
|
||||
"packs": "Packs to add",
|
||||
"pillsToAdd": "Pills to add",
|
||||
@@ -482,7 +501,16 @@
|
||||
"packsAdded_other": "{{count}} packs",
|
||||
"pillsAdded": "{{count}} pill",
|
||||
"pillsAdded_other": "{{count}} pills",
|
||||
"button": "Refill"
|
||||
"button": "Refill",
|
||||
"viaPrescription": "Prescription"
|
||||
},
|
||||
"prescription": {
|
||||
"enabled": "Track prescription",
|
||||
"authorizedRefills": "Authorized refills",
|
||||
"remainingRefills": "Remaining refills",
|
||||
"lowThreshold": "Low-refill reminder threshold",
|
||||
"expiryDate": "Prescription expiry",
|
||||
"useForRefill": "Use prescription refill"
|
||||
},
|
||||
"editStock": {
|
||||
"title": "Correct Stock",
|
||||
|
||||
@@ -79,7 +79,7 @@ function NotificationBellIcon() {
|
||||
export function getReminderStatusData(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
lowCoverage: Coverage[],
|
||||
_allLowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
lastNotificationType: string | null,
|
||||
@@ -97,12 +97,30 @@ export function getReminderStatusData(
|
||||
lastStockSent: { date: string; medNames: string | null } | null;
|
||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const criticalCount = lowCoverage.length;
|
||||
const lowCount = allCoverage.filter((c) => {
|
||||
if (c.medsLeft <= 0) return false;
|
||||
if (c.daysLeft === null) return false;
|
||||
return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore;
|
||||
}).length;
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.daysLeft === null) continue;
|
||||
|
||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||
const isLow = c.daysLeft < lowStockDays;
|
||||
if (!isCritical && !isLow) continue;
|
||||
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||
}
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
// Determine status
|
||||
let status: { text: string; className: string };
|
||||
@@ -123,34 +141,6 @@ export function getReminderStatusData(
|
||||
};
|
||||
}
|
||||
|
||||
// Collect all low stock medications (critical + low), deduplicated by name
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
// Add critical meds (from lowCoverage - these are ≤3 days)
|
||||
for (const c of lowCoverage) {
|
||||
if (c.daysLeft !== null) {
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || c.daysLeft < existing.daysLeft) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add low but not critical meds
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) continue;
|
||||
if (c.daysLeft === null) continue;
|
||||
if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) {
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || c.daysLeft < existing.daysLeft) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by days left (most urgent first)
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
|
||||
// Parse last stock reminder sent info (from dedicated stock tracking columns)
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
@@ -252,45 +242,117 @@ export function DashboardPage() {
|
||||
const intakeRemindersEnabled =
|
||||
(settings.emailEnabled && settings.emailIntakeReminders) ||
|
||||
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
|
||||
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled;
|
||||
const prescriptionRemindersEnabled =
|
||||
(settings.emailEnabled && settings.emailPrescriptionReminders) ||
|
||||
(settings.shoutrrrEnabled && settings.shoutrrrPrescriptionReminders);
|
||||
|
||||
const prescriptionLowMeds = meds
|
||||
.filter((med) => {
|
||||
if (!med.prescriptionEnabled) return false;
|
||||
const remaining = med.prescriptionRemainingRefills ?? 0;
|
||||
const threshold = med.prescriptionLowRefillThreshold ?? 1;
|
||||
return remaining <= threshold;
|
||||
})
|
||||
.map((med) => ({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
remainingRefills: med.prescriptionRemainingRefills ?? 0,
|
||||
threshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
}))
|
||||
.sort((a, b) => a.remainingRefills - b.remainingRefills);
|
||||
|
||||
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
|
||||
|
||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||
const prescriptionStatus =
|
||||
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
|
||||
? {
|
||||
text:
|
||||
prescriptionEmptyCount > 0
|
||||
? t("dashboard.reminders.prescriptionCriticalMeds", { count: prescriptionEmptyCount })
|
||||
: t("dashboard.reminders.prescriptionLowMeds", { count: prescriptionLowMeds.length }),
|
||||
className: prescriptionEmptyCount > 0 ? "danger" : "warning",
|
||||
}
|
||||
: null;
|
||||
|
||||
// Manual reminder send state
|
||||
const [sendingReminder, setSendingReminder] = useState(false);
|
||||
const [reminderResult, setReminderResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
async function sendManualReminder() {
|
||||
if (!stockRemindersEnabled || reminderData.lowStockMeds.length === 0) return;
|
||||
const sendableStock = stockRemindersEnabled && reminderData.lowStockMeds.length > 0;
|
||||
const sendablePrescription = prescriptionRemindersEnabled && prescriptionLowMeds.length > 0;
|
||||
if (!sendableStock && !sendablePrescription) return;
|
||||
|
||||
setSendingReminder(true);
|
||||
setReminderResult(null);
|
||||
|
||||
try {
|
||||
const lowStock = reminderData.lowStockMeds.map((m) => {
|
||||
const cov = coverage.all.find((c) => c.name === m.name);
|
||||
return {
|
||||
name: m.name,
|
||||
medsLeft: cov?.medsLeft ?? 0,
|
||||
daysLeft: m.daysLeft,
|
||||
depletionDate: cov?.depletionDate ?? null,
|
||||
isCritical: m.isCritical,
|
||||
};
|
||||
});
|
||||
const messages: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const res = await fetch("/api/reminder/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
lowStock,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReminderResult({ success: true, message: data.message || t("common.sent") });
|
||||
// Refresh settings so "Last stock reminder" row appears immediately
|
||||
if (sendableStock) {
|
||||
const lowStock = reminderData.lowStockMeds.map((m) => {
|
||||
const cov = coverage.all.find((c) => c.name === m.name);
|
||||
return {
|
||||
name: m.name,
|
||||
medsLeft: cov?.medsLeft ?? 0,
|
||||
daysLeft: m.daysLeft,
|
||||
depletionDate: cov?.depletionDate ?? null,
|
||||
isCritical: m.isCritical,
|
||||
};
|
||||
});
|
||||
|
||||
const stockRes = await fetch("/api/reminder/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
lowStock,
|
||||
}),
|
||||
});
|
||||
const stockData = await stockRes.json();
|
||||
if (stockRes.ok) {
|
||||
messages.push(stockData.message || t("common.sent"));
|
||||
} else {
|
||||
errors.push(stockData.error || t("common.sendFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (sendablePrescription) {
|
||||
const prescriptionLow = prescriptionLowMeds.map((med) => {
|
||||
const fullMed = meds.find((m) => m.id === med.id);
|
||||
return {
|
||||
name: med.name,
|
||||
remainingRefills: med.remainingRefills,
|
||||
threshold: med.threshold,
|
||||
expiryDate: fullMed?.prescriptionExpiryDate ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
prescriptionLow,
|
||||
}),
|
||||
});
|
||||
const prescriptionData = await prescriptionRes.json();
|
||||
if (prescriptionRes.ok) {
|
||||
messages.push(prescriptionData.message || t("common.sent"));
|
||||
} else {
|
||||
errors.push(prescriptionData.error || t("common.sendFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
setReminderResult({ success: true, message: messages.join(" • ") });
|
||||
loadSettings();
|
||||
} else {
|
||||
setReminderResult({ success: false, message: data.error || t("common.sendFailed") });
|
||||
setReminderResult({ success: false, message: errors.join(" • ") || t("common.sendFailed") });
|
||||
}
|
||||
} catch {
|
||||
setReminderResult({ success: false, message: t("common.networkError") });
|
||||
@@ -307,9 +369,15 @@ export function DashboardPage() {
|
||||
<NotificationBellIcon />
|
||||
</span>
|
||||
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
|
||||
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
|
||||
{stockRemindersEnabled && (
|
||||
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
|
||||
)}
|
||||
{prescriptionStatus && (
|
||||
<span className={`status-chip small ${prescriptionStatus.className}`}>{prescriptionStatus.text}</span>
|
||||
)}
|
||||
</div>
|
||||
{(reminderData.lowStockMeds.length > 0 ||
|
||||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
|
||||
(stockRemindersEnabled && reminderData.lastStockSent) ||
|
||||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
|
||||
<div className="reminder-status-details">
|
||||
@@ -346,27 +414,54 @@ export function DashboardPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
|
||||
<div className="reminder-status-row">
|
||||
<span className="reminder-status-label">{t("dashboard.reminders.needsPrescriptionRefill")}:</span>
|
||||
<span className="reminder-status-value">
|
||||
{prescriptionLowMeds.map((med, idx) => {
|
||||
const medication = meds.find((m) => m.id === med.id);
|
||||
const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
|
||||
return (
|
||||
<span key={med.id}>
|
||||
{idx > 0 && ", "}
|
||||
<span className={`reminder-days-left ${textClass}`}>
|
||||
{t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "}
|
||||
{t("dashboard.reminders.usedBy")}:{" "}
|
||||
<span
|
||||
className={`med-link clickable ${textClass}`}
|
||||
onClick={() => medication && openMedDetail(medication)}
|
||||
>
|
||||
{med.name}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stockRemindersEnabled && reminderData.lastStockSent && (
|
||||
<div className="reminder-status-row">
|
||||
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
|
||||
<span className="reminder-status-value">
|
||||
{reminderData.lastStockSent.medNames &&
|
||||
(() => {
|
||||
// Extract first med name (medNames may be "Name (+N)")
|
||||
const rawName = reminderData.lastStockSent!.medNames!;
|
||||
const firstName = rawName.replace(/\s*\(\+\d+\)$/, "");
|
||||
const suffix = rawName.includes("(+") ? rawName.slice(firstName.length) : "";
|
||||
const medication = meds.find((m) => m.name === firstName);
|
||||
return medication ? (
|
||||
<>
|
||||
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
|
||||
{firstName}
|
||||
const names = reminderData.lastStockSent!.medNames!.split(", ");
|
||||
return names.map((name, idx) => {
|
||||
const medication = meds.find((m) => m.name === name);
|
||||
return (
|
||||
<span key={name}>
|
||||
{idx > 0 && ", "}
|
||||
{medication ? (
|
||||
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
|
||||
{name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="reminder-med-name">{name}</span>
|
||||
)}
|
||||
</span>
|
||||
{suffix && <span className="reminder-med-name">{suffix}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="reminder-med-name">{rawName}</span>
|
||||
);
|
||||
);
|
||||
});
|
||||
})()}
|
||||
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
|
||||
</span>
|
||||
@@ -396,7 +491,8 @@ export function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
|
||||
{((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
|
||||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
|
||||
<div className="reminder-send-row">
|
||||
<button type="button" className="ghost" onClick={sendManualReminder} disabled={sendingReminder}>
|
||||
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
|
||||
@@ -527,20 +623,26 @@ export function DashboardPage() {
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
{(() => {
|
||||
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="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
<span data-label={t("table.stock")} className={textClass}>
|
||||
{med?.packageType === "bottle"
|
||||
@@ -698,14 +800,6 @@ export function DashboardPage() {
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -726,6 +820,14 @@ export function DashboardPage() {
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
@@ -905,14 +1007,6 @@ export function DashboardPage() {
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -937,6 +1031,14 @@ export function DashboardPage() {
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
@@ -1092,14 +1194,6 @@ export function DashboardPage() {
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -1120,6 +1214,14 @@ export function DashboardPage() {
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,6 @@ export function SettingsPage() {
|
||||
settings,
|
||||
setSettings,
|
||||
settingsLoading,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
saveSettings,
|
||||
settingsChanged,
|
||||
// Email testing
|
||||
testEmail,
|
||||
testingEmail,
|
||||
@@ -41,7 +37,7 @@ export function SettingsPage() {
|
||||
{settingsLoading ? (
|
||||
<p>{t("settings.loading")}</p>
|
||||
) : (
|
||||
<form className="settings-form" onSubmit={saveSettings}>
|
||||
<div className="settings-form">
|
||||
{/* Language */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
@@ -52,7 +48,16 @@ export function SettingsPage() {
|
||||
<span className="setting-label">{t("settings.language.select")}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
i18n.changeLanguage(lang);
|
||||
fetch("/api/settings/language", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
className="language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
@@ -132,6 +137,37 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t("settings.notifications.prescriptionReminders")}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled
|
||||
? settings.shoutrrrPrescriptionReminders
|
||||
: false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
|
||||
@@ -196,10 +232,9 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="480"
|
||||
step="5"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value, 10) || 30 })
|
||||
@@ -218,10 +253,9 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
@@ -243,7 +277,9 @@ export function SettingsPage() {
|
||||
<div className="setting-row compact">
|
||||
<label className="setting-label">
|
||||
{t("settings.stockReminder.description")}{" "}
|
||||
<span className="status-chip small danger">{t("status.criticalStock")}</span>
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.stockReminder.infoTooltip")}>
|
||||
ⓘ
|
||||
</span>{" "}
|
||||
</label>
|
||||
<label
|
||||
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
|
||||
@@ -276,6 +312,7 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-row compact" style={{ marginTop: "4px" }}>
|
||||
<label className="setting-label">
|
||||
{t("settings.stockReminder.repeatDaily")}
|
||||
@@ -320,6 +357,7 @@ export function SettingsPage() {
|
||||
emailEnabled: false,
|
||||
emailStockReminders: false,
|
||||
emailIntakeReminders: false,
|
||||
emailPrescriptionReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
@@ -389,6 +427,7 @@ export function SettingsPage() {
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrStockReminders: false,
|
||||
shoutrrrIntakeReminders: false,
|
||||
shoutrrrPrescriptionReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
@@ -497,6 +536,20 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.lastPrescriptionReminderSent && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -565,9 +618,9 @@ export function SettingsPage() {
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="364"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
@@ -592,9 +645,9 @@ export function SettingsPage() {
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="365"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
@@ -612,9 +665,9 @@ export function SettingsPage() {
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
min="3"
|
||||
max="730"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
@@ -747,25 +800,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="form-footer">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
settingsSaving ||
|
||||
(!settingsChanged && settingsSaved) ||
|
||||
settings.reminderDaysBefore >= settings.lowStockDays ||
|
||||
settings.lowStockDays >= settings.highStockDays
|
||||
}
|
||||
>
|
||||
{settingsSaving
|
||||
? t("common.saving")
|
||||
: settingsSaved && !settingsChanged
|
||||
? t("common.saved")
|
||||
: t("settings.saveSettings")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Confirmation Modal */}
|
||||
|
||||
+195
-86
@@ -319,7 +319,7 @@ body.modal-open {
|
||||
.reminder-status-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
@@ -375,8 +375,9 @@ body.modal-open {
|
||||
|
||||
.reminder-send-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
@@ -435,6 +436,9 @@ body.modal-open {
|
||||
.reminder-low-stock-list {
|
||||
padding-left: 0;
|
||||
}
|
||||
.reminder-send-row {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@@ -602,6 +606,26 @@ body.modal-open {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Grid view for medications page */
|
||||
.med-grid-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.med-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.med-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.med-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -823,20 +847,10 @@ body.modal-open {
|
||||
}
|
||||
}
|
||||
|
||||
.blisters h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.gap {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Blisters header actions */
|
||||
.blisters-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Inline checkbox for compact layout */
|
||||
.inline-checkbox {
|
||||
display: flex !important;
|
||||
@@ -1069,6 +1083,82 @@ textarea.auto-resize {
|
||||
.form-grid .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-category {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 1.25rem;
|
||||
padding: 0.95rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.form-category-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--accent-light);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.form-category .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-category > label:not(.full) {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.form-category-header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-category-header .form-category-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Intake section */
|
||||
.intake-section .blister-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Image section */
|
||||
.image-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-section .image-preview {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.image-section input[type="file"] {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-section input[type="file"]::file-selector-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-right: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-section input[type="file"]::file-selector-button:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.form-grid .optional-label {
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
@@ -1926,6 +2016,7 @@ textarea.auto-resize {
|
||||
.status-chip.small {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
word-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -3354,13 +3445,6 @@ textarea.auto-resize {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Image upload section */
|
||||
.image-upload-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3376,26 +3460,6 @@ textarea.auto-resize {
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.image-upload-section input[type="file"] {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-upload-section input[type="file"]::file-selector-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-right: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-upload-section input[type="file"]::file-selector-button:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -5168,30 +5232,68 @@ a.about-version-link:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline refill form in edit modal */
|
||||
.refill-form-inline {
|
||||
/* Refill: submit row (button + pill preview) */
|
||||
.refill-submit-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.refill-form-inline label {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
.refill-submit-row button {
|
||||
height: 42px;
|
||||
padding: 0 2rem;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.refill-form-inline label input {
|
||||
/* Refill: prescription toggle row */
|
||||
.refill-prescription-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.refill-prescription-row .refill-prescription-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.2;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refill-form-inline button {
|
||||
.refill-prescription-row .refill-prescription-toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
align-self: flex-end;
|
||||
height: 42px;
|
||||
padding: 0 1rem;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.refill-prescription-label-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.refill-prescription-row .refill-prescription-toggle input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refill-remaining-badge {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--success);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.refill-preview {
|
||||
@@ -5212,11 +5314,7 @@ a.about-version-link:hover {
|
||||
}
|
||||
|
||||
.refill-section {
|
||||
border-left: 3px solid var(--success);
|
||||
background: var(--bg-secondary);
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1rem 1rem 1.25rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-title {
|
||||
@@ -5226,23 +5324,33 @@ a.about-version-link:hover {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button {
|
||||
.refill-section .refill-submit-row button {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button:hover:not(:disabled) {
|
||||
.refill-section .refill-submit-row button:hover:not(:disabled) {
|
||||
background: var(--success-hover, #3aa865);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button:disabled {
|
||||
.refill-section .refill-submit-row button:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refill-unavailable {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--border-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Edit Stock Modal (Correction)
|
||||
============================================================================= */
|
||||
@@ -5573,6 +5681,31 @@ a.about-version-link:hover {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-edit-form .form-category {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-prescription-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-prescription-row .refill-prescription-toggle {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-remaining-badge {
|
||||
margin-left: 0;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.mobile-edit-form.form-grid input,
|
||||
.mobile-edit-form.form-grid textarea,
|
||||
.mobile-edit-form.form-grid select {
|
||||
@@ -5580,30 +5713,6 @@ a.about-version-link:hover {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile Blister/Intake Schedule Section */
|
||||
.mobile-edit-form .blister-section {
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-edit-form .blister-section legend {
|
||||
font-size: 0.95rem;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-edit-form .blister-section legend .toggle-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-edit-form .blister-section legend .legend-hint {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mobile-edit-form .blister-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -144,6 +144,36 @@ describe("MedDetailModal", () => {
|
||||
expect(screen.getByText("Test notes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prescription details section when prescription is enabled", () => {
|
||||
const med: Medication = {
|
||||
...mockMedication,
|
||||
prescriptionEnabled: true,
|
||||
prescriptionAuthorizedRefills: 5,
|
||||
prescriptionRemainingRefills: 2,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
prescriptionExpiryDate: "2026-12-31",
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.getByText(/form\.sections\.prescription/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/prescription\.authorizedRefills/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/prescription\.remainingRefills/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/prescription\.lowThreshold/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/prescription\.expiryDate/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show prescription details section when prescription is disabled", () => {
|
||||
const med: Medication = {
|
||||
...mockMedication,
|
||||
prescriptionEnabled: false,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
expect(screen.queryByText(/form\.sections\.prescription/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays schedule information", () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
@@ -247,7 +277,7 @@ describe("MedDetailModal with refill modal", () => {
|
||||
|
||||
const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement;
|
||||
fireEvent.click(submitBtn);
|
||||
expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id);
|
||||
expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id, undefined);
|
||||
});
|
||||
|
||||
it("disables refill submit button when no pills are entered", () => {
|
||||
|
||||
@@ -460,7 +460,7 @@ describe("MobileEditModal field callbacks", () => {
|
||||
const onHandleValueChange = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onHandleValueChange={onHandleValueChange} />);
|
||||
|
||||
const packCountInput = document.querySelector('input[type="number"][min="0"]') as HTMLInputElement;
|
||||
const packCountInput = document.querySelector('input[type="text"][inputmode="numeric"]') as HTMLInputElement;
|
||||
fireEvent.change(packCountInput, { target: { value: "4" } });
|
||||
|
||||
expect(onHandleValueChange).toHaveBeenCalledWith("packCount", "4");
|
||||
|
||||
@@ -138,13 +138,19 @@ describe("useRefill", () => {
|
||||
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/medications/1/refill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 }),
|
||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/medications/1/refills",
|
||||
expect.objectContaining({ credentials: "include" })
|
||||
);
|
||||
expect(mockSetForm).toHaveBeenCalled();
|
||||
expect(mockLoadMeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("useSettings", () => {
|
||||
expect(result.current.settingsSaved).toBe(true);
|
||||
});
|
||||
|
||||
it("validates email before saving", async () => {
|
||||
it("keeps email channel enabled when recipient is non-empty", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
@@ -111,8 +111,7 @@ describe("useSettings", () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
expect(result.current.testEmailResult?.success).toBe(false);
|
||||
expect(result.current.testEmailResult?.message).toContain("Invalid email");
|
||||
expect(result.current.settings.emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("tests email notification", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,11 @@ export type Medication = {
|
||||
imageUrl?: string | null;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
prescriptionEnabled?: boolean;
|
||||
prescriptionAuthorizedRefills?: number | null;
|
||||
prescriptionRemainingRefills?: number | null;
|
||||
prescriptionLowRefillThreshold?: number;
|
||||
prescriptionExpiryDate?: string | null;
|
||||
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
|
||||
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
||||
updatedAt: string | number | null;
|
||||
@@ -74,6 +79,7 @@ export type RefillEntry = {
|
||||
id: number;
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
usedPrescription?: boolean;
|
||||
refillDate: string;
|
||||
};
|
||||
|
||||
@@ -110,6 +116,11 @@ export type FormState = {
|
||||
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
prescriptionEnabled: boolean;
|
||||
prescriptionAuthorizedRefills: string;
|
||||
prescriptionRemainingRefills: string;
|
||||
prescriptionLowRefillThreshold: string;
|
||||
prescriptionExpiryDate: string;
|
||||
intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
|
||||
blisters: FormBlister[]; // Legacy form format
|
||||
intakes: FormIntake[]; // New form format with per-intake takenBy
|
||||
@@ -154,6 +165,7 @@ export type ScheduleEvent = {
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||
intakeRemindersEnabled: boolean; // Per-intake reminder flag
|
||||
};
|
||||
|
||||
export type BlisterStock = {
|
||||
|
||||
Reference in New Issue
Block a user