refactor(frontend): modularize App.tsx into components, pages, hooks, and context (#60)

- Extract App.tsx from 764 lines to ~404 lines
- Create reusable components: MedDetailModal, MobileEditModal, ShareDialog, etc.
- Add AppContext for global state management
- Split pages: DashboardPage, MedicationsPage, SchedulePage, SettingsPage, PlannerPage
- Create custom hooks: useAuth, useMedications, useSettings, useDoses, useSchedule
- Add utility functions in separate modules
- Fix stock status logic (>30 days = green/normal)
- Fix reminder threshold calculation (use reminderDaysBefore not lowStockDays)
- Fix takenBy validation (send [] instead of null)
- Fix datetime format for blister start times (add Z suffix)
- Style 'All OK' status as green/bold

BREAKING: None - all existing functionality preserved
This commit is contained in:
Daniel Volz
2026-01-22 05:38:34 +01:00
committed by GitHub
parent 89edd74de3
commit 8718311876
44 changed files with 7448 additions and 5139 deletions
+627
View File
@@ -0,0 +1,627 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar, ConfirmModal } from "../components";
import { formatNumber, getExpiryClass } from "../utils/formatters";
import type { Coverage } from "../types";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to calculate blister stock
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
const openBlisterPills = totalPills % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
}
// Helper to format full blisters
function formatFullBlisters(count: number, t: (key: string) => string): string {
return `${count} ${t('common.blisters')}`;
}
// Helper to format open blister and loose pills
function formatOpenBlisterAndLoose(openBlisterPills: number, loosePills: number, _pillsPerBlister: number, t: (key: string) => string): string {
if (openBlisterPills === 0 && loosePills === 0) return "-";
return `${openBlisterPills} ${t('common.pills')}`;
}
// Get total pills for a medication
function getMedTotal(med: { packCount: number; blistersPerPack: number; pillsPerBlister: number; looseTablets: number; stockAdjustment?: number | null }): number {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
// Get next reminder date for a medication
function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string {
if (!row.depletionDate) return "-";
const depletionDate = new Date(row.depletionDate);
const reminderDate = new Date(depletionDate);
reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore);
const now = new Date();
if (reminderDate <= now) return "-";
return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" });
}
// Get reminder status as JSX with proper styling
function getReminderStatusContent(
reminderDaysBefore: number,
lowStockDays: number,
lowCoverage: Coverage[],
allCoverage: Coverage[],
lastAutoEmailSent: string | null,
lastNotificationType: string | null,
lastNotificationChannel: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): React.ReactNode {
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 > 3;
}).length;
let statusElement: React.ReactNode;
if (criticalCount > 0) {
statusElement = <span className="danger-text">{t('dashboard.reminders.criticalMeds', { count: criticalCount })}</span>;
} else if (lowCount > 0) {
statusElement = <span className="warning-text">{t('dashboard.reminders.lowMeds', { count: lowCount })}</span>;
} else {
statusElement = <span className="success-text">{t('dashboard.reminders.allOk')}</span>;
}
// Find next medication to hit reminder threshold (lowest daysLeft > reminderDaysBefore)
const nextToRunOut = allCoverage
.filter(c => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
let nextText = "";
if (nextToRunOut && nextToRunOut.daysLeft !== null) {
// Show days until it hits the reminder threshold, not until empty
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore);
nextText = `${t('dashboard.reminders.next')}: ${nextToRunOut.name} ${t('dashboard.reminders.inDays', { days: daysUntilReminder })}`;
}
let lastSentText = "";
if (lastAutoEmailSent) {
const lastSent = new Date(lastAutoEmailSent);
const formattedDate = lastSent.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
const channelIcon = lastNotificationChannel === "shoutrrr" ? "🔔" : "📧";
lastSentText = `${t('dashboard.reminders.lastSent')}: ${channelIcon} ${formattedDate}`;
}
return (
<>
{statusElement}
{nextText && <span className="next-reminder"> {nextText}</span>}
{lastSentText && <span className="last-sent"> · {lastSentText}</span>}
</>
);
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const {
meds,
settings,
coverage,
coverageByMed,
depletionByMed,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
pastDays,
futureDays,
takenDoses,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
manuallyCollapsedDays,
manuallyExpandedDays,
toggleDayCollapse,
missedPastDoseIds,
getDayStockStatus,
getDoseId,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearingMissed,
dismissMissedDoses,
openMedDetail,
openUserFilter,
openShareDialog,
openScheduleLightbox,
} = useAppContext();
// Local state for reminder email
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendReminderEmail() {
if (!settings.notificationEmail || coverage.low.length === 0) return;
setSendingReminderEmail(true);
setReminderEmailResult(null);
try {
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: settings.notificationEmail,
lowStock: coverage.low,
}),
});
const data = await res.json();
if (res.ok) {
setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
} else {
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
}
} catch {
setReminderEmailResult({ success: false, message: "Network error" });
}
setSendingReminderEmail(false);
}
return (
<>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<section className="email-status-bar">
<span className="email-status-icon">{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"}</span>
<span className="email-status-text">
<span className="email-status-line">{t('dashboard.reminders.active')}</span>
{getReminderStatusContent(settings.reminderDaysBefore, settings.lowStockDays, coverage.low, coverage.all, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, i18n.language)}
</span>
{settings.emailEnabled && settings.notificationEmail && <span className="email-status-recipient"> {settings.notificationEmail}</span>}
</section>
)}
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.reorder.title')}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t('dashboard.reorder.noMeds')}</p>;
}
// Count medications with "Low" stock status (based on lowStockDays setting)
const lowStockCount = coverage.all.filter(c => {
if (c.medsLeft <= 0) return true; // out of stock
if (c.daysLeft === null) return false; // no schedule
return c.daysLeft < settings.lowStockDays;
}).length;
if (coverage.low.length === 0) {
// No critical meds (≤3 days)
if (lowStockCount === 0) {
// All good - everything is Normal or High
return <p className="success-text">{t('dashboard.reorder.allGood')}</p>;
} else {
// Some meds are Low but not critical
return <p className="warning-text">{t('dashboard.reorder.lowWarning', { count: lowStockCount })}</p>;
}
}
return (
<>
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.status')}</span>
<span>{t('table.runsOut')}</span>
<span>{t('table.autoRemind')}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && <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>
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.days')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.autoRemind')} className="next-reminder-date">{getNextReminderForMed(row, settings.reminderDaysBefore, i18n.language)}</span>
</div>
);
})}
</div>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? t('common.sending') : t('dashboard.reorder.sendReminder')}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span>
)}
</div>
)}
</>
);
})()}
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.overview.title')}</h2>
</div>
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.runsOut')}</span>
<span>{t('table.expiry')}</span>
<span>{t('table.status')}</span>
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<span className="med-name-line">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
</span>
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && <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>
)}
</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)}</span>
<span data-label={t('table.daysLeft')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.expiry')} className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
</div>
);
})}
</div>
</article>
</section>
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('dashboard.schedules.title')}</h2>
<div className="card-head-actions">
{meds.some(m => m.takenBy && m.takenBy.length > 0) && (
<button className="ghost share-btn" onClick={openShareDialog} title={t('share.button')}>
🔗 {t('share.button')}
</button>
)}
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 && (() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{missedCount > 0 ? (
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}> {missedCount}</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}></span>
) : null}
</div>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t('dashboard.schedules.clearMissed')}
>
{t('dashboard.schedules.clearMissed')}
</button>
)}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}></span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.find(m => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<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">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate worst stock status for this day
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger") ? "danger" : dayStockStatuses.includes("warning") ? "warning" : "success";
// Check if this is today, past, or future
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
// Determine if day should be collapsed: only today is expanded by default
const isAutoCollapsed = allDayTaken || !isToday;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find(m => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<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">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>
{t(status.label)}
</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isOverdue = dose.when < Date.now();
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
</article>
</section>
{/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && (
<ConfirmModal
title={t('dashboard.schedules.clearMissedConfirmTitle')}
message={t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}
confirmLabel={clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
cancelLabel={t('dashboard.schedules.clearMissedCancel')}
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
onCancel={() => setShowClearMissedConfirm(false)}
isLoading={clearingMissed}
/>
)}
</>
);
}
+530
View File
@@ -0,0 +1,530 @@
import { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { MedicationAvatar, MobileEditModal } from "../components";
import { useMedicationForm } from "../hooks";
import { formatNumber, formatDateTime } from "../utils/formatters";
import { getPackageSize, FIELD_LIMITS } from "../types";
import type { Medication } from "../types";
export function MedicationsPage() {
const { t, i18n } = useTranslation();
const {
meds,
loading,
saving,
setSaving,
loadMeds,
deleteMed,
uploadMedImage,
deleteMedImage,
uploadingImage,
existingPeople,
refillPacks,
setRefillPacks,
refillLoose,
setRefillLoose,
refillSaving,
submitRefill,
} = useAppContext();
// Use the medication form hook
const {
form,
setForm,
editingId,
setEditingId,
formSaved,
setFormSaved,
formChanged,
fieldErrors,
hasValidationErrors,
takenByInput,
setTakenByInput,
addTakenByPerson,
removeTakenByPerson,
handleTakenByKeyDown,
handleValueChange,
addBlister,
removeBlister,
setBlisterValue,
resetForm,
startEdit,
} = useMedicationForm();
// Image state for new medications
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
// Mobile modal state
const [showEditModal, setShowEditModal] = useState(false);
// Calculate total tablets
const totalTablets = useMemo(() => {
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
// Open mobile edit modal
function openEditModal() {
setShowEditModal(true);
window.history.pushState({ modal: 'edit' }, '');
}
// Close mobile edit modal
function closeEditModal() {
if (showEditModal) {
window.history.back();
}
}
// Handle delete medication
async function handleDeleteMed(id: number) {
if (!confirm(t('medications.deleteConfirm'))) return;
await deleteMed(id, editingId, resetForm);
}
// Handle submit refill
async function handleSubmitRefill(medId: number) {
await submitRefill(medId, editingId, setForm, loadMeds);
}
// Save medication
async function saveMedication(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaving(true);
// Prepare medication data
const blisters = form.blisters.map(b => ({
usage: Number(b.usage) || 1,
every: Number(b.every) || 1,
start: combineDateAndTime(b.startDate, b.startTime),
}));
const body = {
name: form.name.trim(),
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
packCount: Number(form.packCount) || 0,
blistersPerPack: Number(form.blistersPerPack) || 1,
pillsPerBlister: Number(form.pillsPerBlister) || 1,
looseTablets: Number(form.looseTablets) || 0,
pillWeightMg: Number(form.pillWeightMg) || null,
expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled,
blisters,
};
try {
let url = "/api/medications";
let method = "POST";
if (editingId) {
url = `/api/medications/${editingId}`;
method = "PUT";
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error("Failed to save");
}
const saved = await res.json();
// Upload image if pending (for new medications)
if (!editingId && pendingImage && saved.id) {
await uploadMedImage(saved.id, pendingImage);
setPendingImage(null);
setPendingImagePreview(null);
}
setFormSaved(true);
loadMeds();
// Reset form after successful save
if (!editingId) {
resetForm();
}
} catch (err) {
console.error("Save error:", err);
alert(t('common.saveFailed'));
}
setSaving(false);
}
// Handle browser back button for modals
useEffect(() => {
const handlePopState = () => {
if (showEditModal) {
setShowEditModal(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showEditModal]);
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && showEditModal) {
closeEditModal();
resetForm();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showEditModal]);
// Handle edit button click - open modal on mobile
function handleEditClick(med: Medication) {
startEdit(med, openEditModal);
}
return (
<section className="grid">
<article className="card meds">
<div className="card-head">
<h2>{t('medications.list.title')}</h2>
<button
type="button"
className="btn primary small"
onClick={() => {
resetForm();
// On mobile, open the edit modal
if (window.innerWidth <= 768) {
openEditModal();
}
}}
>
+ {t('form.newEntry')}
</button>
</div>
<div className="med-list">
{meds.map((med) => (
<div key={med.id} className="med-row">
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<div className="med-name">{med.name}</div>
</div>
<div className="med-details">
<span>{t('medications.details.packs')}: <strong>{med.packCount}</strong></span>
<span>{t('medications.details.blisters')}: <strong>{med.blistersPerPack}</strong></span>
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
</div>
<div className="med-total">{t('medications.details.total')}: {getPackageSize(med)} {t('common.pills')}</div>
</div>
<div className="med-actions">
<button className="info" onClick={() => handleEditClick(med)}>{t('common.edit')}</button>
<button className="danger" onClick={() => handleDeleteMed(med.id)}>{t('common.delete')}</button>
</div>
</div>
<div className="blister-list">
{med.blisters.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="blister-row-simple">
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.blisters.from')} {formatDateTime(s.start, i18n.language)}
</div>
))}
</div>
</div>
))}
</div>
</article>
<article className="card form desktop-only">
<div className="card-head">
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
</div>
<form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? 'has-error' : ''}>
{t('form.commercialName')}
<input
value={form.name}
onChange={(e) => setForm({ ...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={fieldErrors.genericName ? 'has-error' : ''}>
{t('form.genericName')}
<input
value={form.genericName}
onChange={(e) => setForm({ ...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={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={() => removeTakenByPerson(person)}>×</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => setTakenByInput(e.target.value)}
onKeyDown={handleTakenByKeyDown}
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions"
/>
<datalist id="takenby-suggestions">
{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>
{t('form.packs')}
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
</label>
<label>
{t('form.blistersPerPack')}
<input type="number" min="1" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
</label>
<label>
{t('form.pillsPerBlister')}
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
</label>
<label>
{t('form.loosePills')}
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
</label>
<label>
{t('form.pillWeight')}
<input type="number" min="1" value={form.pillWeightMg} onChange={(e) => handleValueChange("pillWeightMg", e.target.value)} placeholder={t('form.placeholders.weight')} />
</label>
<label>
{t('form.total')}
<div className="static-value">{formatNumber(totalTablets)}</div>
</label>
<label>
{t('form.expiryDate')}
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
</label>
{/* Refill section - only shown when editing */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t('refill.title')}</h4>
<div className="refill-form-inline">
<label>
{t('refill.packs')}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
/>
</label>
<button
type="button"
className="success"
onClick={() => handleSubmitRefill(editingId!)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('refill.adding') : t('refill.button')}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
{t('form.notes')}
<textarea
value={form.notes}
onChange={(e) => handleValueChange("notes", e.target.value)}
placeholder={t('form.placeholders.notes')}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.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 className="full blisters">
<div className="card-head">
<h3>{t('form.blisters.title')}</h3>
<div className="blisters-actions">
<label className="inline-checkbox" title={t('form.blisters.remindTooltip')}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t('form.blisters.remind')}</span>
</label>
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
</div>
</div>
{form.blisters.map((s, idx) => (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<label>
{t('form.blisters.usage')}
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} />
</label>
<label>
{t('form.blisters.everyDays')}
<input type="number" min="1" value={s.every} onChange={(e) => setBlisterValue(idx, "every", e.target.value)} />
</label>
<label>
{t('form.blisters.startDate')}
<input type="date" value={s.startDate} onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)} />
</label>
<label>
{t('form.blisters.startTime')}
<input type="time" value={s.startTime} onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)} />
</label>
</div>
{form.blisters.length > 1 && (
<button type="button" className="danger" onClick={() => removeBlister(idx)}>{t('common.remove')}</button>
)}
</div>
))}
</div>
<div className="full image-upload-section">
<label className="setting-label">{t('form.medicationImage')}</label>
{(() => {
// When editing an existing medication
if (editingId) {
const currentMed = meds.find(m => m.id === editingId);
if (currentMed?.imageUrl) {
return (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>{t('form.removeImage')}</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
disabled={uploadingImage}
/>
);
}
// When creating a new medication
if (pendingImagePreview) {
return (
<div className="image-preview">
<img src={pendingImagePreview} alt="Preview" />
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setPendingImage(file);
const reader = new FileReader();
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
reader.readAsDataURL(file);
}
}}
/>
);
})()}
</div>
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
{t('common.cancel')}
</button>
)}
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
{saving ? t('common.saving') : formSaved && !formChanged ? t('common.saved') : t('common.save')}
</button>
</div>
</form>
</article>
{/* Mobile Edit Modal */}
<MobileEditModal
show={showEditModal}
editingId={editingId}
form={form}
onFormChange={setForm}
fieldErrors={fieldErrors}
saving={saving}
formSaved={formSaved}
formChanged={formChanged}
hasValidationErrors={hasValidationErrors}
takenByInput={takenByInput}
onTakenByInputChange={setTakenByInput}
existingPeople={existingPeople}
onAddTakenByPerson={addTakenByPerson}
onRemoveTakenByPerson={removeTakenByPerson}
onTakenByKeyDown={handleTakenByKeyDown}
onSetBlisterValue={setBlisterValue}
onAddBlister={addBlister}
onRemoveBlister={removeBlister}
onHandleValueChange={handleValueChange}
refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks}
refillLoose={refillLoose}
onRefillLooseChange={setRefillLoose}
refillSaving={refillSaving}
onSubmitRefill={handleSubmitRefill}
meds={meds}
onUploadMedImage={uploadMedImage}
onDeleteMedImage={deleteMedImage}
onClose={() => { closeEditModal(); }}
onResetForm={resetForm}
onSaveMedication={saveMedication}
/>
</section>
);
}
// Helper function to combine date and time into ISO datetime with Z suffix
function combineDateAndTime(date: string, time: string): string {
return `${date}T${time}:00.000Z`;
}
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { PlannerRow } from "../types";
import { toInputValue } from "../utils/formatters";
// Date helpers
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function plusDaysIso(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
// Convert datetime-local value to ISO string
function toIsoString(value: string): string {
if (!value) return new Date().toISOString();
const date = new Date(value);
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
}
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
export function PlannerPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerLoading, setPlannerLoading] = useState(false);
const [range, setRange] = useState<{ start: string; end: string }>({
start: toInputValue(todayIso()),
end: toInputValue(plusDaysIso(3))
});
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
// Load user-specific planner data when user changes
useEffect(() => {
if (typeof window !== "undefined" && user?.id) {
const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows"));
const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange"));
if (savedRows) {
try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); }
} else {
setPlannerRows([]);
}
if (savedRange) {
try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
} else {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
}
} else {
setPlannerRows([]);
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
}
}, [user?.id]);
async function runPlanner(e: React.FormEvent) {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then((res) => res.json())
.catch(() => []) as PlannerRow[];
setPlannerRows(rows);
setPlannerLoading(false);
// Save to user-specific localStorage
if (user?.id) {
localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range));
localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows));
}
}
function resetRange() {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
setPlannerRows([]);
if (user?.id) {
localStorage.removeItem(userStorageKey(user.id, "plannerRange"));
localStorage.removeItem(userStorageKey(user.id, "plannerRows"));
}
}
async function sendPlannerEmail() {
if (!settings.notificationEmail || plannerRows.length === 0) return;
setSendingPlannerEmail(true);
setPlannerEmailResult(null);
try {
const res = await fetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
until: range.end,
rows: plannerRows,
}),
});
const data = await res.json();
if (res.ok) {
setPlannerEmailResult({ success: true, message: data.message || "Email sent!" });
} else {
setPlannerEmailResult({ success: false, message: data.error || "Failed to send" });
}
} catch {
setPlannerEmailResult({ success: false, message: "Network error" });
}
setSendingPlannerEmail(false);
}
return (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t('planner.title')}</h2>
</div>
<form className="planner" onSubmit={runPlanner}>
<label>
{t('planner.from')}
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
</label>
<label>
{t('planner.until')}
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
</label>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>{t('common.reset')}</button>
<button type="submit" disabled={plannerLoading}>{plannerLoading ? t('planner.calculating') : t('planner.calculate')}</button>
</div>
</form>
{plannerRows.length > 0 && (
<>
<div className="table">
<div className="table-head">
<span>{t('planner.table.medication')}</span>
<span>{t('planner.table.usage')}</span>
<span>{t('planner.table.blistersNeeded')}</span>
<span>{t('planner.table.available')}</span>
<span>{t('table.status')}</span>
</div>
{plannerRows.map((row) => {
const med = meds.find(m => m.name === row.medicationName);
return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong>&nbsp;{t('common.pills')}</span>
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
<span data-label={t('planner.table.available')}>
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
</span>
<span data-label={t('table.status')} className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? t('status.enough') : t('status.outOfStock')}</span>
</div>
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
<div className="planner-email-action">
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
{sendingPlannerEmail ? t('common.sending') : t('planner.sendEmail')}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
{plannerEmailResult.message}
</span>
)}
</div>
)}
</>
)}
</article>
</section>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { MedicationAvatar } from "../components";
import type { Coverage } from "../types";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to get worst stock status for a day
function getDayStockStatus(dayMeds: Array<{ medName: string }>, coverageByMed: Record<string, Coverage>, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }): string {
let worstLevel = 3; // 3=success, 2=warning, 1=danger
for (const item of dayMeds) {
const cov = coverageByMed[item.medName];
if (!cov) continue;
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
}
return worstLevel === 1 ? "danger" : worstLevel === 2 ? "warning" : "success";
}
// Helper to get dose ID (with or without person)
function getDoseId(baseId: string, person: string | null): string {
return person ? `${baseId}-${person}` : baseId;
}
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const {
meds,
settings,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
pastDays,
futureDays,
takenDoses,
markDoseTaken,
undoDoseTaken,
coverageByMed,
depletionByMed,
manuallyExpandedDays,
toggleDayCollapse,
openUserFilter,
} = useAppContext();
return (
<section className="grid">
<article className="card schedule-full">
<div className="card-head">
<h2>{t('dashboard.schedules.title')}</h2>
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
<option value={90}>{t('dashboard.schedules.3months')}</option>
<option value={180}>{t('dashboard.schedules.6months')}</option>
</select>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 && (() => {
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
return (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{missedPastDoses > 0 && <span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}> {missedPastDoses}</span>}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}></span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.find(m => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><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">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
return (
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const med = meds.find(m => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><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">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>
{t(status.label)}
</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const now = Date.now();
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
return (
<div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);})}
</div>
</article>
</section>
);
}
+563
View File
@@ -0,0 +1,563 @@
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context";
import { ConfirmModal, ExportModal } from "../components";
export function SettingsPage() {
const { t, i18n } = useTranslation();
const {
settings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
saveSettings,
settingsChanged,
// Email testing
testEmail,
testingEmail,
testEmailResult,
// Shoutrrr testing
testShoutrrr,
testingShoutrrr,
testShoutrrrResult,
// Export/Import
exporting,
importing,
showExportModal,
setShowExportModal,
handleExport,
handleImportFileSelect,
showImportConfirm,
setShowImportConfirm,
pendingImportData,
setPendingImportData,
handleImportConfirm,
importResult,
setImportResult,
} = useAppContext();
return (
<section className="grid">
{settingsLoading ? (
<p>{t('settings.loading')}</p>
) : (
<form className="settings-form" onSubmit={saveSettings}>
{/* Language */}
<article className="card">
<div className="card-head">
<h2>{t('settings.language.title')}</h2>
</div>
<div className="setting-section">
<label className="setting-row language-row">
<span className="setting-label">{t('settings.language.select')}</span>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="language-select"
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
</div>
</article>
{/* Notifications */}
<article className="card">
<div className="card-head">
<h2>{t('settings.notifications.title')}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.channels')}</h3>
</div>
<div className="notification-matrix">
<div className="matrix-header">
<div className="matrix-label"></div>
<div className="matrix-channel">{t('settings.notifications.email')}</div>
<div className="matrix-channel">{t('settings.notifications.push')}</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t('settings.notifications.stockReminders')}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
onChange={(e) => setSettings({ ...settings, emailStockReminders: 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.shoutrrrStockReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: 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.shoutrrrIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: 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>
)}
{/* Skip reminders for taken doses */}
<div className="setting-row compact" style={{marginTop: "16px"}}>
<label className="setting-label">
{t('settings.notifications.skipTakenDoses')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.skipRemindersForTakenDoses}
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Repeat reminders for missed doses */}
<div className="setting-row compact" style={{marginTop: "12px"}}>
<label className="setting-label">
{t('settings.notifications.repeatReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.repeatRemindersEnabled}
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Reminder interval (only shown when repeat is enabled) */}
{settings.repeatRemindersEnabled && (
<>
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.reminderInterval')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}></span>
</label>
<input
type="number"
min="5"
max="480"
step="5"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.maxNaggingReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}></span>
</label>
<input
type="number"
min="1"
max="20"
step="1"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.email')}</h3>
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.smtpHost ? settings.emailEnabled : false}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.shoutrrrEnabled) {
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, emailEnabled: newVal });
}
}}
disabled={!settings.smtpHost}
/>
<span className="toggle-slider"></span>
</label>
</div>
{settings.emailEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.email.recipient')}</span>
<div className="input-with-tooltip">
<input
type="email"
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="your@email.com"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
autoComplete="email"
/>
<span className="info-tooltip" data-tooltip={`SMTP: ${settings.smtpHost || t('settings.email.notConfigured')}:${settings.smtpPort}${settings.hasSmtpPassword ? '\nPassword: ✓' : ''}`}></span>
</div>
</label>
</div>
<div className="setting-actions">
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
{testingEmail ? t('common.sending') : t('common.test')}
</button>
{testEmailResult && (
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
{testEmailResult.message}
</span>
)}
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.push')}</h3>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shoutrrrEnabled}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.emailEnabled) {
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, shoutrrrEnabled: newVal });
}
}}
/>
<span className="toggle-slider"></span>
</label>
</div>
{settings.shoutrrrEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t('settings.push.url')}</span>
<div className="input-with-tooltip">
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t('settings.push.urlPlaceholder')}
/>
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}></span>
</div>
</label>
</div>
<div className="setting-actions">
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
{testingShoutrrr ? t('common.sending') : t('common.test')}
</button>
{testShoutrrrResult && (
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
{testShoutrrrResult.message}
</span>
)}
</div>
</>
)}
</div>
<div className="schedule-overview">
<div className="schedule-header">
<span className="schedule-title">{t('settings.schedule.title')}</span>
<span className="info-tooltip" data-tooltip={t('settings.schedule.envHint')}></span>
</div>
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.stockCheck')}</span>
<span className="schedule-value">{t('settings.schedule.dailyAt6')}</span>
</div>
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.intakeCheck')}</span>
<span className="schedule-value">{t('settings.schedule.15minBefore')}</span>
</div>
{settings.nextScheduledCheck && (
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.nextCheck')}</span>
<span className="schedule-value">{new Date(settings.nextScheduledCheck).toLocaleString(i18n.language, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
</div>
)}
{settings.lastAutoEmailSent && (
<div className="schedule-row">
<span className="schedule-label">{t('settings.schedule.lastSent')}</span>
<span className="schedule-value">{new Date(settings.lastAutoEmailSent).toLocaleString(i18n.language, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
</div>
)}
</div>
</article>
{/* Stock Settings */}
<article className="card">
<div className="card-head">
<h2>{t('settings.stock.title')}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.threshold')}</h3>
</div>
<div className="threshold-input">
<label>
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
<div className="threshold-field">
<input
type="number"
min="1"
max="90"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
<span className="threshold-unit">{t('common.days')}</span>
</div>
</label>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t('settings.stock.repeatDaily')}
<span className="info-tooltip small" data-tooltip={t('settings.stock.repeatTooltip')}></span>
</label>
<label className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl))}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.calculationMode')}</h3>
</div>
<div className="setting-group calculation-mode-group">
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
<input
type="radio"
name="stockCalculationMode"
value="automatic"
checked={settings.stockCalculationMode === 'automatic'}
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
/>
<div className="radio-card-content">
<div className="radio-card-text">
<span className="radio-card-title">{t('settings.stock.automatic')}</span>
<span className="radio-card-desc">{t('settings.stock.automaticDesc')}</span>
</div>
</div>
</label>
<label className={`radio-card ${settings.stockCalculationMode === 'manual' ? 'selected' : ''}`}>
<input
type="radio"
name="stockCalculationMode"
value="manual"
checked={settings.stockCalculationMode === 'manual'}
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
/>
<div className="radio-card-content">
<div className="radio-card-text">
<span className="radio-card-title">{t('settings.stock.manual')}</span>
<span className="radio-card-desc">{t('settings.stock.manualDesc')}</span>
</div>
</div>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.stock.display')}</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}></span>
</div>
</label>
<label>
<span className="field-label">{t('settings.stock.highStockDays')}</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}></span>
</div>
</label>
</div>
</div>
</article>
{/* Export/Import Section */}
<article className="card">
<div className="card-head">
<h2>
{t('exportImport.title')}
<span className="info-tooltip" data-tooltip={t('exportImport.description')}></span>
</h2>
</div>
<div className="setting-section">
<div className="setting-group">
{/* Import Success Message */}
{importResult && (
<div className="success-banner" style={{marginBottom: '16px', padding: '12px 16px', borderRadius: '8px', backgroundColor: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)'}}>
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
<div>
<strong style={{display: 'block', marginBottom: '4px', color: 'var(--success)'}}> {t('exportImport.importSuccess')}</strong>
<span style={{fontSize: '0.9em'}}>{t('exportImport.importSuccessDetails', {
medications: importResult.medications,
doses: importResult.doses,
shares: importResult.shares
})}</span>
</div>
<button
type="button"
onClick={() => setImportResult(null)}
style={{background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2em', padding: '0', lineHeight: '1', color: 'inherit', opacity: 0.7}}
aria-label="Close"
>×</button>
</div>
</div>
)}
{/* Export */}
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
</div>
<button
type="button"
className="secondary"
onClick={() => setShowExportModal(true)}
disabled={exporting}
>
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
</button>
</div>
{/* Import */}
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.importTitle')}</span>
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
</div>
<input
type="file"
id="import-file-input"
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: 'none'}}
/>
<button
type="button"
className="secondary"
onClick={() => document.getElementById('import-file-input')?.click()}
disabled={importing}
>
{importing ? t('exportImport.importing') : t('exportImport.import')}
</button>
</div>
</div>
</div>
</article>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
</button>
</div>
</form>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t('exportImport.confirmImport')}
message={
<>
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
<p className="warning-text"> {t('exportImport.confirmImportWarning')}</p>
</>
}
confirmLabel={t('exportImport.confirmButton')}
cancelLabel={t('exportImport.cancelButton')}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant="danger"
/>
)}
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
</section>
);
}
+6
View File
@@ -0,0 +1,6 @@
// Pages barrel export
export { DashboardPage } from "./DashboardPage";
export { MedicationsPage } from "./MedicationsPage";
export { PlannerPage } from "./PlannerPage";
export { SchedulePage } from "./SchedulePage";
export { SettingsPage } from "./SettingsPage";