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:
@@ -222,6 +222,8 @@ The `main` branch is protected - releases must go through the automated release
|
||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||
|
||||
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
|
||||
|
||||
**Keep it informative but concise.** Users want to know what changed and where to find it.
|
||||
|
||||
**Required structure of release notes:**
|
||||
@@ -243,6 +245,12 @@ The `main` branch is protected - releases must go through the automated release
|
||||
- ❌ Number of tests added
|
||||
- ❌ Internal API changes (unless breaking)
|
||||
- ❌ Excessive emoji on every bullet point
|
||||
- ❌ .gitignore changes or other developer-only file changes
|
||||
- ❌ AI/Copilot instruction updates
|
||||
- ❌ CI/CD workflow changes (unless affecting users)
|
||||
- ❌ Code refactoring without user-visible changes
|
||||
|
||||
**Only include user-relevant changes** - things that affect what users see or experience in the app.
|
||||
|
||||
**Example of good release notes:**
|
||||
|
||||
|
||||
@@ -71,3 +71,4 @@ Thumbs.db
|
||||
*.local
|
||||
.cache/
|
||||
.turbo/
|
||||
docs/TECH_STACK.md
|
||||
Generated
+2
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
@@ -2079,7 +2079,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz",
|
||||
"integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@libsql/core": "^0.10.0",
|
||||
"@libsql/hrana-client": "^0.6.2",
|
||||
@@ -4579,7 +4578,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -5776,7 +5774,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6538,7 +6535,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -6602,7 +6598,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6678,7 +6673,6 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
|
||||
+129
-5123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from '../App';
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: 'checking' | 'up-to-date' | 'update-available' | 'error';
|
||||
latestVersion?: string;
|
||||
lastChecked?: string;
|
||||
}
|
||||
|
||||
interface AboutModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
// Fetch backend version and cached update result on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Fetch backend version
|
||||
fetch('/api/health')
|
||||
.then(res => res.json())
|
||||
.then(data => setBackendVersion(data.version || 'unknown'))
|
||||
.catch(() => setBackendVersion('unknown'));
|
||||
|
||||
// Load cached update check result
|
||||
const cached = sessionStorage.getItem('updateCheckResult');
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setUpdateCheckResult(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
async function checkForUpdates() {
|
||||
setUpdateCheckResult({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
const latestVersion = (data.tag_name || '').replace(/^v/, '');
|
||||
const currentVersion = FRONTEND_VERSION.replace(/^v/, '');
|
||||
const isUpToDate = latestVersion === currentVersion;
|
||||
const result: UpdateCheckResult = {
|
||||
status: isUpToDate ? 'up-to-date' : 'update-available',
|
||||
latestVersion,
|
||||
lastChecked: new Date().toISOString()
|
||||
};
|
||||
setUpdateCheckResult(result);
|
||||
// Cache the result
|
||||
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
|
||||
} catch {
|
||||
setUpdateCheckResult({ status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z"/>
|
||||
<path d="M12 8v4l2.5 2.5"/>
|
||||
<path d="M9 2h6M12 2v2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{t('about.appName', 'MedAssist')}</h2>
|
||||
<p className="about-tagline">{t('about.description', 'Personal medication tracking and reminder app')}</p>
|
||||
</div>
|
||||
<div className="about-versions">
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.frontendVersion', 'Frontend')}</span>
|
||||
<span className="about-version-value">{FRONTEND_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.backendVersion', 'Backend')}</span>
|
||||
<span className="about-version-value">{backendVersion || '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-update-section">
|
||||
<button className="about-update-btn" onClick={checkForUpdates} disabled={updateCheckResult?.status === 'checking'}>
|
||||
{updateCheckResult?.status === 'checking' ? (
|
||||
<>
|
||||
<span className="spinner-small"></span>
|
||||
{t('about.checking', 'Checking...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 16h5v5"/>
|
||||
</svg>
|
||||
{t('about.checkForUpdates', 'Check for Updates')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{updateCheckResult && updateCheckResult.status !== 'checking' && (
|
||||
<div className={`about-update-result ${updateCheckResult.status}`}>
|
||||
{updateCheckResult.status === 'up-to-date' && (
|
||||
<span className="update-status-text">✓ {t('about.upToDate', 'You are up to date!')}</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'update-available' && (
|
||||
<span className="update-status-text">
|
||||
⬆ {t('about.updateAvailable', 'Update available')}: <strong>v{updateCheckResult.latestVersion}</strong>
|
||||
<a href={`${GITHUB_URL}/releases/latest`} target="_blank" rel="noopener noreferrer" className="update-download-link">
|
||||
{t('about.downloadUpdate', 'Download')}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'error' && (
|
||||
<span className="update-status-text">⚠ {t('about.checkFailed', 'Could not check for updates')}</span>
|
||||
)}
|
||||
{updateCheckResult.lastChecked && (
|
||||
<span className="update-last-checked">
|
||||
{t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="about-links">
|
||||
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{t('about.viewOnGitHub', 'View on GitHub')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<p className="about-copyright">{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}</p>
|
||||
<p className="about-license">{t('about.license', 'GPL-3.0 License')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* AppHeader - Main application header with navigation and user menu
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "./Auth";
|
||||
import { useTheme } from "../hooks";
|
||||
|
||||
interface AppHeaderProps {
|
||||
onOpenProfile: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { user, authState, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// User dropdown state (for mobile click-based behavior)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||
|
||||
// Close user dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!userDropdownOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.user-menu')) {
|
||||
setUserDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, [userDropdownOpen]);
|
||||
|
||||
// Page titles based on current route
|
||||
const pageInfo = {
|
||||
"/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') },
|
||||
"/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') },
|
||||
"/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') },
|
||||
"/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') },
|
||||
"/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') },
|
||||
}[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') };
|
||||
|
||||
return (
|
||||
<header className="hero">
|
||||
<div className="hero-title">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
|
||||
<div>
|
||||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||||
<h1>{pageInfo.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="tabs">
|
||||
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>{t('nav.dashboard')}</button>
|
||||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
|
||||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
|
||||
</div>
|
||||
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
|
||||
{!authState?.authEnabled && (
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}>⚙️</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{authState?.authEnabled && user && (
|
||||
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
|
||||
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
||||
) : (
|
||||
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="user-dropdown">
|
||||
<div className="dropdown-header">
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
|
||||
) : (
|
||||
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
|
||||
)}
|
||||
<span className="dropdown-username">{user.username}</span>
|
||||
</div>
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={() => { onOpenProfile(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{t('auth.profile', 'Profile')}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{t('nav.settings', 'Settings')}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { onOpenAbout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
{t('about.title', 'About')}
|
||||
</button>
|
||||
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{t('auth.signOut', 'Sign Out')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// =============================================================================
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string | ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
confirmVariant?: "primary" | "danger" | "success";
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
confirmVariant = "primary"
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{title}</h2>
|
||||
<div style={{ marginBottom: "24px" }}>{typeof message === "string" ? <p>{message}</p> : message}</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}
|
||||
>
|
||||
<button type="button" className="ghost" onClick={onCancel} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button type="button" className={confirmVariant} onClick={onConfirm} disabled={isLoading}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (includeImages: boolean) => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.exportOptions')}</h2>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}>
|
||||
<button
|
||||
type="button"
|
||||
className="action-card"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onExport(true);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportWithImages')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportWithImagesDesc')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-card"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onExport(false);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportDataOnly')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportDataOnlyDesc')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('exportImport.cancelButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// =============================================================================
|
||||
// Lightbox Component - Full-screen image viewer
|
||||
// =============================================================================
|
||||
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* MedDetailModal - Medication detail view with nested modals
|
||||
* Displays medication information, stock, schedules, and provides refill/edit functionality
|
||||
*
|
||||
* Can work in two modes:
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, RefillEntry, StockThresholds } from "../types";
|
||||
import { MedicationAvatar, Lightbox } from "../components";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
// =============================================================================
|
||||
// Local Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate blister stock - divides current pills into full blisters and partial
|
||||
*/
|
||||
function getBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
_originalLooseTablets: number,
|
||||
_originalTotalPills: number
|
||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
||||
}
|
||||
const fullBlisters = Math.floor(currentPills / pillsPerBlister);
|
||||
const openBlisterPills = currentPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters column
|
||||
*/
|
||||
function formatFullBlisters(fullBlisters: number, t: (key: string) => string): string {
|
||||
if (fullBlisters === 0) return "—";
|
||||
return `${fullBlisters} ${fullBlisters === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format open blister column
|
||||
*/
|
||||
function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
_loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Props Interface
|
||||
// =============================================================================
|
||||
|
||||
export interface MedDetailModalProps {
|
||||
// Required
|
||||
selectedMed: Medication | null;
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
// Modal state
|
||||
showImageLightbox: boolean;
|
||||
showRefillModal: boolean;
|
||||
showEditStockModal: boolean;
|
||||
// Modal actions
|
||||
onClose: () => void;
|
||||
onOpenImageLightbox: () => void;
|
||||
onCloseImageLightbox: () => void;
|
||||
onOpenRefillModal: () => void;
|
||||
onCloseRefillModal: () => void;
|
||||
onOpenEditStockModal: () => void;
|
||||
onCloseEditStockModal: () => void;
|
||||
// Refill state
|
||||
refillPacks: number;
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
onRefillHistoryExpandedChange: (value: boolean) => void;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
// Edit stock state
|
||||
editStockFullBlisters: number;
|
||||
onEditStockFullBlistersChange: (value: number) => void;
|
||||
editStockPartialBlisterPills: number;
|
||||
onEditStockPartialBlisterPillsChange: (value: number) => void;
|
||||
editStockSaving: boolean;
|
||||
onSubmitStockCorrection: (medId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function MedDetailModal({
|
||||
selectedMed,
|
||||
coverage,
|
||||
settings,
|
||||
showImageLightbox,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
onClose,
|
||||
onOpenImageLightbox,
|
||||
onCloseImageLightbox,
|
||||
onOpenRefillModal,
|
||||
onCloseRefillModal,
|
||||
onOpenEditStockModal,
|
||||
onCloseEditStockModal,
|
||||
refillPacks,
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
onRefillHistoryExpandedChange,
|
||||
onSubmitRefill,
|
||||
editStockFullBlisters,
|
||||
onEditStockFullBlistersChange,
|
||||
editStockPartialBlisterPills,
|
||||
onEditStockPartialBlisterPillsChange,
|
||||
editStockSaving,
|
||||
onSubmitStockCorrection,
|
||||
}: MedDetailModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedMed) return null;
|
||||
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const packageSize = getPackageSize(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="med-detail-body">
|
||||
{/* Header */}
|
||||
<div className="med-detail-header">
|
||||
<div
|
||||
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? "clickable" : ""}`}
|
||||
onClick={() => selectedMed.imageUrl && onOpenImageLightbox()}
|
||||
>
|
||||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
||||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||||
</div>
|
||||
<div className="med-detail-titles">
|
||||
<h2>{selectedMed.name}</h2>
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")} {selectedMed.takenBy.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Info Section */}
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.stockInfo")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.openBlister")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.pillsPerBlister ?? 1, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="med-detail-item full-width">
|
||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{currentStock} / {packageSize}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Package Details Section */}
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.packageDetails")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||
</div>
|
||||
{selectedMed.pillWeightMg && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.expiryDate && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.expiryDate")}</span>
|
||||
<span className={`med-detail-value ${new Date(selectedMed.expiryDate) < new Date() ? "danger-text" : ""}`}>
|
||||
{new Date(selectedMed.expiryDate).toLocaleDateString(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">
|
||||
<h3>
|
||||
{t("modal.intakeSchedule")}{" "}
|
||||
{selectedMed.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="med-detail-schedules">
|
||||
{selectedMed.blisters.map((blister, idx) => {
|
||||
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
|
||||
const totalUsage = blister.usage * personCount;
|
||||
return (
|
||||
<div key={idx} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{t("form.blisters.every")} {blister.every} {blister.every !== 1 ? t("common.days") : t("common.day")}
|
||||
</span>
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
{new Date(blister.start).toLocaleTimeString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Status Section */}
|
||||
{medCoverage && status && (
|
||||
<div className="med-detail-section">
|
||||
<h3 className="section-header-with-badge">
|
||||
{t("modal.coverageStatus")}
|
||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||
</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.daysLeft")}</span>
|
||||
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.runsOut")}</span>
|
||||
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
{selectedMed.notes && (
|
||||
<div className="med-detail-section">
|
||||
<h3>📝 {t("modal.notes")}</h3>
|
||||
<div className="med-notes-content">{selectedMed.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refill History Section */}
|
||||
{refillHistory.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3 className="section-header-clickable" onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}>
|
||||
{t("refill.history")} ({refillHistory.length})
|
||||
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
|
||||
</h3>
|
||||
{refillHistoryExpanded && (
|
||||
<div className="refill-history-list">
|
||||
{refillHistory.map((entry) => (
|
||||
<div key={entry.id} className="refill-history-item">
|
||||
<span className="refill-date">
|
||||
{new Date(entry.refillDate).toLocaleDateString(i18n.language, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
,{" "}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="med-detail-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
<div className="footer-actions">
|
||||
<button className="success" onClick={onOpenRefillModal}>
|
||||
{t("refill.button")}
|
||||
</button>
|
||||
<button className="info" onClick={onOpenEditStockModal}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<button className="secondary icon-only" onClick={() => generateICS(selectedMed)} title={t("modal.exportTooltip")}>
|
||||
📅
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{showImageLightbox && selectedMed.imageUrl && (
|
||||
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
|
||||
)}
|
||||
|
||||
{/* Refill Modal */}
|
||||
{showRefillModal && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseRefillModal();
|
||||
}}
|
||||
>
|
||||
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onCloseRefillModal}>
|
||||
×
|
||||
</button>
|
||||
<h2>{t("refill.title")}</h2>
|
||||
<p className="refill-med-name">{selectedMed.name}</p>
|
||||
|
||||
<div className="refill-form">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseRefillModal}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div className="refill-footer-right">
|
||||
<button
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(selectedMed.id)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Stock Modal */}
|
||||
{showEditStockModal && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseEditStockModal();
|
||||
}}
|
||||
>
|
||||
<div className="modal-content edit-stock-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onCloseEditStockModal}>
|
||||
×
|
||||
</button>
|
||||
<h2>{t("editStock.title")}</h2>
|
||||
<p className="edit-stock-med-name">{selectedMed.name}</p>
|
||||
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
||||
|
||||
{(() => {
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
|
||||
const difference = newTotal - currentTotal;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="edit-stock-form">
|
||||
<label>
|
||||
{t("editStock.fullBlisters")} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editStockFullBlisters}
|
||||
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("editStock.partialBlisterPills")}
|
||||
<input
|
||||
type="number"
|
||||
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
|
||||
max={selectedMed.pillsPerBlister}
|
||||
value={editStockPartialBlisterPills}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const max = selectedMed.pillsPerBlister;
|
||||
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="edit-stock-summary">
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.currentTotal")}:</span>
|
||||
<span>
|
||||
{currentTotal} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.newTotal")}:</span>
|
||||
<span>
|
||||
{newTotal} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}>
|
||||
<span>{t("editStock.difference")}:</span>
|
||||
<span>
|
||||
{difference > 0 ? "+" : ""}
|
||||
{difference} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseEditStockModal}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="info"
|
||||
onClick={() => onSubmitStockCorrection(selectedMed.id)}
|
||||
disabled={editStockSaving}
|
||||
>
|
||||
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// =============================================================================
|
||||
// MedicationAvatar Component
|
||||
// =============================================================================
|
||||
|
||||
export type MedicationAvatarProps = {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
name: { max: 100 },
|
||||
genericName: { max: 100 },
|
||||
takenBy: { max: 50 },
|
||||
notes: { max: 1000 },
|
||||
};
|
||||
|
||||
export interface MobileEditModalProps {
|
||||
show: boolean;
|
||||
editingId: number | null;
|
||||
form: FormState;
|
||||
onFormChange: (form: FormState) => void;
|
||||
fieldErrors: FieldErrors;
|
||||
saving: boolean;
|
||||
formSaved: boolean;
|
||||
formChanged: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
// TakenBy tag input
|
||||
takenByInput: string;
|
||||
onTakenByInputChange: (value: string) => void;
|
||||
existingPeople: string[];
|
||||
onAddTakenByPerson: (person: string) => void;
|
||||
onRemoveTakenByPerson: (person: string) => void;
|
||||
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
// Blister helpers
|
||||
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
onAddBlister: () => void;
|
||||
onRemoveBlister: (idx: number) => void;
|
||||
// Value change handler for numeric fields
|
||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
||||
// Refill state (for edit mode)
|
||||
refillPacks: number;
|
||||
onRefillPacksChange: (value: number) => void;
|
||||
refillLoose: number;
|
||||
onRefillLooseChange: (value: number) => void;
|
||||
refillSaving: boolean;
|
||||
onSubmitRefill: (medId: number) => Promise<void>;
|
||||
// Image handling
|
||||
meds: Medication[];
|
||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||
// Actions
|
||||
onClose: () => void;
|
||||
onResetForm: () => void;
|
||||
onSaveMedication: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
function deriveTotal(form: FormState) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function MobileEditModal({
|
||||
show,
|
||||
editingId,
|
||||
form,
|
||||
onFormChange,
|
||||
fieldErrors,
|
||||
saving,
|
||||
formSaved,
|
||||
formChanged,
|
||||
hasValidationErrors,
|
||||
takenByInput,
|
||||
onTakenByInputChange,
|
||||
existingPeople,
|
||||
onAddTakenByPerson,
|
||||
onRemoveTakenByPerson,
|
||||
onTakenByKeyDown,
|
||||
onSetBlisterValue,
|
||||
onAddBlister,
|
||||
onRemoveBlister,
|
||||
onHandleValueChange,
|
||||
refillPacks,
|
||||
onRefillPacksChange,
|
||||
refillLoose,
|
||||
onRefillLooseChange,
|
||||
refillSaving,
|
||||
onSubmitRefill,
|
||||
meds,
|
||||
onUploadMedImage,
|
||||
onDeleteMedImage,
|
||||
onClose,
|
||||
onResetForm,
|
||||
onSaveMedication,
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form className="form-grid mobile-edit-form" onSubmit={onSaveMedication}>
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
{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>
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })} />
|
||||
</label>
|
||||
|
||||
{/* 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">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : 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) => 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={currentMed.imageUrl} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
</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")}
|
||||
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.intakeRemindersEnabled}
|
||||
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<span className="legend-hint">{t("form.blisters.remind")}</span>
|
||||
</legend>
|
||||
{form.blisters.map((b, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input type="number" min="0.5" step="0.5" value={b.usage} onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input type="number" min="1" value={b.every} onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input type="date" value={b.startDate} onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)} />
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input type="time" value={b.startTime} onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)} />
|
||||
</label>
|
||||
{form.blisters.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserProfile } from './Auth';
|
||||
|
||||
interface ProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<UserProfile onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ShareDialog - Modal for generating share links for medication schedules
|
||||
* Allows sharing schedule view for a specific person
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
show: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
onShareSelectedPersonChange: (person: string) => void;
|
||||
shareSelectedDays: number;
|
||||
onShareSelectedDaysChange: (days: number) => void;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
onShareLinkChange: (link: string | null) => void;
|
||||
shareCopied: boolean;
|
||||
onShareCopiedChange: (copied: boolean) => void;
|
||||
onClose: () => void;
|
||||
onGenerateShareLink: () => Promise<void>;
|
||||
onCopyShareLink: () => void;
|
||||
}
|
||||
|
||||
export function ShareDialog({
|
||||
show,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
onShareSelectedPersonChange,
|
||||
shareSelectedDays,
|
||||
onShareSelectedDaysChange,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
onShareLinkChange,
|
||||
shareCopied,
|
||||
onShareCopiedChange,
|
||||
onClose,
|
||||
onGenerateShareLink,
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="share-dialog-header">
|
||||
<h2>🔗 {t("share.title")}</h2>
|
||||
<p className="share-dialog-description">{t("share.description")}</p>
|
||||
</div>
|
||||
|
||||
{sharePeople.length === 0 ? (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t("share.noPeople")}</p>
|
||||
</div>
|
||||
) : shareLink ? (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button className="btn-copy" onClick={onCopyShareLink}>
|
||||
{shareCopied ? "✓" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
</button>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPerson")}</label>
|
||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPeriod")}</label>
|
||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
||||
<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="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
// =============================================================================
|
||||
// SharedSchedule Component - Public view for shared schedules
|
||||
// =============================================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SharedScheduleData, ExpiredLinkData } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
|
||||
export function SharedSchedule() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}
|
||||
|
||||
// Collapsed days state for SharedSchedule (token-specific localStorage)
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load collapsed/expanded state from localStorage
|
||||
useEffect(() => {
|
||||
if (token && typeof window !== "undefined") {
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
`share_${token}_collapsedDays`,
|
||||
`share_${token}_expandedDays`
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Toggle day collapse/expand for SharedSchedule
|
||||
function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
|
||||
if (isAutoCollapsed) {
|
||||
setManuallyExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setManuallyCollapsedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for lightbox with history support (mobile back swipe)
|
||||
function openLightbox(url: string, name: string) {
|
||||
setLightboxImage({ url, name });
|
||||
window.history.pushState({ modal: "lightbox" }, "");
|
||||
}
|
||||
function closeLightbox() {
|
||||
if (lightboxImage) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
// Close lightbox on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && lightboxImage) {
|
||||
closeLightbox();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [lightboxImage]);
|
||||
|
||||
// Handle browser back button to close lightbox
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
if (lightboxImage) {
|
||||
setLightboxImage(null);
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [lightboxImage]);
|
||||
|
||||
// Load taken doses from server with polling for real-time sync
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
async function loadTakenDoses() {
|
||||
try {
|
||||
const res = await fetch(`/api/share/${token}/doses`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
||||
} else {
|
||||
setTakenDoses(new Set());
|
||||
}
|
||||
} catch {
|
||||
setTakenDoses(new Set());
|
||||
}
|
||||
}
|
||||
loadTakenDoses();
|
||||
|
||||
// Poll for updates every 5 seconds (real-time sync with dashboard)
|
||||
const interval = setInterval(loadTakenDoses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Get dose ID with optional person suffix
|
||||
function getDoseId(baseDoseId: string, person: string | null): string {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}
|
||||
|
||||
// Count taken doses for a day/item
|
||||
function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } {
|
||||
let total = 0;
|
||||
let taken = 0;
|
||||
for (const d of doses) {
|
||||
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
|
||||
for (const person of people) {
|
||||
total++;
|
||||
if (takenDoses.has(getDoseId(d.id, person))) taken++;
|
||||
}
|
||||
}
|
||||
return { total, taken };
|
||||
}
|
||||
|
||||
async function markDoseTaken(doseId: string) {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/share/${token}/doses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doseId })
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function undoDoseTaken(doseId: string) {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/share/${token}/doses/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!token) {
|
||||
setError("Invalid link");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/share/${token}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} else if (res.status === 410) {
|
||||
// Link expired - get owner info
|
||||
const json = await res.json();
|
||||
setExpiredData({
|
||||
ownerUsername: json.ownerUsername,
|
||||
takenBy: json.takenBy,
|
||||
expiredAt: json.expiredAt
|
||||
});
|
||||
} else if (res.status === 404) {
|
||||
setError(t("share.notFound"));
|
||||
} else {
|
||||
setError(t("share.error"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("share.error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [token, t]);
|
||||
|
||||
// Build schedule from medications - matches buildSchedulePreview logic exactly
|
||||
const schedule = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
// Use same logic as buildSchedulePreview in main app
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
|
||||
|
||||
// Use 180 days horizon like main app (scheduleDays only limits futureDays display)
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 180);
|
||||
|
||||
const doses: {
|
||||
id: string;
|
||||
when: number;
|
||||
medName: string;
|
||||
usage: number;
|
||||
timeStr: string;
|
||||
isPast: boolean;
|
||||
takenBy: string[];
|
||||
dateStr: string;
|
||||
}[] = [];
|
||||
|
||||
for (const med of data.medications) {
|
||||
med.blisters.forEach((blister, blisterIdx) => {
|
||||
const startDate = new Date(blister.start);
|
||||
if (Number.isNaN(startDate.getTime())) return;
|
||||
|
||||
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
||||
// This ensures identical timestamps even across DST changes
|
||||
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
|
||||
const t = d.getTime();
|
||||
const isPast = d < todayStart;
|
||||
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
|
||||
const doseId = `${med.id}-${blisterIdx}-${t}`;
|
||||
doses.push({
|
||||
id: doseId,
|
||||
when: t,
|
||||
medName: med.name,
|
||||
usage: blister.usage,
|
||||
isPast,
|
||||
takenBy: med.takenBy || [],
|
||||
timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString(i18n.language, { weekday: "short", day: "2-digit", month: "short" })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doses.sort((a, b) => a.when - b.when);
|
||||
|
||||
// Group by date - matches groupedSchedule logic in main app
|
||||
type DoseInfo = (typeof doses)[number];
|
||||
const days = new Map<
|
||||
string,
|
||||
{
|
||||
dateStr: string;
|
||||
date: Date;
|
||||
isPast: boolean;
|
||||
meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const dose of doses.slice(0, 2000)) {
|
||||
const day = days.get(dose.dateStr) ?? { dateStr: dose.dateStr, date: new Date(dose.when), isPast: dose.isPast, meds: new Map() };
|
||||
const medEntry = day.meds.get(dose.medName) ?? { medName: dose.medName, total: 0, doses: [], lastWhen: dose.when };
|
||||
medEntry.total += dose.usage;
|
||||
medEntry.doses.push(dose);
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, dose.when);
|
||||
day.meds.set(dose.medName, medEntry);
|
||||
days.set(dose.dateStr, day);
|
||||
}
|
||||
|
||||
return Array.from(days.values()).map((d) => ({
|
||||
dateStr: d.dateStr,
|
||||
date: d.date,
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values())
|
||||
}));
|
||||
}, [data, i18n.language]);
|
||||
|
||||
// Split into past and future - matches main app logic
|
||||
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
|
||||
// Limit future days by scheduleDays setting (same as main app)
|
||||
const futureDays = useMemo(() => schedule.filter((d) => !d.isPast).slice(0, data?.scheduleDays ?? 30), [schedule, data?.scheduleDays]);
|
||||
|
||||
// Calculate coverage for stock status colors (matches main app logic)
|
||||
// This needs to account for taken doses and calculate depletion time
|
||||
const { coverageByMed, depletionByMed } = useMemo(() => {
|
||||
if (!data) return { coverageByMed: {}, depletionByMed: {} };
|
||||
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
|
||||
const depletion: Record<string, number | null> = {};
|
||||
|
||||
// Calculate total pills taken per medication from takenDoses
|
||||
// Each person's taken dose counts separately toward pills consumed
|
||||
const takenByMed: Record<string, number> = {};
|
||||
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
|
||||
// Check all person-specific dose IDs for this dose
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
for (const person of people) {
|
||||
const doseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
if (takenDoses.has(doseId)) {
|
||||
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const med of data.medications) {
|
||||
const totalCount = getMedTotal(med);
|
||||
const taken = takenByMed[med.name] || 0;
|
||||
const currentCount = Math.max(0, totalCount - taken);
|
||||
// Calculate daily usage from blisters, multiplied by number of people
|
||||
const personCount = Math.max(1, med.takenBy?.length || 1);
|
||||
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount;
|
||||
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
|
||||
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
|
||||
|
||||
// Calculate depletion time (when medication will run out)
|
||||
if (dailyUsage > 0 && currentCount > 0) {
|
||||
const daysUntilEmpty = currentCount / dailyUsage;
|
||||
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000;
|
||||
} else if (currentCount <= 0) {
|
||||
depletion[med.name] = Date.now(); // Already empty
|
||||
} else {
|
||||
depletion[med.name] = null; // No usage schedule
|
||||
}
|
||||
}
|
||||
return { coverageByMed: coverage, depletionByMed: depletion };
|
||||
}, [data, schedule, takenDoses]);
|
||||
|
||||
// Stock thresholds from user settings (provided by API) or defaults
|
||||
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30;
|
||||
|
||||
// Get worst stock status for a day's medications (matches main app logic with depletion)
|
||||
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => {
|
||||
const statuses = meds.map((item) => {
|
||||
const coverage = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
|
||||
// Will be out of stock by this day?
|
||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (!coverage) return "success";
|
||||
const { daysLeft, medsLeft } = coverage;
|
||||
|
||||
// Currently out of stock
|
||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
||||
// No schedule (can't calculate)
|
||||
if (daysLeft === null) return "success";
|
||||
// Low stock: < lowStockDays (warning)
|
||||
if (daysLeft < lowStockDays) return "warning";
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p>{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expiredData) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error expired">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<div className="expired-icon">⏰</div>
|
||||
<h2>{t("share.expired.title")}</h2>
|
||||
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
|
||||
<p className="expired-contact">{t("share.expired.contact", { username: expiredData.ownerUsername })}</p>
|
||||
<p className="expired-date">{t("share.expired.expiredOn", { date: new Date(expiredData.expiredAt).toLocaleDateString(i18n.language) })}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p className="error-message">{error || "Unknown error"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>
|
||||
💊 {t("share.scheduleFor")} {data.takenBy}
|
||||
</h1>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="shared-schedule-period">
|
||||
{t("share.period")}:{" "}
|
||||
{data.scheduleDays === 30
|
||||
? t("dashboard.schedules.1month")
|
||||
: data.scheduleDays === 90
|
||||
? t("dashboard.schedules.3months")
|
||||
: t("dashboard.schedules.6months")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="timeline">
|
||||
{schedule.length === 0 ? (
|
||||
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* 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>
|
||||
) : totalPastDoses.length > 0 ? (
|
||||
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
</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;
|
||||
|
||||
// Calculate stock status for this day
|
||||
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, 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 = data.medications.find((m) => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
// Calculate status for this medication on this day
|
||||
let status: { className: string; label: string } | null = null;
|
||||
if (willBeOutOfStock) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (medCoverage) {
|
||||
const { daysLeft, medsLeft } = medCoverage;
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (daysLeft !== null && daysLeft < lowStockDays) {
|
||||
status = { className: "warning", label: "status.lowStock" };
|
||||
} else {
|
||||
status = { className: "success", label: "status.normal" };
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<span
|
||||
className={med?.imageUrl ? "clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</span>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</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 allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${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">{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) => {
|
||||
// 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 stock status for this day
|
||||
const worstStatus = getDayStockStatus(day.meds);
|
||||
|
||||
// Check if this is today
|
||||
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" : ""} 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 med = data.medications.find((m) => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
// Calculate status for this medication on this day
|
||||
let status: { className: string; label: string } | null = null;
|
||||
if (willBeOutOfStock) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (medCoverage) {
|
||||
const { daysLeft, medsLeft } = medCoverage;
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
status = { className: "danger", label: "status.outOfStock" };
|
||||
} else if (daysLeft !== null && daysLeft < lowStockDays) {
|
||||
status = { className: "warning", label: "status.lowStock" };
|
||||
} else {
|
||||
status = { className: "success", label: "status.normal" };
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<span
|
||||
className={med?.imageUrl ? "clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</span>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</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 allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
|
||||
// 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();
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${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);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
|
||||
return (
|
||||
<div
|
||||
key={doseId}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{person && <span className="person-name">{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>
|
||||
|
||||
<footer className="shared-schedule-footer">
|
||||
<p>
|
||||
{t("share.generatedBy")}{" "}
|
||||
{data?.sharedBy && (
|
||||
<>
|
||||
<strong>{data.sharedBy}</strong> ·{" "}
|
||||
</>
|
||||
)}
|
||||
<a href="/">MedAssist</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={`/api/images/${lightboxImage.url}`}
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// =============================================================================
|
||||
// TagInput Component - Reusable tag input with suggestions
|
||||
// =============================================================================
|
||||
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
export interface TagInputProps {
|
||||
tags: string[];
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onAddTag: (tag: string) => void;
|
||||
onRemoveTag: (tag: string) => void;
|
||||
suggestions?: string[];
|
||||
placeholder?: string;
|
||||
addPlaceholder?: string;
|
||||
maxLength?: number;
|
||||
error?: string;
|
||||
datalistId?: string;
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
tags,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
suggestions = [],
|
||||
placeholder = "",
|
||||
addPlaceholder = "",
|
||||
maxLength,
|
||||
error,
|
||||
datalistId = "tag-suggestions"
|
||||
}: TagInputProps) {
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
onAddTag(inputValue);
|
||||
}
|
||||
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
||||
onRemoveTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tag-input-container">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
<button type="button" className="tag-remove" onClick={() => onRemoveTag(tag)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (inputValue.trim()) onAddTag(inputValue);
|
||||
}}
|
||||
placeholder={tags.length === 0 ? placeholder : addPlaceholder}
|
||||
maxLength={maxLength}
|
||||
list={datalistId}
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<datalist id={datalistId}>
|
||||
{suggestions
|
||||
.filter((s) => !tags.includes(s))
|
||||
.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
{error && <span className="field-error">{error}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* UserFilterModal - Shows medications for a specific person (takenBy filter)
|
||||
* Allows clicking through to medication details
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, StockThresholds } from "../types";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
selectedUser: string | null;
|
||||
meds: Medication[];
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
export function UserFilterModal({
|
||||
selectedUser,
|
||||
meds,
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="user-meds-header">
|
||||
<div className="user-avatar">{selectedUser.charAt(0).toUpperCase()}</div>
|
||||
<h2>{t("modal.userMedications", { name: selectedUser })}</h2>
|
||||
</div>
|
||||
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
|
||||
</span>
|
||||
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{userMeds.length === 0 && (
|
||||
<div className="user-meds-empty">{t("modal.noMedsForUser", { name: selectedUser })}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="user-meds-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Components barrel export
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
|
||||
export { SharedSchedule } from "./SharedSchedule";
|
||||
|
||||
export { TagInput } from "./TagInput";
|
||||
export type { TagInputProps } from "./TagInput";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
|
||||
export { UserFilterModal } from "./UserFilterModal";
|
||||
export type { UserFilterModalProps } from "./UserFilterModal";
|
||||
|
||||
export { ShareDialog } from "./ShareDialog";
|
||||
export type { ShareDialogProps } from "./ShareDialog";
|
||||
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
|
||||
export { default as ProfileModal } from "./ProfileModal";
|
||||
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, { createContext, useContext, useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import {
|
||||
useDoses,
|
||||
useCollapsedDays,
|
||||
useSettings,
|
||||
useShare,
|
||||
useMedications,
|
||||
useRefill,
|
||||
} from "../hooks";
|
||||
import type {
|
||||
Medication,
|
||||
Coverage,
|
||||
ScheduleEvent,
|
||||
} from "../types";
|
||||
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type DoseInfo = {
|
||||
id: string;
|
||||
timeStr: string;
|
||||
when: number;
|
||||
usage: number;
|
||||
takenBy: string[];
|
||||
};
|
||||
|
||||
export type DayMedEntry = {
|
||||
medName: string;
|
||||
total: number;
|
||||
doses: DoseInfo[];
|
||||
lastWhen: number;
|
||||
};
|
||||
|
||||
export type GroupedDay = {
|
||||
dateStr: string;
|
||||
date: Date;
|
||||
isPast: boolean;
|
||||
meds: DayMedEntry[];
|
||||
};
|
||||
|
||||
export interface AppContextValue {
|
||||
// From useMedications
|
||||
meds: Medication[];
|
||||
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
deleteMedImage: (medId: number) => Promise<void>;
|
||||
|
||||
// From useSettings (selected fields)
|
||||
settings: ReturnType<typeof useSettings>["settings"];
|
||||
setSettings: ReturnType<typeof useSettings>["setSettings"];
|
||||
savedSettings: ReturnType<typeof useSettings>["savedSettings"];
|
||||
settingsLoading: boolean;
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
testEmailResult: { success: boolean; message: string } | null;
|
||||
testingShoutrrr: boolean;
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
|
||||
// From useDoses
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
toggleDayCollapse: (dateStr: string, isCurrentlyExpanded: boolean) => void;
|
||||
|
||||
// From useShare
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: number;
|
||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openShareDialog: () => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
|
||||
// From useRefill
|
||||
showRefillModal: boolean;
|
||||
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillPacks: number;
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: ReturnType<typeof useRefill>["refillHistory"];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editStockFullBlisters: number;
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (medId: number, editingId: number | null, setForm: React.Dispatch<React.SetStateAction<any>>, loadMeds: () => void) => Promise<void>;
|
||||
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
closeEditStockModal: () => void;
|
||||
|
||||
// Computed values
|
||||
schedule: { events: ScheduleEvent[] };
|
||||
coverage: { all: Coverage[]; low: Coverage[] };
|
||||
coverageByMed: Record<string, Coverage>;
|
||||
depletionByMed: Record<string, number | null>;
|
||||
existingPeople: string[];
|
||||
groupedSchedule: GroupedDay[];
|
||||
pastDays: GroupedDay[];
|
||||
futureDays: GroupedDay[];
|
||||
missedPastDoseIds: string[];
|
||||
getDayStockStatus: (dayMeds: { medName: string; lastWhen: number }[]) => "success" | "warning" | "danger";
|
||||
|
||||
// Schedule UI state
|
||||
scheduleDays: number;
|
||||
setScheduleDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
showPastDays: boolean;
|
||||
setShowPastDays: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Modal state
|
||||
selectedMed: Medication | null;
|
||||
setSelectedMed: React.Dispatch<React.SetStateAction<Medication | null>>;
|
||||
showImageLightbox: boolean;
|
||||
setShowImageLightbox: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
scheduleLightboxImage: string | null;
|
||||
setScheduleLightboxImage: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
selectedUser: string | null;
|
||||
setSelectedUser: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
|
||||
// Export/Import state
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
showExportModal: boolean;
|
||||
setShowExportModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showImportConfirm: boolean;
|
||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
pendingImportData: unknown;
|
||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||
importResult: { medications: number; doses: number; shares: number } | null;
|
||||
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
|
||||
handleExport: (includeImages?: boolean) => Promise<void>;
|
||||
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleImportConfirm: () => Promise<void>;
|
||||
settingsChanged: boolean;
|
||||
|
||||
// Modal helpers
|
||||
openMedDetail: (med: Medication) => void;
|
||||
closeMedDetail: () => void;
|
||||
openImageLightbox: () => void;
|
||||
closeImageLightbox: () => void;
|
||||
openScheduleLightbox: (imageUrl: string) => void;
|
||||
closeScheduleLightbox: () => void;
|
||||
openUserFilter: (person: string) => void;
|
||||
closeUserFilter: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider
|
||||
// =============================================================================
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Compose hooks
|
||||
const medications = useMedications();
|
||||
const settingsHook = useSettings();
|
||||
const doses = useDoses();
|
||||
const collapsed = useCollapsedDays(user?.id);
|
||||
const share = useShare();
|
||||
const refill = useRefill();
|
||||
|
||||
// Schedule UI state
|
||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
|
||||
// Modal state
|
||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
|
||||
// Export/Import state
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
|
||||
|
||||
// Load user-specific scheduleDays when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays"));
|
||||
setScheduleDays(storedDays ? Number(storedDays) : 30);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Load medications and settings when user changes
|
||||
useEffect(() => {
|
||||
medications.loadMeds();
|
||||
settingsHook.loadSettings();
|
||||
}, [user?.id]);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
if (selectedMed) {
|
||||
const updated = medications.meds.find(m => m.id === selectedMed.id);
|
||||
if (updated && (
|
||||
updated.packCount !== selectedMed.packCount ||
|
||||
updated.looseTablets !== selectedMed.looseTablets ||
|
||||
updated.updatedAt !== selectedMed.updatedAt
|
||||
)) {
|
||||
setSelectedMed(updated);
|
||||
}
|
||||
}
|
||||
}, [medications.meds, selectedMed]);
|
||||
|
||||
// Computed values
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, i18n.language, true),
|
||||
[medications.meds, i18n.language]
|
||||
);
|
||||
|
||||
const coverage = useMemo(
|
||||
() => calculateCoverage(
|
||||
medications.meds,
|
||||
schedule.events,
|
||||
i18n.language,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
settingsHook.settings.stockCalculationMode,
|
||||
doses.takenDoses
|
||||
),
|
||||
[medications.meds, schedule.events, i18n.language, settingsHook.settings.reminderDaysBefore, settingsHook.settings.stockCalculationMode, doses.takenDoses]
|
||||
);
|
||||
|
||||
const depletionByMed = useMemo(
|
||||
() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])),
|
||||
[coverage.all]
|
||||
);
|
||||
|
||||
const coverageByMed = useMemo(
|
||||
() => Object.fromEntries(coverage.all.map((c) => [c.name, c])),
|
||||
[coverage.all]
|
||||
);
|
||||
|
||||
const existingPeople = useMemo(() => {
|
||||
const allPeople = medications.meds.flatMap(m => m.takenBy || []);
|
||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
||||
}, [medications.meds]);
|
||||
|
||||
// Get worst stock status for a day's medications
|
||||
const getDayStockStatus = useCallback((dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
||||
const statuses = dayMeds.map((item) => {
|
||||
const cov = coverageByMed[item.medName];
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
|
||||
// Will be out of stock by this day?
|
||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
|
||||
return "danger";
|
||||
}
|
||||
|
||||
if (!cov) return "success";
|
||||
const { daysLeft, medsLeft } = cov;
|
||||
|
||||
// Currently out of stock
|
||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
||||
// No schedule (can't calculate)
|
||||
if (daysLeft === null) return "success";
|
||||
// Low stock: < lowStockDays (warning)
|
||||
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
}, [coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]);
|
||||
|
||||
const groupedSchedule = useMemo(() => {
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
|
||||
schedule.events.slice(0, 2000).forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), isPast: event.isPast, meds: new Map() };
|
||||
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when };
|
||||
medEntry.total += event.usage;
|
||||
medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage, takenBy: event.takenBy || [] });
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, isPast: d.isPast, meds: Array.from(d.meds.values()) }));
|
||||
}, [schedule.events]);
|
||||
|
||||
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
||||
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(() => {
|
||||
const totalPastDoses = pastDays.flatMap(d =>
|
||||
d.meds.flatMap(m =>
|
||||
m.doses.flatMap(dose =>
|
||||
(dose.takenBy || []).length > 0
|
||||
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
|
||||
: [dose.id]
|
||||
)
|
||||
)
|
||||
);
|
||||
return totalPastDoses.filter(id => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
|
||||
}, [pastDays, doses.takenDoses, doses.dismissedDoses]);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
const openMedDetail = useCallback((med: Medication) => {
|
||||
setSelectedMed(med);
|
||||
refill.setRefillHistoryExpanded(false);
|
||||
refill.loadRefillHistory(med.id);
|
||||
window.history.pushState({ modal: 'medDetail', medId: med.id }, '');
|
||||
}, [refill]);
|
||||
|
||||
const closeMedDetail = useCallback(() => {
|
||||
if (selectedMed) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [selectedMed]);
|
||||
|
||||
const openImageLightbox = useCallback(() => {
|
||||
setShowImageLightbox(true);
|
||||
window.history.pushState({ modal: 'imageLightbox' }, '');
|
||||
}, []);
|
||||
|
||||
const closeImageLightbox = useCallback(() => {
|
||||
if (showImageLightbox) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showImageLightbox]);
|
||||
|
||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
||||
setScheduleLightboxImage(imageUrl);
|
||||
window.history.pushState({ modal: 'scheduleLightbox' }, '');
|
||||
}, []);
|
||||
|
||||
const closeScheduleLightbox = useCallback(() => {
|
||||
if (scheduleLightboxImage) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [scheduleLightboxImage]);
|
||||
|
||||
const openUserFilter = useCallback((person: string) => {
|
||||
setSelectedUser(person);
|
||||
window.history.pushState({ modal: 'userFilter', person }, '');
|
||||
}, []);
|
||||
|
||||
const closeUserFilter = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Export data to JSON file
|
||||
const handleExport = useCallback(async (includeImages: boolean = true) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const data = await res.json();
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
a.href = url;
|
||||
a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export error:", err);
|
||||
}
|
||||
setExporting(false);
|
||||
}, [t]);
|
||||
|
||||
// Handle file selection for import
|
||||
const handleImportFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (!data.version || !data.exportedAt) {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
return;
|
||||
}
|
||||
setPendingImportData(data);
|
||||
setShowImportConfirm(true);
|
||||
} catch {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input
|
||||
e.target.value = "";
|
||||
}, [t]);
|
||||
|
||||
// Confirm and execute import
|
||||
const handleImportConfirm = useCallback(async () => {
|
||||
if (!pendingImportData) return;
|
||||
setImporting(true);
|
||||
setShowImportConfirm(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(pendingImportData),
|
||||
});
|
||||
|
||||
// Get the response text first to handle non-JSON responses
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
console.error("Import response parse error:", text);
|
||||
alert(t('exportImport.importError') + ": Server returned invalid response");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
alert(t('exportImport.importError') + ": " + (data.error || `HTTP ${res.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show success message in UI instead of browser alert
|
||||
setImportResult({
|
||||
medications: data.imported?.medications || 0,
|
||||
doses: data.imported?.doseHistory || 0,
|
||||
shares: data.imported?.shareLinks || 0,
|
||||
});
|
||||
|
||||
// Reload all data
|
||||
medications.loadMeds();
|
||||
settingsHook.loadSettings();
|
||||
doses.loadTakenDoses();
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
alert(t('exportImport.importError'));
|
||||
}
|
||||
|
||||
setPendingImportData(null);
|
||||
setImporting(false);
|
||||
}, [pendingImportData, t, medications, settingsHook, doses]);
|
||||
|
||||
// Compute settingsChanged
|
||||
const settingsChanged = useMemo(() => {
|
||||
const settings = settingsHook.settings;
|
||||
const savedSettings = settingsHook.savedSettings;
|
||||
return settings.emailEnabled !== savedSettings.emailEnabled ||
|
||||
settings.notificationEmail !== savedSettings.notificationEmail ||
|
||||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
|
||||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
|
||||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
|
||||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
|
||||
settings.lowStockDays !== savedSettings.lowStockDays ||
|
||||
settings.normalStockDays !== savedSettings.normalStockDays ||
|
||||
settings.highStockDays !== savedSettings.highStockDays ||
|
||||
settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
|
||||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl ||
|
||||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
|
||||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
|
||||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
|
||||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
|
||||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
|
||||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
||||
settings.stockCalculationMode !== savedSettings.stockCalculationMode;
|
||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||
|
||||
// Build context value
|
||||
const value: AppContextValue = useMemo(() => ({
|
||||
// From useMedications
|
||||
...medications,
|
||||
|
||||
// From useSettings
|
||||
settings: settingsHook.settings,
|
||||
setSettings: settingsHook.setSettings,
|
||||
savedSettings: settingsHook.savedSettings,
|
||||
settingsLoading: settingsHook.settingsLoading,
|
||||
settingsSaving: settingsHook.settingsSaving,
|
||||
settingsSaved: settingsHook.settingsSaved,
|
||||
testingEmail: settingsHook.testingEmail,
|
||||
testEmailResult: settingsHook.testEmailResult,
|
||||
testingShoutrrr: settingsHook.testingShoutrrr,
|
||||
testShoutrrrResult: settingsHook.testShoutrrrResult,
|
||||
loadSettings: settingsHook.loadSettings,
|
||||
saveSettings: settingsHook.saveSettings,
|
||||
testEmail: settingsHook.testEmail,
|
||||
testShoutrrr: settingsHook.testShoutrrr,
|
||||
|
||||
// From useDoses
|
||||
takenDoses: doses.takenDoses,
|
||||
setTakenDoses: doses.setTakenDoses,
|
||||
dismissedDoses: doses.dismissedDoses,
|
||||
clearingMissed: doses.clearingMissed,
|
||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||
getDoseId: doses.getDoseId,
|
||||
countTakenDoses: doses.countTakenDoses,
|
||||
markDoseTaken: doses.markDoseTaken,
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
dismissMissedDoses: doses.dismissMissedDoses,
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||
manuallyExpandedDays: collapsed.manuallyExpandedDays,
|
||||
toggleDayCollapse: collapsed.toggleDayCollapse,
|
||||
|
||||
// From useShare
|
||||
showShareDialog: share.showShareDialog,
|
||||
sharePeople: share.sharePeople,
|
||||
shareSelectedPerson: share.shareSelectedPerson,
|
||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||
shareSelectedDays: share.shareSelectedDays,
|
||||
setShareSelectedDays: share.setShareSelectedDays,
|
||||
shareGenerating: share.shareGenerating,
|
||||
shareLink: share.shareLink,
|
||||
setShareLink: share.setShareLink,
|
||||
shareCopied: share.shareCopied,
|
||||
setShareCopied: share.setShareCopied,
|
||||
openShareDialog,
|
||||
generateShareLink: share.generateShareLink,
|
||||
copyShareLink: share.copyShareLink,
|
||||
closeShareDialog: share.closeShareDialog,
|
||||
resetShareDialogState: share.resetShareDialogState,
|
||||
|
||||
// From useRefill
|
||||
showRefillModal: refill.showRefillModal,
|
||||
setShowRefillModal: refill.setShowRefillModal,
|
||||
refillPacks: refill.refillPacks,
|
||||
setRefillPacks: refill.setRefillPacks,
|
||||
refillLoose: refill.refillLoose,
|
||||
setRefillLoose: refill.setRefillLoose,
|
||||
refillSaving: refill.refillSaving,
|
||||
refillHistory: refill.refillHistory,
|
||||
refillHistoryExpanded: refill.refillHistoryExpanded,
|
||||
setRefillHistoryExpanded: refill.setRefillHistoryExpanded,
|
||||
showEditStockModal: refill.showEditStockModal,
|
||||
setShowEditStockModal: refill.setShowEditStockModal,
|
||||
editStockFullBlisters: refill.editStockFullBlisters,
|
||||
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
||||
editStockSaving: refill.editStockSaving,
|
||||
loadRefillHistory: refill.loadRefillHistory,
|
||||
submitRefill: refill.submitRefill,
|
||||
submitStockCorrection: refill.submitStockCorrection,
|
||||
openRefillModal: refill.openRefillModal,
|
||||
closeRefillModal: refill.closeRefillModal,
|
||||
openEditStockModal: refill.openEditStockModal,
|
||||
closeEditStockModal: refill.closeEditStockModal,
|
||||
|
||||
// Computed values
|
||||
schedule,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
existingPeople,
|
||||
groupedSchedule,
|
||||
pastDays,
|
||||
futureDays,
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
|
||||
// Schedule UI state
|
||||
scheduleDays,
|
||||
setScheduleDays,
|
||||
showPastDays,
|
||||
setShowPastDays,
|
||||
|
||||
// Modal state
|
||||
selectedMed,
|
||||
setSelectedMed,
|
||||
showImageLightbox,
|
||||
setShowImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
setScheduleLightboxImage,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
|
||||
// Modal helpers
|
||||
openMedDetail,
|
||||
closeMedDetail,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
openScheduleLightbox,
|
||||
closeScheduleLightbox,
|
||||
openUserFilter,
|
||||
closeUserFilter,
|
||||
|
||||
// Export/Import
|
||||
exporting,
|
||||
importing,
|
||||
showExportModal,
|
||||
setShowExportModal,
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
importResult,
|
||||
setImportResult,
|
||||
handleExport,
|
||||
handleImportFileSelect,
|
||||
handleImportConfirm,
|
||||
settingsChanged,
|
||||
}), [
|
||||
medications,
|
||||
settingsHook,
|
||||
doses,
|
||||
collapsed,
|
||||
share,
|
||||
refill,
|
||||
schedule,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
existingPeople,
|
||||
groupedSchedule,
|
||||
pastDays,
|
||||
futureDays,
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
scheduleDays,
|
||||
showPastDays,
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
openMedDetail,
|
||||
closeMedDetail,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
openScheduleLightbox,
|
||||
closeScheduleLightbox,
|
||||
openUserFilter,
|
||||
closeUserFilter,
|
||||
openShareDialog,
|
||||
exporting,
|
||||
importing,
|
||||
showExportModal,
|
||||
showImportConfirm,
|
||||
pendingImportData,
|
||||
importResult,
|
||||
handleExport,
|
||||
handleImportFileSelect,
|
||||
handleImportConfirm,
|
||||
settingsChanged,
|
||||
]);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error("useAppContext must be used within an AppProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Context barrel export
|
||||
export { AppProvider, useAppContext } from "./AppContext";
|
||||
export type { AppContextValue, DoseInfo, DayMedEntry, GroupedDay } from "./AppContext";
|
||||
@@ -0,0 +1,17 @@
|
||||
// Hooks barrel export
|
||||
export { useDoses } from "./useDoses";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useTheme } from "./useTheme";
|
||||
export type { Theme, UseThemeReturn } from "./useTheme";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useShare } from "./useShare";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
export { useMedications } from "./useMedications";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedicationForm, defaultBlister, defaultForm } from "./useMedicationForm";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
@@ -0,0 +1,67 @@
|
||||
// =============================================================================
|
||||
// useCollapsedDays Hook - Day collapse/expand state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { loadCollapsedDaysFromStorage, userStorageKey } from "../utils/storage";
|
||||
|
||||
export interface UseCollapsedDaysReturn {
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
toggleDayCollapse: (dateStr: string, isAutoCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysReturn {
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load collapsed/expanded state from localStorage when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && userId) {
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
userStorageKey(userId, "collapsedDays"),
|
||||
userStorageKey(userId, "expandedDays")
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Toggle day collapse/expand
|
||||
const toggleDayCollapse = useCallback(
|
||||
(dateStr: string, isAutoCollapsed: boolean) => {
|
||||
if (isAutoCollapsed) {
|
||||
// Day is auto-collapsed (all taken) - toggle the expanded override
|
||||
setManuallyExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (userId) localStorage.setItem(userStorageKey(userId, "expandedDays"), JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Day is not auto-collapsed - toggle manual collapse
|
||||
setManuallyCollapsedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dateStr)) {
|
||||
next.delete(dateStr);
|
||||
} else {
|
||||
next.add(dateStr);
|
||||
}
|
||||
if (userId) localStorage.setItem(userStorageKey(userId, "collapsedDays"), JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
return {
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// =============================================================================
|
||||
// useDoses Hook - Dose tracking state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
loadTakenDoses: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
// Load taken doses from server
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const taken = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses) {
|
||||
if (d.dismissed) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setDismissedDoses(dismissed);
|
||||
}
|
||||
// Don't reset on error - keep current state
|
||||
} catch {
|
||||
// Don't reset on error - keep current state
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll for taken doses from server (works with or without auth)
|
||||
useEffect(() => {
|
||||
loadTakenDoses();
|
||||
|
||||
// Poll for updates every 5 seconds (real-time sync with share links)
|
||||
const interval = setInterval(loadTakenDoses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadTakenDoses]);
|
||||
|
||||
// Get dose ID with optional person suffix
|
||||
const getDoseId = useCallback((baseDoseId: string, person: string | null): string => {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}, []);
|
||||
|
||||
// Count taken doses for a day/item
|
||||
const countTakenDoses = useCallback(
|
||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||
let total = 0;
|
||||
let taken = 0;
|
||||
for (const d of doses) {
|
||||
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
|
||||
for (const person of people) {
|
||||
total++;
|
||||
if (takenDoses.has(getDoseId(d.id, person))) taken++;
|
||||
}
|
||||
}
|
||||
return { total, taken };
|
||||
},
|
||||
[takenDoses, getDoseId]
|
||||
);
|
||||
|
||||
const markDoseTaken = useCallback(async (doseId: string) => {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch("/api/doses/taken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId })
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undoDoseTaken = useCallback(async (doseId: string) => {
|
||||
// Optimistic update
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include"
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dismiss missed doses without deducting from stock
|
||||
const dismissMissedDoses = useCallback(async (doseIds: string[]) => {
|
||||
if (doseIds.length === 0) return;
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/doses/dismiss", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseIds })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state - move these from neither set to dismissed set
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of doseIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setShowClearMissedConfirm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error - dialog stays open
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
takenDoses,
|
||||
setTakenDoses,
|
||||
dismissedDoses,
|
||||
clearingMissed,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
getDoseId,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
dismissMissedDoses,
|
||||
loadTakenDoses
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
import { FIELD_LIMITS } from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
return {
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now)
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultForm = (): FormState => ({
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [defaultBlister()]
|
||||
});
|
||||
|
||||
export interface UseMedicationFormReturn {
|
||||
form: FormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
originalForm: FormState;
|
||||
editingId: number | null;
|
||||
setEditingId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
showEditModal: boolean;
|
||||
setShowEditModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fieldErrors: FieldErrors;
|
||||
formSaved: boolean;
|
||||
setFormSaved: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
hasValidationErrors: boolean;
|
||||
formChanged: boolean;
|
||||
pendingImage: File | null;
|
||||
setPendingImage: React.Dispatch<React.SetStateAction<File | null>>;
|
||||
pendingImagePreview: string | null;
|
||||
setPendingImagePreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
takenByInput: string;
|
||||
setTakenByInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
validateField: (field: keyof FieldErrors, value: string | string[]) => string | undefined;
|
||||
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
resetForm: () => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||
addTakenByPerson: (name: string) => void;
|
||||
removeTakenByPerson: (name: string) => void;
|
||||
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(defaultForm());
|
||||
const [originalForm, setOriginalForm] = useState<FormState>(defaultForm());
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [formSaved, setFormSaved] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
|
||||
// Validate form fields
|
||||
const validateField = useCallback((field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
const limits = FIELD_LIMITS[field];
|
||||
// Skip validation for takenBy array (individual items validated on add)
|
||||
if (field === 'takenBy') return undefined;
|
||||
const strValue = typeof value === 'string' ? value : '';
|
||||
if (field === 'name' && (!strValue || strValue.trim().length === 0)) {
|
||||
return t('common.validation.required');
|
||||
}
|
||||
if ('max' in limits && strValue.length > limits.max) {
|
||||
return t('common.validation.maxLength', { max: limits.max, current: strValue.length });
|
||||
}
|
||||
return undefined;
|
||||
}, [t]);
|
||||
|
||||
// Check if form has any errors
|
||||
const hasValidationErrors = useMemo(() => {
|
||||
return Object.values(fieldErrors).some(error => error !== undefined);
|
||||
}, [fieldErrors]);
|
||||
|
||||
// Check if form has been modified from original state
|
||||
const formChanged = useMemo(() => {
|
||||
return JSON.stringify(form) !== JSON.stringify(originalForm);
|
||||
}, [form, originalForm]);
|
||||
|
||||
// Reset formSaved when form changes
|
||||
useEffect(() => {
|
||||
if (formChanged) {
|
||||
setFormSaved(false);
|
||||
}
|
||||
}, [formChanged]);
|
||||
|
||||
// Validate all fields when form changes
|
||||
useEffect(() => {
|
||||
const errors: FieldErrors = {};
|
||||
(['name', 'genericName', 'notes'] as const).forEach(field => {
|
||||
const error = validateField(field, form[field]);
|
||||
if (error) errors[field] = error;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}, [form.name, form.genericName, form.notes, validateField]);
|
||||
|
||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.blisters];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, blisters: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addBlister = useCallback(() => {
|
||||
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
|
||||
}, []);
|
||||
|
||||
const removeBlister = useCallback((idx: number) => {
|
||||
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
|
||||
setEditingId(med.id);
|
||||
setTakenByInput(""); // Clear tag input when starting edit
|
||||
setFormSaved(false);
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
takenBy: med.takenBy || [], // Already an array from API
|
||||
packCount: String(med.packCount),
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
blisters: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start)
|
||||
})),
|
||||
};
|
||||
setForm(editForm);
|
||||
setOriginalForm(editForm);
|
||||
// Show modal on mobile
|
||||
if (window.innerWidth <= 768) {
|
||||
openEditModal();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setShowEditModal(false);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
setTakenByInput("");
|
||||
setFormSaved(false);
|
||||
const newForm = defaultForm();
|
||||
setForm(newForm);
|
||||
setOriginalForm(newForm);
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
const addTakenByPerson = useCallback((name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
||||
setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
}, [form.takenBy]);
|
||||
|
||||
const removeTakenByPerson = useCallback((name: string) => {
|
||||
setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
|
||||
}, []);
|
||||
|
||||
const handleTakenByKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTakenByPerson(takenByInput);
|
||||
} else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
|
||||
}
|
||||
}, [takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]);
|
||||
|
||||
return {
|
||||
form,
|
||||
setForm,
|
||||
originalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
fieldErrors,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
hasValidationErrors,
|
||||
formChanged,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
pendingImagePreview,
|
||||
setPendingImagePreview,
|
||||
takenByInput,
|
||||
setTakenByInput,
|
||||
validateField,
|
||||
setBlisterValue,
|
||||
addBlister,
|
||||
removeBlister,
|
||||
startEdit,
|
||||
resetForm,
|
||||
handleValueChange,
|
||||
addTakenByPerson,
|
||||
removeTakenByPerson,
|
||||
handleTakenByKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseMedicationsReturn {
|
||||
meds: Medication[];
|
||||
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
uploadingImage: boolean;
|
||||
loadMeds: () => void;
|
||||
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
|
||||
uploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||
deleteMedImage: (medId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useMedications(): UseMedicationsReturn {
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const deleteMed = useCallback(async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
const uploadMedImage = useCallback(async (medId: number, file: File) => {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}, [loadMeds]);
|
||||
|
||||
const deleteMedImage = useCallback(async (medId: number) => {
|
||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
|
||||
return {
|
||||
meds,
|
||||
setMeds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
uploadingImage,
|
||||
loadMeds,
|
||||
deleteMed,
|
||||
uploadMedImage,
|
||||
deleteMedImage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication, RefillEntry, Coverage, FormState } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
export interface UseRefillReturn {
|
||||
// Refill state
|
||||
showRefillModal: boolean;
|
||||
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refillPacks: number;
|
||||
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillLoose: number;
|
||||
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
|
||||
refillSaving: boolean;
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Edit stock (correction) state
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
editStockFullBlisters: number;
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
|
||||
// Actions
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
closeEditStockModal: () => void;
|
||||
}
|
||||
|
||||
export function useRefill(): UseRefillReturn {
|
||||
// Refill state
|
||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||
const [refillPacks, setRefillPacks] = useState(1);
|
||||
const [refillLoose, setRefillLoose] = useState(0);
|
||||
const [refillSaving, setRefillSaving] = useState(false);
|
||||
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||
|
||||
// Edit stock (correction) state
|
||||
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
|
||||
} else {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
} catch {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Submit a refill
|
||||
const submitRefill = useCallback(async (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Update form values if we're in edit mode
|
||||
if (editingId === medId && data.newStock) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
packCount: String(data.newStock.packCount),
|
||||
looseTablets: String(data.newStock.looseTablets),
|
||||
}));
|
||||
}
|
||||
// Reset refill form
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
// Close refill modal via history back for proper back-button support
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
// Reload refill history
|
||||
await loadRefillHistory(medId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRefillSaving(false);
|
||||
}, [refillPacks, refillLoose, showRefillModal, loadRefillHistory]);
|
||||
|
||||
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||
const submitStockCorrection = useCallback(async (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
// Auto-convert: handle full blister and negative partial blister
|
||||
let finalFullBlisters = editStockFullBlisters;
|
||||
let finalPartialPills = editStockPartialBlisterPills;
|
||||
|
||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||
finalFullBlisters += 1;
|
||||
finalPartialPills = 0;
|
||||
}
|
||||
|
||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||
finalFullBlisters -= 1;
|
||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||
}
|
||||
|
||||
// Ensure we don't go negative
|
||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||
|
||||
// What the user says they have RIGHT NOW = the new DB total
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Close edit stock modal via history back
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setEditStockSaving(false);
|
||||
}, [editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
setShowRefillModal(true);
|
||||
window.history.pushState({ modal: 'refill' }, '');
|
||||
}, []);
|
||||
|
||||
const closeRefillModal = useCallback(() => {
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showRefillModal]);
|
||||
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
if (!selectedMed) return;
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
|
||||
// Simply divide into full blisters and partial
|
||||
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
||||
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
||||
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: 'editStock' }, '');
|
||||
}, []);
|
||||
|
||||
const closeEditStockModal = useCallback(() => {
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showEditStockModal]);
|
||||
|
||||
return {
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
refillPacks,
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
setRefillHistoryExpanded,
|
||||
showEditStockModal,
|
||||
setShowEditStockModal,
|
||||
editStockFullBlisters,
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockSaving,
|
||||
loadRefillHistory,
|
||||
submitRefill,
|
||||
submitStockCorrection,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
closeEditStockModal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// =============================================================================
|
||||
// useSettings Hook - Settings state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface Settings {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPass: string;
|
||||
smtpFrom: string;
|
||||
smtpSecure: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
lastAutoEmailSent: string | null;
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
expiryWarningDays: number;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
smtpHost: "",
|
||||
smtpPort: 587,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
smtpFrom: "",
|
||||
smtpSecure: false,
|
||||
hasSmtpPassword: false,
|
||||
lastAutoEmailSent: null,
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
expiryWarningDays: 30
|
||||
};
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
||||
savedSettings: Settings;
|
||||
settingsLoading: boolean;
|
||||
settingsSaving: boolean;
|
||||
settingsSaved: boolean;
|
||||
testingEmail: boolean;
|
||||
testEmailResult: { success: boolean; message: string } | null;
|
||||
setTestEmailResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
|
||||
testingShoutrrr: boolean;
|
||||
testShoutrrrResult: { success: boolean; message: string } | null;
|
||||
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
|
||||
loadSettings: () => void;
|
||||
saveSettings: (e: React.FormEvent) => Promise<void>;
|
||||
testEmail: () => Promise<void>;
|
||||
testShoutrrr: () => Promise<void>;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const { i18n } = useTranslation();
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
|
||||
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Load settings function - exposed for manual refresh (e.g., after auth)
|
||||
const loadSettings = useCallback(() => {
|
||||
setSettingsLoading(true);
|
||||
fetch("/api/settings", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
.then((data) => {
|
||||
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
|
||||
setSettings(newSettings);
|
||||
setSavedSettings(newSettings);
|
||||
setSettingsSaved(false);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Auto-disable email if no recipient is set
|
||||
const effectiveEmailEnabled = settings.emailEnabled && !!settings.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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
language: i18n.language,
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
smtpUser: settings.smtpUser,
|
||||
smtpPass: settings.smtpPass || undefined,
|
||||
smtpFrom: settings.smtpFrom,
|
||||
smtpSecure: settings.smtpSecure
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled
|
||||
};
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(updatedSettings);
|
||||
setSettingsSaved(true);
|
||||
},
|
||||
[settings, i18n.language]
|
||||
);
|
||||
|
||||
const testEmail = useCallback(async () => {
|
||||
setTestingEmail(true);
|
||||
setTestEmailResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: settings.notificationEmail })
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestEmailResult({ success: res.ok, message: data.message || (res.ok ? "Email sent!" : "Failed to send email") });
|
||||
} catch {
|
||||
setTestEmailResult({ success: false, message: "Failed to send test email" });
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
}, [settings.notificationEmail]);
|
||||
|
||||
const testShoutrrr = useCallback(async () => {
|
||||
setTestingShoutrrr(true);
|
||||
setTestShoutrrrResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test-shoutrrr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl })
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestShoutrrrResult({
|
||||
success: res.ok,
|
||||
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification")
|
||||
});
|
||||
} catch {
|
||||
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
|
||||
} finally {
|
||||
setTestingShoutrrr(false);
|
||||
}
|
||||
}, [settings.shoutrrrUrl]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
|
||||
|
||||
return {
|
||||
settings,
|
||||
setSettings,
|
||||
savedSettings,
|
||||
settingsLoading,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
testingEmail,
|
||||
testEmailResult,
|
||||
setTestEmailResult,
|
||||
testingShoutrrr,
|
||||
testShoutrrrResult,
|
||||
setTestShoutrrrResult,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
testEmail,
|
||||
testShoutrrr,
|
||||
hasUnsavedChanges
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// =============================================================================
|
||||
// useShare Hook - Share dialog state and operations
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseShareReturn {
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
shareSelectedPerson: string;
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: number;
|
||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openShareDialog: (meds: Medication[]) => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
}
|
||||
|
||||
export function useShare(): UseShareReturn {
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const openShareDialog = useCallback((meds: Medication[]) => {
|
||||
setShowShareDialog(true);
|
||||
window.history.pushState({ modal: "share" }, "");
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
setShareSelectedPerson("");
|
||||
setShareSelectedDays(30);
|
||||
|
||||
// Get unique takenBy people from all medications (flatten arrays)
|
||||
const allPeople = meds.flatMap((m) => m.takenBy || []);
|
||||
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
||||
setSharePeople(uniquePeople);
|
||||
if (uniquePeople.length > 0) {
|
||||
setShareSelectedPerson(uniquePeople[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateShareLink = useCallback(async () => {
|
||||
if (!shareSelectedPerson) return;
|
||||
setShareGenerating(true);
|
||||
setShareCopied(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||
setShareLink(fullUrl);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Failed to generate share link");
|
||||
}
|
||||
} catch {
|
||||
alert("Failed to generate share link");
|
||||
} finally {
|
||||
setShareGenerating(false);
|
||||
}
|
||||
}, [shareSelectedPerson, shareSelectedDays]);
|
||||
|
||||
const copyShareLink = useCallback(() => {
|
||||
if (shareLink) {
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const closeShareDialog = useCallback(() => {
|
||||
if (showShareDialog) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showShareDialog]);
|
||||
|
||||
// Internal function to reset share dialog state (called by popstate handler)
|
||||
const resetShareDialogState = useCallback(() => {
|
||||
setShowShareDialog(false);
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showShareDialog,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
openShareDialog,
|
||||
generateShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// =============================================================================
|
||||
// useTheme Hook - Theme (dark/light mode) state management
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export interface UseThemeReturn {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function useTheme(): UseThemeReturn {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as Theme) || "dark";
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -50,8 +50,9 @@
|
||||
"reminders": {
|
||||
"active": "Automatische Erinnerungen aktiv",
|
||||
"allStockOk": "Bestand OK",
|
||||
"allOk": "Alles OK",
|
||||
"allOk": "✓ Alles OK",
|
||||
"lastReminder": "Letzte Erinnerung",
|
||||
"next": "Nächste",
|
||||
"nextIn": "Nächste",
|
||||
"inDays": "in {{days}} Tagen",
|
||||
"noRemindersNeeded": "keine Erinnerungen nötig",
|
||||
@@ -243,8 +244,8 @@
|
||||
"highStock": "Hoch",
|
||||
"noSchedule": "Kein Zeitplan",
|
||||
"enough": "Ausreichend",
|
||||
"noPillsLeft": "⚠ Keine Tabletten mehr",
|
||||
"stockOk": "✓ Bestand OK"
|
||||
"noPillsLeft": "Keine Tabletten mehr",
|
||||
"stockOk": "Bestand OK"
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||
|
||||
@@ -52,8 +52,9 @@
|
||||
"reminders": {
|
||||
"active": "Automatic reminders active",
|
||||
"allStockOk": "All stock OK",
|
||||
"allOk": "All OK",
|
||||
"allOk": "✓ All OK",
|
||||
"lastReminder": "Last reminder",
|
||||
"next": "Next",
|
||||
"nextIn": "Next",
|
||||
"inDays": "in {{days}} days",
|
||||
"noRemindersNeeded": "no reminders needed",
|
||||
@@ -245,8 +246,8 @@
|
||||
"highStock": "High",
|
||||
"noSchedule": "No Schedule",
|
||||
"enough": "Enough",
|
||||
"noPillsLeft": "⚠ No pills left",
|
||||
"stockOk": "✓ Stock OK"
|
||||
"noPillsLeft": "No pills left",
|
||||
"stockOk": "Stock OK"
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Intake reminders enabled",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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> {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,173 @@
|
||||
// =============================================================================
|
||||
// Core Types for MedAssist
|
||||
// =============================================================================
|
||||
|
||||
export type Blister = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
};
|
||||
|
||||
export type Medication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
takenBy: string[];
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
blisters: Blister[];
|
||||
imageUrl?: string | null;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
|
||||
export type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
};
|
||||
|
||||
export type RefillEntry = {
|
||||
id: number;
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
refillDate: string;
|
||||
};
|
||||
|
||||
export type FormBlister = {
|
||||
usage: string;
|
||||
every: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
};
|
||||
|
||||
export type FormState = {
|
||||
name: string;
|
||||
genericName: string;
|
||||
takenBy: string[];
|
||||
packCount: string;
|
||||
blistersPerPack: string;
|
||||
pillsPerBlister: string;
|
||||
looseTablets: string;
|
||||
pillWeightMg: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
intakeRemindersEnabled: boolean;
|
||||
blisters: FormBlister[];
|
||||
};
|
||||
|
||||
export type FieldErrors = {
|
||||
name?: string;
|
||||
genericName?: string;
|
||||
takenBy?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type Coverage = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
depletionTime: number | null;
|
||||
nextDose: string | null;
|
||||
};
|
||||
|
||||
export type StockStatus = {
|
||||
level: "out-of-stock" | "low" | "normal" | "high";
|
||||
className: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type StockThresholds = {
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
};
|
||||
|
||||
export type ScheduleEvent = {
|
||||
id: string;
|
||||
medName: string;
|
||||
timeStr: string;
|
||||
dateStr: string;
|
||||
usage: number;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
takenBy: string[];
|
||||
};
|
||||
|
||||
export type BlisterStock = {
|
||||
fullBlisters: number;
|
||||
openBlisterPills: number;
|
||||
loosePills: number;
|
||||
};
|
||||
|
||||
// Shared schedule types
|
||||
export type SharedMedication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
imageUrl?: string | null;
|
||||
totalPills: number;
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
looseTablets: number;
|
||||
pillsPerBlister: number;
|
||||
takenBy: string[];
|
||||
blisters: Blister[];
|
||||
};
|
||||
|
||||
export type SharedScheduleData = {
|
||||
takenBy: string;
|
||||
sharedBy: string | null;
|
||||
scheduleDays: number;
|
||||
medications: SharedMedication[];
|
||||
stockThresholds?: {
|
||||
lowStockDays: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExpiredLinkData = {
|
||||
ownerUsername: string;
|
||||
takenBy: string;
|
||||
expiredAt: string;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Field Validation Limits (must match backend)
|
||||
// =============================================================================
|
||||
export const FIELD_LIMITS = {
|
||||
name: { min: 1, max: 100 },
|
||||
genericName: { max: 100 },
|
||||
takenBy: { max: 100 },
|
||||
notes: { max: 2000 }
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions for Medication Calculations
|
||||
// =============================================================================
|
||||
|
||||
type MedLike = Pick<Medication, 'packCount' | 'blistersPerPack' | 'pillsPerBlister' | 'looseTablets'> & { stockAdjustment?: number };
|
||||
|
||||
/** Calculate total pills including stockAdjustment */
|
||||
export function getMedTotal(med: MedLike): number {
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
/** Get the base package size (without stockAdjustment) */
|
||||
export function getPackageSize(med: MedLike): number {
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// =============================================================================
|
||||
// Formatting Utilities
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication, BlisterStock } from "../types";
|
||||
|
||||
/**
|
||||
* Format a number using the current locale with optional decimal places
|
||||
*/
|
||||
export function formatNumber(n: number | null | undefined, decimals = 0): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date/time string for display
|
||||
*/
|
||||
export function formatDateTime(iso: string | null | undefined, locale?: string): string {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
};
|
||||
const timeOpts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
};
|
||||
const dateStr = d.toLocaleDateString(locale, dateOpts);
|
||||
const timeStr = d.toLocaleTimeString(locale, timeOpts);
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number to 2 digits with leading zero
|
||||
*/
|
||||
export function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Date to ISO date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function toIsoString(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date portion (YYYY-MM-DD) from an ISO datetime string or Date
|
||||
*/
|
||||
export function toDateValue(input: string | Date): string {
|
||||
if (input instanceof Date) {
|
||||
return toIsoString(input);
|
||||
}
|
||||
return input.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time portion (HH:MM) from an ISO datetime string or Date
|
||||
*/
|
||||
export function toTimeValue(input: string | Date): string {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a date string (YYYY-MM-DD) and time string (HH:MM) into ISO datetime
|
||||
*/
|
||||
export function combineDateAndTime(dateStr: string, timeStr: string): string {
|
||||
return `${dateStr}T${timeStr}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date or ISO string to datetime-local input value (YYYY-MM-DDTHH:MM)
|
||||
*/
|
||||
export function toInputValue(input: Date | string): string {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive total pills from medication inventory
|
||||
*/
|
||||
export function deriveTotal(
|
||||
packCount: number,
|
||||
blistersPerPack: number,
|
||||
pillsPerBlister: number,
|
||||
looseTablets: number
|
||||
): number {
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for expiry date status
|
||||
*/
|
||||
export function getExpiryClass(expiryDate: string | null | undefined, thresholdDays: number): string {
|
||||
if (!expiryDate) return "";
|
||||
const exp = new Date(expiryDate);
|
||||
const now = new Date();
|
||||
const diff = (exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diff < 0) return "expired";
|
||||
if (diff <= thresholdDays) return "expiring-soon";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate blister stock breakdown for a medication
|
||||
*/
|
||||
export function getBlisterStock(med: Medication): BlisterStock {
|
||||
const total = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const bSize = med.pillsPerBlister;
|
||||
const fullBlisters = Math.floor(total / bSize);
|
||||
const openBlisterPills = total % bSize;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters count with optional pills per blister
|
||||
*/
|
||||
export function formatFullBlisters(stock: BlisterStock, pillsPerBlister?: number): string {
|
||||
const count = stock.fullBlisters;
|
||||
if (pillsPerBlister !== undefined) {
|
||||
return `${count} (${count * pillsPerBlister})`;
|
||||
}
|
||||
return String(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format open blister and loose pills
|
||||
*/
|
||||
export function formatOpenBlisterAndLoose(stock: BlisterStock): string {
|
||||
return String(stock.openBlisterPills);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic version strings
|
||||
* Returns: negative if a < b, positive if a > b, 0 if equal
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/, "").split(".").map(Number);
|
||||
const pb = b.replace(/^v/, "").split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] ?? 0;
|
||||
const nb = pb[i] ?? 0;
|
||||
if (na !== nb) return na - nb;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// =============================================================================
|
||||
// ICS Calendar Generation
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication } from "../types";
|
||||
|
||||
/**
|
||||
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and download an ICS calendar file for a medication's schedule
|
||||
*/
|
||||
export function generateICS(med: Medication): void {
|
||||
const events = med.blisters
|
||||
.map((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
|
||||
const interval = blister.every;
|
||||
|
||||
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const summary = `💊 ${med.name} - ${pillInfo}`;
|
||||
const description = [
|
||||
`Medication: ${med.name}`,
|
||||
med.genericName ? `Generic: ${med.genericName}` : "",
|
||||
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
|
||||
`Dosage: ${pillInfo}`,
|
||||
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
|
||||
med.notes ? `Notes: ${med.notes}` : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
|
||||
return `BEGIN:VEVENT
|
||||
UID:medassist-ng-${med.id}-${idx}@medassist-ng
|
||||
DTSTAMP:${formatICSDate(new Date())}
|
||||
DTSTART:${formatICSDate(start)}
|
||||
DTEND:${formatICSDate(end)}
|
||||
RRULE:FREQ=DAILY;INTERVAL=${interval}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT5M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Time to take ${med.name}
|
||||
END:VALARM
|
||||
END:VEVENT`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const ics = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//MedAssist-ng//Medication Schedule//EN
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:${med.name} Schedule
|
||||
${events}
|
||||
END:VCALENDAR`;
|
||||
|
||||
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${med.name.replace(/[^a-zA-Z0-9]/g, "_")}_schedule.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// =============================================================================
|
||||
// Utility Functions - Barrel Export
|
||||
// =============================================================================
|
||||
|
||||
export * from "./formatters";
|
||||
export * from "./schedule";
|
||||
export * from "./storage";
|
||||
export * from "./ics";
|
||||
@@ -0,0 +1,275 @@
|
||||
// =============================================================================
|
||||
// Schedule Building and Coverage Calculations
|
||||
// =============================================================================
|
||||
|
||||
import type { Medication, Coverage, StockStatus, StockThresholds, ScheduleEvent } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
/**
|
||||
* Build schedule preview events for medications
|
||||
*/
|
||||
export function buildSchedulePreview(
|
||||
meds: Medication[],
|
||||
locale: string,
|
||||
includePast: boolean = false
|
||||
): { events: ScheduleEvent[]; today: number; nextThree: number; totalBlisters: number } {
|
||||
const events: ScheduleEvent[] = [];
|
||||
if (!Array.isArray(meds)) return { events, today: 0, nextThree: 0, totalBlisters: 0 };
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 180); // 6 months horizon
|
||||
|
||||
meds.forEach((med) => {
|
||||
med.blisters.forEach((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
|
||||
const isPast = d < todayStart;
|
||||
if (isPast && !includePast) continue;
|
||||
const whenMs = d.getTime();
|
||||
events.push({
|
||||
id: `${med.id}-${idx}-${whenMs}`,
|
||||
medName: med.name,
|
||||
takenBy: med.takenBy || [],
|
||||
usage: blister.usage,
|
||||
when: whenMs,
|
||||
isPast,
|
||||
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
const todayCount = events.filter((e) => {
|
||||
const t = new Date(e.when);
|
||||
const n = new Date();
|
||||
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
|
||||
}).length;
|
||||
|
||||
return {
|
||||
events,
|
||||
today: todayCount,
|
||||
nextThree: events.length,
|
||||
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coverage information for medications
|
||||
*/
|
||||
export function calculateCoverage(
|
||||
meds: Medication[],
|
||||
events: Array<{ medName: string; when: number }>,
|
||||
locale: string,
|
||||
reminderDaysBefore: number,
|
||||
stockCalculationMode: "automatic" | "manual",
|
||||
takenDoses: Set<string>
|
||||
): { low: Coverage[]; all: Coverage[] } {
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const personCount = Math.max(1, m.takenBy?.length || 1);
|
||||
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
|
||||
|
||||
let consumed = 0;
|
||||
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
m.blisters.forEach((s) => {
|
||||
const blisterStart = new Date(s.start).getTime();
|
||||
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
consumed += occurrences * s.usage * personCount;
|
||||
});
|
||||
} else {
|
||||
takenDoses.forEach((doseId) => {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (medId === m.id && m.blisters[blisterIdx]) {
|
||||
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
|
||||
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
|
||||
consumed += m.blisters[blisterIdx].usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const totalPills = getMedTotal(m);
|
||||
const medsLeft = Math.max(0, totalPills - consumed);
|
||||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
|
||||
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
||||
const depletionDate = depletionMs !== null
|
||||
? new Date(depletionMs).toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
|
||||
: null;
|
||||
const nextEvent = events.find((e) => e.medName === m.name);
|
||||
|
||||
return {
|
||||
name: m.name,
|
||||
medsLeft: Number(medsLeft.toFixed(1)),
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
depletionTime: depletionMs,
|
||||
nextDose: nextEvent
|
||||
? new Date(nextEvent.when).toLocaleString(locale, { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= reminderDaysBefore));
|
||||
return { low, all: coverage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock status based on days left and thresholds
|
||||
*/
|
||||
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||
}
|
||||
|
||||
if (daysLeft === null) {
|
||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||
}
|
||||
|
||||
if (daysLeft > thresholds.highStockDays) {
|
||||
return { level: "high", className: "high", label: "status.highStock" };
|
||||
}
|
||||
|
||||
if (daysLeft >= thresholds.lowStockDays) {
|
||||
return { level: "normal", className: "success", label: "status.normal" };
|
||||
}
|
||||
|
||||
return { level: "low", className: "warning", label: "status.lowStock" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next reminder date for a medication
|
||||
*/
|
||||
export function getNextReminderForMed(med: Coverage, reminderDaysBefore: number, locale: string): string {
|
||||
if (!med.depletionTime) return "—";
|
||||
|
||||
const reminderTime = med.depletionTime - reminderDaysBefore * 86_400_000;
|
||||
const now = Date.now();
|
||||
|
||||
if (reminderTime <= now) {
|
||||
return "Due now";
|
||||
}
|
||||
|
||||
return new Date(reminderTime).toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reminder status text for dashboard display
|
||||
*/
|
||||
export function getReminderStatusText(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_lowStock: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastSent: string | null,
|
||||
lastType: "stock" | "intake" | null,
|
||||
lastChannel: "email" | "push" | "both" | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): { lines: Array<{ text: string; className?: string; strong?: boolean }> } {
|
||||
const emptyMeds = allCoverage.filter((c) => c.medsLeft <= 0);
|
||||
const medsNeedingReminder = allCoverage
|
||||
.filter((c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
|
||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||
const lowStockNotYetCritical = allCoverage.filter(
|
||||
(c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays
|
||||
);
|
||||
|
||||
const formatLastSent = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const getTypeLabel = () => (lastType === "intake" ? t("dashboard.reminders.typeIntake") : t("dashboard.reminders.typeStock"));
|
||||
const getChannelLabel = () => {
|
||||
if (lastChannel === "both") return t("dashboard.reminders.channelBoth");
|
||||
if (lastChannel === "push") return t("dashboard.reminders.channelPush");
|
||||
return t("dashboard.reminders.channelEmail");
|
||||
};
|
||||
|
||||
const formatLastInfo = (iso: string) => {
|
||||
const dateStr = formatLastSent(iso);
|
||||
if (lastType && lastChannel) {
|
||||
return `${dateStr} (${getTypeLabel()}, ${getChannelLabel()})`;
|
||||
}
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const lines: Array<{ text: string; className?: string; strong?: boolean }> = [];
|
||||
|
||||
if (emptyMeds.length > 0) {
|
||||
lines.push({ text: `🚨 ${t("dashboard.reminders.emptyStock", { count: emptyMeds.length })}`, className: "danger-text", strong: true });
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({ text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text" });
|
||||
}
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
}
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({ text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, className: "danger-text", strong: true });
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
}
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
|
||||
if (lowStockNotYetCritical.length > 0) {
|
||||
const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0];
|
||||
const daysUntilReminder = Math.max(0, (nextMed.daysLeft ?? 0) - reminderDaysBefore);
|
||||
lines.push({ text: t("dashboard.reminders.lowWarning", { count: lowStockNotYetCritical.length }), className: "warning-text" });
|
||||
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
|
||||
return { lines };
|
||||
}
|
||||
|
||||
const allWithDepletion = allCoverage
|
||||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.medsLeft > 0)
|
||||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
|
||||
|
||||
if (allWithDepletion.length > 0) {
|
||||
const nextMed = allWithDepletion[0];
|
||||
const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore;
|
||||
if (daysUntilReminder > 0) {
|
||||
lines.push({ text: `✓ ${t("dashboard.reminders.allOk")}`, className: "success-text" });
|
||||
lines.push({ text: `${t("dashboard.reminders.nextIn")}: ${nextMed.name} ${t("dashboard.reminders.inDays", { days: daysUntilReminder })}` });
|
||||
return { lines };
|
||||
}
|
||||
}
|
||||
|
||||
lines.push({ text: `✓ ${t("dashboard.reminders.allStockOk")}`, className: "success-text" });
|
||||
if (lastSent) {
|
||||
lines.push({ text: `${t("dashboard.reminders.lastReminder")}: ${formatLastInfo(lastSent)}` });
|
||||
} else {
|
||||
lines.push({ text: t("dashboard.reminders.noRemindersNeeded") });
|
||||
}
|
||||
return { lines };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// =============================================================================
|
||||
// Local Storage Utilities
|
||||
// =============================================================================
|
||||
|
||||
import { pad2 } from "./formatters";
|
||||
|
||||
/**
|
||||
* Generate a user-specific storage key
|
||||
* @param userId - The user ID
|
||||
* @param key - The storage key name
|
||||
*/
|
||||
export function userStorageKey(userId: number | string, key: string): string {
|
||||
return `${key}_user_${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date as ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a date N days from today as ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
export function plusDaysIso(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load collapsed days state from localStorage
|
||||
*/
|
||||
export function loadCollapsedDaysFromStorage(
|
||||
collapsedKey: string,
|
||||
expandedKey: string
|
||||
): { collapsed: Set<string>; expanded: Set<string> } {
|
||||
const collapsed = new Set<string>();
|
||||
const expanded = new Set<string>();
|
||||
try {
|
||||
const storedCollapsed = localStorage.getItem(collapsedKey);
|
||||
if (storedCollapsed) {
|
||||
const arr = JSON.parse(storedCollapsed);
|
||||
if (Array.isArray(arr)) arr.forEach((s: string) => collapsed.add(s));
|
||||
}
|
||||
const storedExpanded = localStorage.getItem(expandedKey);
|
||||
if (storedExpanded) {
|
||||
const arr = JSON.parse(storedExpanded);
|
||||
if (Array.isArray(arr)) arr.forEach((s: string) => expanded.add(s));
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return { collapsed, expanded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save collapsed days state to localStorage
|
||||
*/
|
||||
export function saveCollapsedDaysToStorage(storageKey: string, state: Record<string, boolean>): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme from localStorage or default
|
||||
*/
|
||||
export function getStoredTheme(): "light" | "dark" {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save theme to localStorage
|
||||
*/
|
||||
export function saveTheme(theme: "light" | "dark"): void {
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
Reference in New Issue
Block a user