feat: add shared overview and harden frontend session state (#407)
This commit is contained in:
+1086
-1022
File diff suppressed because it is too large
Load Diff
@@ -1261,7 +1261,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
className="select-field package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => handleValueChange("packageType", e.target.value as PackageType)}
|
||||
>
|
||||
@@ -1284,6 +1284,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={form.pillForm}
|
||||
onChange={(e) => handleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
@@ -1295,7 +1296,11 @@ export function MedicationsPage() {
|
||||
{isTubePackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"topical"}
|
||||
onChange={() => handleValueChange("medicationForm", "topical")}
|
||||
>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -1303,7 +1308,11 @@ export function MedicationsPage() {
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"liquid"}
|
||||
onChange={() => handleValueChange("medicationForm", "liquid")}
|
||||
>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -1503,7 +1512,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
@@ -1563,7 +1572,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
@@ -1597,7 +1606,7 @@ export function MedicationsPage() {
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
@@ -1760,6 +1769,7 @@ export function MedicationsPage() {
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
@@ -1775,6 +1785,7 @@ export function MedicationsPage() {
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function PlannerPage() {
|
||||
return t("form.ml");
|
||||
}
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
return med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
}
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
@@ -140,7 +140,7 @@ export function PlannerPage() {
|
||||
return `${roundedLoose} ${t("form.ml")}`;
|
||||
}
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
const unit = med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
return `${roundedLoose} ${unit}`;
|
||||
}
|
||||
return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
|
||||
@@ -179,7 +179,7 @@ export function SchedulePage() {
|
||||
<div className="card-head">
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
className="select-field schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
@@ -6,10 +7,15 @@ import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [apiKeyToken, setApiKeyToken] = useState("");
|
||||
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
|
||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
settingsLoading,
|
||||
settingsLoadError,
|
||||
// Email testing
|
||||
testEmail,
|
||||
testingEmail,
|
||||
@@ -35,10 +41,95 @@ export function SettingsPage() {
|
||||
} = useAppContext();
|
||||
|
||||
const hasExistingData = meds.length > 0;
|
||||
let emailUnavailableReason: string | null = null;
|
||||
if (settingsLoadError === "auth") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorAuth");
|
||||
} else if (settingsLoadError === "forbidden") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorForbidden");
|
||||
} else if (settingsLoadError === "request") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorGeneric");
|
||||
} else if (!settings.smtpHost) {
|
||||
emailUnavailableReason = t("settings.email.serverNotConfigured");
|
||||
}
|
||||
|
||||
const generateApiKey = async () => {
|
||||
setApiKeyGenerating(true);
|
||||
setApiKeyError(null);
|
||||
setApiKeyCopied(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: "Default API Key",
|
||||
scope: "write",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof data?.token !== "string" || !data.token) {
|
||||
setApiKeyError(t("settings.apiKey.generateError"));
|
||||
return;
|
||||
}
|
||||
|
||||
setApiKeyToken(data.token);
|
||||
} catch {
|
||||
setApiKeyError(t("settings.apiKey.generateError"));
|
||||
} finally {
|
||||
setApiKeyGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyApiKeyToken = async () => {
|
||||
if (!apiKeyToken) return;
|
||||
|
||||
const markCopied = () => {
|
||||
setApiKeyCopied(true);
|
||||
setTimeout(() => setApiKeyCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKeyToken);
|
||||
markCopied();
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to textarea-based copy.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = apiKeyToken;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
markCopied();
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
<p>{t("settings.loading")}</p>
|
||||
<div className="page-loading-skeleton" aria-busy="true">
|
||||
<span className="screen-reader-only">{t("settings.loading")}</span>
|
||||
<article className="card skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
</article>
|
||||
<article className="card skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
</article>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-form">
|
||||
{/* Language */}
|
||||
@@ -60,7 +151,7 @@ export function SettingsPage() {
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
className="language-select"
|
||||
className="select-field language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
@@ -68,6 +159,46 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t("settings.apiKey.title")}</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<div className="setting-group" style={{ gridTemplateColumns: "1fr" }}>
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t("settings.apiKey.generateTitle")}</span>
|
||||
<span className="action-card-desc">{t("settings.apiKey.generateDesc")}</span>
|
||||
</div>
|
||||
<button type="button" className="secondary" onClick={generateApiKey} disabled={apiKeyGenerating}>
|
||||
{apiKeyGenerating ? t("settings.apiKey.generating") : t("settings.apiKey.generateButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeyToken ? (
|
||||
<div>
|
||||
<span className="field-label">{t("settings.apiKey.currentToken")}</span>
|
||||
<div className="setting-actions api-key-actions">
|
||||
<input
|
||||
type="text"
|
||||
className="api-key-token-input"
|
||||
value={apiKeyToken}
|
||||
readOnly
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button type="button" className="ghost" onClick={copyApiKeyToken}>
|
||||
{apiKeyCopied ? t("settings.apiKey.copied") : t("settings.apiKey.copyButton")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint-text">{t("settings.apiKey.copyHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiKeyError ? <p className="danger-text">{apiKeyError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Notifications */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
@@ -361,6 +492,11 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{emailUnavailableReason && (
|
||||
<div className="setting-actions">
|
||||
<span className={settingsLoadError ? "danger-text" : "info-text"}>{emailUnavailableReason}</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
@@ -375,12 +511,19 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
type="text"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
placeholder="recipient address"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
data-bwignore="true"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { SharedMedicationOverviewItem, SharedMedicationOverviewResponse } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: light)").matches) {
|
||||
return "light";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
|
||||
if (medication.packageType === "blister") {
|
||||
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
|
||||
}
|
||||
|
||||
if (medication.totalPills !== null) {
|
||||
return `${medication.packCount} x ${medication.totalPills}`;
|
||||
}
|
||||
|
||||
return `${medication.packCount}`;
|
||||
}
|
||||
|
||||
function formatDate(dateValue: string | null, locale: string): string {
|
||||
if (!dateValue) return "-";
|
||||
const parsed = new Date(`${dateValue}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) return dateValue;
|
||||
return parsed.toLocaleDateString(locale);
|
||||
}
|
||||
|
||||
export function SharedOverviewPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [data, setData] = useState<SharedMedicationOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredAt, setExpiredAt] = useState<string | null>(null);
|
||||
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem("theme") as ThemePreference | null;
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
}
|
||||
return "dark";
|
||||
});
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedTheme = themePreference === "system" ? getSystemTheme() : themePreference;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", resolvedTheme);
|
||||
localStorage.setItem("theme", themePreference);
|
||||
}, [themePreference, resolvedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themePreference !== "system") return;
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: light)");
|
||||
if (!mq) return;
|
||||
const handler = () => {
|
||||
const resolved = mq.matches ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeMenuOpen) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function loadOverview() {
|
||||
if (!token) {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
setError(t("sharedOverview.error.notFound"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setExpiredAt(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/share/${token}/overview`);
|
||||
const responseData = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("not_found");
|
||||
}
|
||||
if (response.status === 410) {
|
||||
setExpiredAt(responseData.expiredAt ?? null);
|
||||
throw new Error("expired");
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error("rate_limited");
|
||||
}
|
||||
throw new Error("load_failed");
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setData(responseData as SharedMedicationOverviewResponse);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (isCancelled) return;
|
||||
|
||||
const message = loadError instanceof Error ? loadError.message : "load_failed";
|
||||
if (message === "not_found") {
|
||||
setError(t("sharedOverview.error.notFound"));
|
||||
return;
|
||||
}
|
||||
if (message === "expired") {
|
||||
setError(t("sharedOverview.error.expired"));
|
||||
return;
|
||||
}
|
||||
if (message === "rate_limited") {
|
||||
setError(t("sharedOverview.error.rateLimit"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(t("sharedOverview.error.generic"));
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadOverview();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading shared-schedule-loading-skeleton" aria-busy="true">
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
<span className="screen-reader-only">{t("common.loading")}</span>
|
||||
<div className="skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className={`shared-schedule-error${expiredAt ? " expired" : ""}`}>
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
{expiredAt ? <div className="expired-icon">⏰</div> : null}
|
||||
<h2>{t("sharedOverview.title")}</h2>
|
||||
<p className="error-message">{error ?? t("sharedOverview.error.generic")}</p>
|
||||
{expiredAt ? (
|
||||
<p className="expired-date">
|
||||
{t("sharedOverview.expiredOn", {
|
||||
date: new Date(expiredAt).toLocaleDateString(getSystemLocale(i18n.language)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const locale = getSystemLocale(i18n.language);
|
||||
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-container shared-overview-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>{t("sharedOverview.title", { person: data.takenBy })}</h1>
|
||||
<p className="shared-schedule-period">{t("sharedOverview.sharedBy", { user: data.sharedBy ?? "-" })}</p>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
||||
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
|
||||
{resolvedTheme === "dark" ? "🌙" : "☀️"}
|
||||
</button>
|
||||
<div className="theme-dropdown">
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "light" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("light");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.light")}
|
||||
</button>
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "dark" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("dark");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.dark")}
|
||||
</button>
|
||||
<button
|
||||
className={`theme-dropdown-item${themePreference === "system" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setThemePreference("system");
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("theme.system")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{data.medications.length === 0 ? (
|
||||
<p className="shared-schedule-empty">{t("sharedOverview.noMedications")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shared-overview-table-wrap">
|
||||
<table className="shared-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("sharedOverview.columns.name")}</th>
|
||||
<th>{t("sharedOverview.columns.package")}</th>
|
||||
<th>{t("sharedOverview.columns.stock")}</th>
|
||||
<th>{t("sharedOverview.columns.daysLeft")}</th>
|
||||
<th>{t("sharedOverview.columns.nextIntake")}</th>
|
||||
<th>{t("sharedOverview.columns.depletion")}</th>
|
||||
<th>{t("sharedOverview.columns.priority")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.medications.map((medication) => {
|
||||
const priorityKey =
|
||||
medication.priority === "high"
|
||||
? "sharedOverview.priority.high"
|
||||
: "sharedOverview.priority.normal";
|
||||
return (
|
||||
<tr key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}>
|
||||
<td>
|
||||
<div className="shared-overview-medication-cell">
|
||||
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
|
||||
<div className="shared-overview-medication-text">
|
||||
<strong>{medication.name}</strong>
|
||||
{medication.genericName ? <span>{medication.genericName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPackageInfo(medication)}</td>
|
||||
<td>
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
? "-"
|
||||
: t("sharedOverview.stock.of", {
|
||||
current: medication.currentStock,
|
||||
capacity: medication.capacity,
|
||||
})}
|
||||
</td>
|
||||
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</td>
|
||||
<td>{formatDate(medication.nextIntakeDate, locale)}</td>
|
||||
<td>{formatDate(medication.depletionDate, locale)}</td>
|
||||
<td>
|
||||
{medication.priority === null ? (
|
||||
"-"
|
||||
) : (
|
||||
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shared-overview-cards">
|
||||
{data.medications.map((medication) => {
|
||||
const priorityKey =
|
||||
medication.priority === "high" ? "sharedOverview.priority.high" : "sharedOverview.priority.normal";
|
||||
return (
|
||||
<article
|
||||
className="shared-overview-card"
|
||||
key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}
|
||||
>
|
||||
<div className="shared-overview-card-title">
|
||||
<MedicationAvatar name={medication.name} imageUrl={medication.imageUrl} size="sm" />
|
||||
<div>
|
||||
<strong>{medication.name}</strong>
|
||||
{medication.genericName ? <p>{medication.genericName}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shared-overview-card-grid">
|
||||
<span>{t("sharedOverview.columns.package")}</span>
|
||||
<strong>{formatPackageInfo(medication)}</strong>
|
||||
<span>{t("sharedOverview.columns.stock")}</span>
|
||||
<strong>
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
? "-"
|
||||
: t("sharedOverview.stock.of", {
|
||||
current: medication.currentStock,
|
||||
capacity: medication.capacity,
|
||||
})}
|
||||
</strong>
|
||||
<span>{t("sharedOverview.columns.daysLeft")}</span>
|
||||
<strong>{medication.daysLeft === null ? "-" : medication.daysLeft}</strong>
|
||||
<span>{t("sharedOverview.columns.nextIntake")}</span>
|
||||
<strong>{formatDate(medication.nextIntakeDate, locale)}</strong>
|
||||
<span>{t("sharedOverview.columns.depletion")}</span>
|
||||
<strong>{formatDate(medication.depletionDate, locale)}</strong>
|
||||
</div>
|
||||
{medication.priority ? (
|
||||
<span className={`shared-overview-priority ${medication.priority}`}>{t(priorityKey)}</span>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,11 +102,17 @@ export function getReminderStatusData(
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const emptyCount = lowStockMeds.filter((m) => m.daysLeft <= 0).length;
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical && m.daysLeft > 0).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
if (emptyCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.emptyStock", { count: emptyCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
|
||||
@@ -4,3 +4,4 @@ export { MedicationsPage } from "./MedicationsPage";
|
||||
export { PlannerPage } from "./PlannerPage";
|
||||
export { SchedulePage } from "./SchedulePage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { SharedOverviewPage } from "./SharedOverviewPage";
|
||||
|
||||
Reference in New Issue
Block a user