Files
medassist-ng/frontend/src/pages/SettingsPage.tsx
T

1168 lines
41 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context";
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,
savedSettings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
settingsLoadError,
// Email testing
testEmail,
testingEmail,
testEmailResult,
// Shoutrrr testing
testShoutrrr,
testingShoutrrr,
testShoutrrrResult,
// Export/Import
exporting,
importing,
showExportModal,
setShowExportModal,
handleExport,
handleImportFileSelect,
showImportConfirm,
setShowImportConfirm,
setPendingImportData,
handleImportConfirm,
importResult,
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
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);
}
};
const automaticStockCalculationId = "settings-stock-calculation-automatic";
const manualStockCalculationId = "settings-stock-calculation-manual";
useEffect(() => {
setTimezoneDraft(settings.timezone);
}, [settings.timezone]);
const commitTimezoneDraft = () => {
if (timezoneDraft === settings.timezone) {
return;
}
setTimezoneTouched(true);
setSettings((prev) => ({ ...prev, timezone: timezoneDraft }));
};
const savedTimezone = savedSettings?.timezone ?? settings.timezone;
const timezoneChanged = settings.timezone !== savedTimezone;
const showTimezoneSaving = timezoneTouched && timezoneChanged && settingsSaving;
const showTimezoneSaved = timezoneTouched && !timezoneChanged && settingsSaved;
let timezoneStatusText = "";
if (showTimezoneSaving) {
timezoneStatusText = t("settings.timezone.saving");
} else if (showTimezoneSaved) {
timezoneStatusText = t("settings.timezone.saved");
}
const timezoneStatusClassName = showTimezoneSaved ? "timezone-status timezone-status-saved" : "timezone-status";
const availableTimezones = Array.isArray(settings.availableTimezones) ? settings.availableTimezones : [];
const timezoneSuggestions =
availableTimezones.length > 0
? availableTimezones
: (() => {
try {
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
return intlWithSupportedValues.supportedValuesOf("timeZone");
}
} catch {
// fall through
}
return [settings.serverTimezone || "UTC", "UTC"];
})();
return (
<section className="grid">
{settingsLoading ? (
<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" data-testid="settings-page">
{/* Language */}
<article className="card">
<div className="card-head">
<h2>{t("settings.language.title")}</h2>
</div>
<label className="setting-row language-row" data-testid="settings-language-select">
<span className="setting-label">{t("settings.language.select")}</span>
<select
value={i18n.language}
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
className="select-field language-select"
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
<div className="setting-row language-row" style={{ marginTop: "12px" }}>
<div className="setting-label">
<span>{t("settings.timezone.select")}</span>
<span className="info-tooltip small tooltip-align-left" data-tooltip={t("settings.timezone.hint")}>
</span>
</div>
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px", width: "auto" }}>
<input
type="text"
className="select-field language-select"
value={timezoneDraft}
onChange={(e) => {
setTimezoneDraft(e.target.value);
}}
onBlur={commitTimezoneDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
list="settings-timezone-suggestions"
placeholder={settings.serverTimezone || "UTC"}
/>
<datalist id="settings-timezone-suggestions">
{timezoneSuggestions.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
<button
type="button"
className="ghost"
onClick={() => {
setTimezoneTouched(true);
setTimezoneDraft("");
setSettings((prev) => ({ ...prev, timezone: "" }));
}}
>
{t("settings.timezone.useServerDefault")}
</button>
</div>
</div>
<p className={timezoneStatusClassName}>{timezoneStatusText || " "}</p>
<p className="hint-text" style={{ marginTop: "8px" }}>
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
</p>
</article>
<article className="card" data-testid="settings-notification-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">
<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" data-testid="settings-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.emailStockReminders}
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.shoutrrrStockReminders}
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.emailIntakeReminders}
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.shoutrrrIntakeReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: 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.prescriptionReminders")}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.emailPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.shoutrrrPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</div>
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
)}
{/* 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="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) =>
setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value, 10) || 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="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, maxNaggingReminders: Math.max(1, Math.min(20, val)) });
}
}}
style={{ width: "80px", textAlign: "center" }}
/>
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stockReminder.title")}</h3>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stockReminder.description")}{" "}
<span className="info-tooltip small" data-tooltip={t("settings.stockReminder.infoTooltip")}>
</span>{" "}
</label>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
}
onChange={(e) => {
const newVal = e.target.checked;
if (newVal) {
setSettings({
...settings,
emailStockReminders: settings.emailEnabled ? true : settings.emailStockReminders,
shoutrrrStockReminders: settings.shoutrrrEnabled ? true : settings.shoutrrrStockReminders,
});
} else {
setSettings({
...settings,
emailStockReminders: false,
shoutrrrStockReminders: false,
repeatDailyReminders: false,
});
}
}}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-row compact" style={{ marginTop: "4px" }}>
<label className="setting-label">
{t("settings.stockReminder.repeatDaily")}
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stockReminder.repeatTooltip")}
>
</span>
</label>
<label
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders)) ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
)
}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.notifications.email")}</h3>
<label
className={`toggle-switch small${!settings.smtpHost ? " disabled" : ""}`}
data-testid="settings-email-enabled-toggle"
>
<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,
emailPrescriptionReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
} else {
setSettings({ ...settings, emailEnabled: newVal });
}
}}
disabled={!settings.smtpHost}
/>
<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">
<div className="full">
<span className="field-label">
{t("settings.email.recipient")}
<span
className="info-tooltip"
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
>
</span>
</span>
<input
type="text"
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="recipient address"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
inputMode="email"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
</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,
shoutrrrPrescriptionReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
} else {
setSettings({ ...settings, shoutrrrEnabled: newVal });
}
}}
/>
<span className="toggle-slider"></span>
</label>
</div>
{settings.shoutrrrEnabled && (
<>
<div className="setting-group">
<div className="full">
<span className="field-label">
{t("settings.push.url")}
<span
className="info-tooltip"
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
>
</span>
</span>
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t("settings.push.urlPlaceholder")}
/>
</div>
</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.dailyAtHour", { hour: settings.reminderHour })}
</span>
</div>
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.intakeCheck")}</span>
<span className="schedule-value">
{t("settings.schedule.minutesBefore", { minutes: settings.reminderMinutesBefore })}
</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(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{settings.lastStockReminderSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value">
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(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.lastIntakeSent")}</span>
<span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{settings.lastPrescriptionReminderSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
<span className="schedule-value">
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</div>
</article>
{/* Stock Settings */}
<article className="card" data-testid="settings-security-card">
<div className="card-head">
<h2>{t("settings.stock.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.calculationMode")}</h3>
</div>
<div className="setting-group calculation-mode-group" data-testid="settings-calculation-mode">
<label
className={`radio-card ${settings.stockCalculationMode === "automatic" ? "selected" : ""}`}
htmlFor={automaticStockCalculationId}
>
<input
id={automaticStockCalculationId}
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" : ""}`}
htmlFor={manualStockCalculationId}
>
<input
id={manualStockCalculationId}
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.thresholds")}</h3>
</div>
<div className="setting-group threshold-chips-group">
<div
className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}
data-testid="settings-threshold-critical"
>
<span className="field-label threshold-chip-label">
<span className="status-chip small danger">{t("status.criticalStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.criticalStockTooltip")}
>
</span>
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</div>
<div
className={
settings.lowStockDays <= settings.reminderDaysBefore ||
settings.lowStockDays >= settings.highStockDays
? "threshold-invalid"
: ""
}
data-testid="settings-threshold-low"
>
<span className="field-label threshold-chip-label">
<span className="status-chip small warning">{t("status.lowStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.lowStockTooltip")}
>
</span>
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
</div>
<div
className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}
data-testid="settings-threshold-high"
>
<span className="field-label threshold-chip-label">
<span className="status-chip small high">{t("status.highStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.highStockTooltip")}
>
</span>
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
</div>
</div>
{(settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays) && (
<p className="threshold-validation-error" data-testid="settings-threshold-validation">
{t("settings.stock.thresholdValidation")}
</p>
)}
<p className="hint-text" style={{ marginTop: "12px" }}>
{t("settings.stock.packageTypesNote")}
</p>
</div>
</article>
{/* General UI */}
<article className="card">
<div className="card-head">
<h2>{t("settings.timeline.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.dashboardSectionOrder")}</h3>
</div>
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.timeline.swapDashboardSections")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.swapDashboardSectionsDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.swapDashboardMainSections}
onChange={(e) => setSettings({ ...settings, swapDashboardMainSections: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.upcomingSection")}</h3>
</div>
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.timeline.upcomingTodayOnly")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.upcomingTodayOnlyDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.upcomingTodayOnly}
onChange={(e) => setSettings({ ...settings, upcomingTodayOnly: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.sharedSection")}</h3>
</div>
<div className="setting-row compact" style={{ marginTop: "10px" }}>
<div className="setting-label">
<span>{t("settings.timeline.shareMedicationOverview")}</span>
<span
className="info-tooltip small"
data-tooltip={t("settings.timeline.shareMedicationOverviewDesc")}
>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareMedicationOverview}
onChange={(e) => setSettings({ ...settings, shareMedicationOverview: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-row compact" style={{ marginTop: "10px" }}>
<div className="setting-label">
<span>{t("settings.timeline.shareScheduleTodayOnly")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.shareScheduleTodayOnlyDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareScheduleTodayOnly}
onChange={(e) => setSettings({ ...settings, shareScheduleTodayOnly: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</article>
{/* Export/Import Section */}
<article className="card" data-testid="settings-danger-zone-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,
refills: importResult.refills,
shares: importResult.shares,
})}
</span>
</div>
<button
type="button"
onClick={() => setImportResult(null)}
aria-label={t("common.close")}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "1.2em",
padding: "0",
lineHeight: "1",
color: "inherit",
opacity: 0.7,
}}
>
×
</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>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={
hasExistingData ? (
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
) : (
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
)
}
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant={hasExistingData ? "danger" : "primary"}
/>
)}
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
</section>
);
}