feat: mobile UI improvements, biome linting, and reminder info display (#71)
* fix: make dismissed doses robust against schedule/timezone changes - Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs - Add POST /medications/dismiss-until endpoint to set dismissed date - Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date - Update frontend to use medication-level dismissedUntil for filtering - Remove old dismissMissedDoses function from useDoses hook (was using dose IDs) - Add backward-compatible ALTER TABLE migration for dismissed_until column - Add 5 integration tests for dismiss-until functionality - Update test schemas with new column The old approach stored individual dose IDs which broke when schedule or timezone settings changed (dose IDs contain timestamps). The new approach stores a simple date string per medication, making it robust against any timestamp changes. * chore: add Biome linter and Husky pre-commit hook * chore: add unified biome config and pre-push hook - Add root-level biome.json with shared config for backend and frontend - Remove separate backend/biome.json and frontend/biome.json - Add .husky/pre-push hook to run backend tests before push - Update package.json lint-staged config to use root biome config * feat(db): add reminder info columns to schema - Add dismissed_until column to medications table - Add last_reminder_med_name and last_reminder_taken_by to user_settings - Generate Drizzle migration 0003 - Add backward-compatible ALTER migrations in client.ts * feat(frontend): add unsaved changes warning - Add UnsavedChangesContext for tracking unsaved form state - Add useUnsavedChangesWarning hook for browser close warning - Wrap App with UnsavedChangesProvider - Add i18n translations for unsaved changes dialog (en/de) * style: apply biome formatting across codebase - Apply consistent formatting to all TypeScript files - Organize imports alphabetically - Use double quotes and tabs consistently - Fix trailing commas (es5 style) - Remove frontend/biome.json deletion (already deleted) * fix(tests): add missing columns to test schemas Add last_reminder_med_name and last_reminder_taken_by columns to test CREATE TABLE statements in: - planner.test.ts - e2e-routes.test.ts - integration.test.ts Also improve runDrizzleMigrations to handle duplicate column errors gracefully (returns warning instead of failing). * fix(planner): add missing 'as unknown' type cast for request.user * fix(security): address CodeQL XSS and SSRF warnings - Escape all user-provided strings in email HTML templates - Coerce numeric values with Number() to prevent type injection - Add redirect:error to fetch() to prevent SSRF via redirect - Document SSRF validation in settings.ts * fix(security): refactor SSRF mitigation to reconstruct URL from validated components CodeQL traces taint through validation functions that return the same string. Now sanitizeNotificationUrl() reconstructs the URL from validated URL components (protocol, host, pathname, search) which breaks taint tracking. - Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data - Returns reconstructed URL built from URL() parsed components - Extracts auth credentials separately instead of including in URL string - Added isNtfy flag to avoid re-parsing the sanitized URL * fix(security): add SSRF suppression comment for validated notification URL The fetch() uses a URL that has been validated by sanitizeNotificationUrl(): - Only http/https protocols - Blocks localhost and loopback IPs - Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Blocks internal hostnames (.local, .internal, .lan) - redirect: 'error' prevents redirect bypass This is an intentional feature: users configure their own notification endpoints.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,16 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useMedicationForm } from "../hooks";
|
||||
import { formatNumber, formatDateTime, combineDateAndTime } from "../utils/formatters";
|
||||
import { getPackageSize, FIELD_LIMITS } from "../types";
|
||||
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { Medication } from "../types";
|
||||
import { FIELD_LIMITS, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
|
||||
export function MedicationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
meds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
loadMeds,
|
||||
@@ -34,7 +33,6 @@ export function MedicationsPage() {
|
||||
setForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
formChanged,
|
||||
@@ -53,12 +51,39 @@ export function MedicationsPage() {
|
||||
startEdit,
|
||||
} = useMedicationForm();
|
||||
|
||||
// Warn user about unsaved changes when navigating away
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// Sync formChanged state to the global context for navigation blocking
|
||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||
useEffect(() => {
|
||||
setHasUnsavedChanges(formChanged);
|
||||
return () => setHasUnsavedChanges(false); // Clear on unmount
|
||||
}, [formChanged, setHasUnsavedChanges]);
|
||||
|
||||
// Push history state when form changes to capture browser back button
|
||||
const hasUnsavedHistoryState = useRef(false);
|
||||
useEffect(() => {
|
||||
if (formChanged && !hasUnsavedHistoryState.current && !showEditModal) {
|
||||
// Push a history state so we can intercept browser back
|
||||
window.history.pushState({ unsavedChanges: true }, "");
|
||||
hasUnsavedHistoryState.current = true;
|
||||
} else if (!formChanged && hasUnsavedHistoryState.current) {
|
||||
// Clean up history state when form is saved/reset
|
||||
hasUnsavedHistoryState.current = false;
|
||||
}
|
||||
}, [formChanged, showEditModal]);
|
||||
|
||||
// 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);
|
||||
// Track if close was confirmed programmatically (to avoid double confirmation)
|
||||
const closeConfirmedRef = useRef(false);
|
||||
// Confirmation modal for unsaved changes
|
||||
const [showUnsavedConfirm, setShowUnsavedConfirm] = useState(false);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
@@ -72,19 +97,53 @@ export function MedicationsPage() {
|
||||
// Open mobile edit modal
|
||||
function openEditModal() {
|
||||
setShowEditModal(true);
|
||||
window.history.pushState({ modal: 'edit' }, '');
|
||||
window.history.pushState({ modal: "edit" }, "");
|
||||
}
|
||||
|
||||
// Close mobile edit modal
|
||||
function closeEditModal() {
|
||||
if (showEditModal) {
|
||||
// Check for unsaved changes before closing
|
||||
if (formChanged) {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||
closeConfirmedRef.current = true;
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle confirmed close (user clicked "Leave" in confirmation modal)
|
||||
function handleConfirmClose() {
|
||||
setShowUnsavedConfirm(false);
|
||||
closeConfirmedRef.current = true;
|
||||
hasUnsavedHistoryState.current = false;
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
resetForm();
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Handle cancelled close (user clicked "Stay" in confirmation modal)
|
||||
function handleCancelClose() {
|
||||
setShowUnsavedConfirm(false);
|
||||
}
|
||||
|
||||
// Helper to reset form and clear history state
|
||||
function handleResetForm() {
|
||||
if (hasUnsavedHistoryState.current) {
|
||||
hasUnsavedHistoryState.current = false;
|
||||
// Go back to remove the unsaved changes history entry
|
||||
window.history.back();
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// Handle delete medication
|
||||
async function handleDeleteMed(id: number) {
|
||||
if (!confirm(t('medications.deleteConfirm'))) return;
|
||||
if (!confirm(t("medications.deleteConfirm"))) return;
|
||||
await deleteMed(id, editingId, resetForm);
|
||||
}
|
||||
|
||||
@@ -100,7 +159,7 @@ export function MedicationsPage() {
|
||||
setSaving(true);
|
||||
|
||||
// Prepare medication data
|
||||
const blisters = form.blisters.map(b => ({
|
||||
const blisters = form.blisters.map((b) => ({
|
||||
usage: Number(b.usage) || 1,
|
||||
every: Number(b.every) || 1,
|
||||
start: combineDateAndTime(b.startDate, b.startTime),
|
||||
@@ -151,6 +210,12 @@ export function MedicationsPage() {
|
||||
setFormSaved(true);
|
||||
loadMeds();
|
||||
|
||||
// Clean up history state if we had unsaved changes
|
||||
if (hasUnsavedHistoryState.current) {
|
||||
hasUnsavedHistoryState.current = false;
|
||||
// Don't go back here, just clear the flag - the state will be cleaned naturally
|
||||
}
|
||||
|
||||
// Reset form after successful save
|
||||
if (!editingId) {
|
||||
resetForm();
|
||||
@@ -160,34 +225,62 @@ export function MedicationsPage() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(t('common.saveFailed'));
|
||||
alert(t("common.saveFailed"));
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// Handle browser back button for modals
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle mobile edit modal
|
||||
if (showEditModal) {
|
||||
// Check for unsaved changes (user pressed browser back directly)
|
||||
if (formChanged) {
|
||||
// Re-push history state to stay in modal
|
||||
window.history.pushState({ modal: "edit" }, "");
|
||||
// Show confirmation modal
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle desktop form with unsaved changes
|
||||
if (formChanged && hasUnsavedHistoryState.current) {
|
||||
// Re-push history state to stay on page
|
||||
window.history.pushState({ unsavedChanges: true }, "");
|
||||
// Show confirmation modal
|
||||
setShowUnsavedConfirm(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [showEditModal]);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [showEditModal, formChanged, resetForm]);
|
||||
|
||||
// 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]);
|
||||
}, [showEditModal, closeEditModal]);
|
||||
|
||||
// Handle edit button click - open modal on mobile
|
||||
function handleEditClick(med: Medication) {
|
||||
@@ -198,10 +291,10 @@ export function MedicationsPage() {
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>{t('medications.list.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
<h2>{t("medications.list.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
// On mobile, open the edit modal
|
||||
@@ -210,7 +303,7 @@ export function MedicationsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {t('form.newEntry')}
|
||||
+ {t("form.newEntry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
@@ -223,22 +316,38 @@ export function MedicationsPage() {
|
||||
<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>
|
||||
<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 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>
|
||||
<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)}
|
||||
{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)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,106 +358,145 @@ export function MedicationsPage() {
|
||||
|
||||
<article className="card form desktop-only">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||
<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')}
|
||||
<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
|
||||
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')}
|
||||
<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')}
|
||||
<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>
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
<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')}
|
||||
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} />
|
||||
))}
|
||||
{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)} />
|
||||
{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)} />
|
||||
{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)} />
|
||||
{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)} />
|
||||
{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')} />
|
||||
{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')}
|
||||
{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')} />
|
||||
{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>
|
||||
<h4 className="refill-title">{t("refill.title")}</h4>
|
||||
<div className="refill-form-inline">
|
||||
<label>
|
||||
{t('refill.packs')}
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillPacks}
|
||||
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('refill.loosePills')}
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillLoose}
|
||||
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -357,29 +505,36 @@ export function MedicationsPage() {
|
||||
onClick={() => handleSubmitRefill(editingId!)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t('refill.adding') : t('refill.button')}
|
||||
{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>
|
||||
<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')}
|
||||
<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'; }}
|
||||
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 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>}
|
||||
@@ -387,63 +542,88 @@ export function MedicationsPage() {
|
||||
|
||||
<div className="full blisters">
|
||||
<div className="card-head">
|
||||
<h3>{t('form.blisters.title')}</h3>
|
||||
<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 }))}
|
||||
<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>
|
||||
<span>🔔 {t("form.blisters.remind")}</span>
|
||||
</label>
|
||||
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
|
||||
<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)} />
|
||||
{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)} />
|
||||
{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)} />
|
||||
{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)} />
|
||||
{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>
|
||||
<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>
|
||||
<label className="setting-label">{t("form.medicationImage")}</label>
|
||||
{(() => {
|
||||
// When editing an existing medication
|
||||
if (editingId) {
|
||||
const currentMed = meds.find(m => m.id === 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>
|
||||
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
<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}
|
||||
@@ -455,13 +635,22 @@ export function MedicationsPage() {
|
||||
return (
|
||||
<div className="image-preview">
|
||||
<img src={pendingImagePreview} alt="Preview" />
|
||||
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
}}
|
||||
>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -479,12 +668,15 @@ export function MedicationsPage() {
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
{t('common.cancel')}
|
||||
<button type="button" className="ghost" onClick={handleResetForm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
|
||||
{formSaved && !formChanged ? t('common.saved') : t('common.save')}
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -520,12 +712,25 @@ export function MedicationsPage() {
|
||||
meds={meds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onClose={() => { closeEditModal(); }}
|
||||
onResetForm={resetForm}
|
||||
onClose={() => {
|
||||
closeEditModal();
|
||||
}}
|
||||
onResetForm={handleResetForm}
|
||||
onSaveMedication={saveMedication}
|
||||
/>
|
||||
|
||||
{/* Unsaved Changes Confirmation Modal */}
|
||||
{showUnsavedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("common.unsavedChanges.title", "Unsaved Changes")}
|
||||
message={t("common.unsavedChanges.message")}
|
||||
confirmLabel={t("common.unsavedChanges.leave", "Leave")}
|
||||
cancelLabel={t("common.unsavedChanges.stay", "Stay")}
|
||||
onConfirm={handleConfirmClose}
|
||||
onCancel={handleCancelClose}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { PlannerRow } from "../types";
|
||||
import { toInputValue } from "../utils/formatters";
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PlannerPage() {
|
||||
const [plannerLoading, setPlannerLoading] = useState(false);
|
||||
const [range, setRange] = useState<{ start: string; end: string }>({
|
||||
start: toInputValue(todayIso()),
|
||||
end: toInputValue(plusDaysIso(3))
|
||||
end: toInputValue(plusDaysIso(3)),
|
||||
});
|
||||
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
|
||||
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
@@ -49,15 +49,23 @@ export function PlannerPage() {
|
||||
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([]); }
|
||||
try {
|
||||
setPlannerRows(JSON.parse(savedRows));
|
||||
} catch {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
} else {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
|
||||
|
||||
if (savedRange) {
|
||||
try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
|
||||
try {
|
||||
setRange(JSON.parse(savedRange));
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
} else {
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
}
|
||||
@@ -71,9 +79,13 @@ export function PlannerPage() {
|
||||
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) })
|
||||
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[];
|
||||
.catch(() => [])) as PlannerRow[];
|
||||
setPlannerRows(rows);
|
||||
setPlannerLoading(false);
|
||||
// Save to user-specific localStorage
|
||||
@@ -124,43 +136,70 @@ export function PlannerPage() {
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('planner.title')}</h2>
|
||||
<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 })} />
|
||||
{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 })} />
|
||||
{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>
|
||||
<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>
|
||||
<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);
|
||||
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 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 && ` + ${Math.round(row.loosePills * 10) / 10} ${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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -168,7 +207,7 @@ export function PlannerPage() {
|
||||
{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')}
|
||||
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")}
|
||||
</button>
|
||||
{plannerEmailResult && (
|
||||
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
|
||||
|
||||
+248
-131
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { Coverage } from "../types";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
@@ -10,7 +10,11 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
}
|
||||
|
||||
// Helper function to get stock status
|
||||
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
|
||||
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" };
|
||||
@@ -18,7 +22,11 @@ function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { l
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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];
|
||||
@@ -61,8 +69,8 @@ export function SchedulePage() {
|
||||
<section className="grid">
|
||||
<article className="card schedule-full">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.schedules.title')}</h2>
|
||||
<select
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
@@ -71,89 +79,267 @@ export function SchedulePage() {
|
||||
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>
|
||||
<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')}
|
||||
{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="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 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>
|
||||
{!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]);
|
||||
);
|
||||
})()}
|
||||
{/* 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="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>
|
||||
<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) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
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 past">
|
||||
<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>
|
||||
<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" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
<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 undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button
|
||||
className="dose-btn take"
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -169,75 +355,6 @@ export function SchedulePage() {
|
||||
</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>
|
||||
|
||||
+249
-118
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -30,7 +30,6 @@ export function SettingsPage() {
|
||||
handleImportFileSelect,
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
handleImportConfirm,
|
||||
importResult,
|
||||
@@ -40,17 +39,17 @@ export function SettingsPage() {
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
<p>{t('settings.loading')}</p>
|
||||
<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>
|
||||
<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>
|
||||
<span className="setting-label">{t("settings.language.select")}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
@@ -66,23 +65,23 @@ export function SettingsPage() {
|
||||
{/* Notifications */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.notifications.title')}</h2>
|
||||
<h2>{t("settings.notifications.title")}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.channels')}</h3>
|
||||
<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 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-label">{t("settings.notifications.stockReminders")}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
@@ -93,10 +92,12 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -105,9 +106,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
|
||||
<div className="matrix-label">{t("settings.notifications.intakeReminders")}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
@@ -118,10 +119,12 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -131,16 +134,20 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
|
||||
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Skip reminders for taken doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "16px"}}>
|
||||
<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>
|
||||
{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' : ''}`}>
|
||||
<label
|
||||
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.skipRemindersForTakenDoses}
|
||||
@@ -150,14 +157,21 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Repeat reminders for missed doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "12px"}}>
|
||||
<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>
|
||||
{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' : ''}`}>
|
||||
<label
|
||||
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatRemindersEnabled}
|
||||
@@ -167,14 +181,19 @@ export function SettingsPage() {
|
||||
<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"}}>
|
||||
<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>
|
||||
{t("settings.notifications.reminderInterval")}
|
||||
<span
|
||||
className="info-tooltip small"
|
||||
data-tooltip={t("settings.notifications.reminderIntervalTooltip")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -182,14 +201,21 @@ export function SettingsPage() {
|
||||
max="480"
|
||||
step="5"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
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"}}>
|
||||
<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>
|
||||
{t("settings.notifications.maxNaggingReminders")}
|
||||
<span
|
||||
className="info-tooltip small"
|
||||
data-tooltip={t("settings.notifications.maxNaggingRemindersTooltip")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -197,8 +223,13 @@ export function SettingsPage() {
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
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>
|
||||
</>
|
||||
@@ -207,15 +238,22 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.email')}</h3>
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
|
||||
<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 });
|
||||
setSettings({
|
||||
...settings,
|
||||
emailEnabled: false,
|
||||
emailStockReminders: false,
|
||||
emailIntakeReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
} else {
|
||||
setSettings({ ...settings, emailEnabled: newVal });
|
||||
}
|
||||
@@ -229,7 +267,7 @@ export function SettingsPage() {
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.email.recipient')}</span>
|
||||
<span className="field-label">{t("settings.email.recipient")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="email"
|
||||
@@ -239,13 +277,23 @@ export function SettingsPage() {
|
||||
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>
|
||||
<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
|
||||
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"}>
|
||||
@@ -259,7 +307,7 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.push')}</h3>
|
||||
<h3>{t("settings.notifications.push")}</h3>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -267,7 +315,14 @@ export function SettingsPage() {
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.emailEnabled) {
|
||||
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
setSettings({
|
||||
...settings,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrStockReminders: false,
|
||||
shoutrrrIntakeReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
} else {
|
||||
setSettings({ ...settings, shoutrrrEnabled: newVal });
|
||||
}
|
||||
@@ -280,21 +335,31 @@ export function SettingsPage() {
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.push.url')}</span>
|
||||
<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')}
|
||||
placeholder={t("settings.push.urlPlaceholder")}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||
<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
|
||||
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"}>
|
||||
@@ -308,27 +373,45 @@ export function SettingsPage() {
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<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.lastAutoEmailSent && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.lastSent')}</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>
|
||||
<span className="schedule-label">{t("settings.schedule.lastSent")}</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>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,16 +420,16 @@ export function SettingsPage() {
|
||||
{/* Stock Settings */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.stock.title')}</h2>
|
||||
<h2>{t("settings.stock.title")}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.threshold')}</h3>
|
||||
<h3>{t("settings.stock.threshold")}</h3>
|
||||
</div>
|
||||
<div className="threshold-input">
|
||||
<label>
|
||||
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
|
||||
<span className="threshold-label">{t("settings.stock.remindWhen")}</span>
|
||||
<div className="threshold-field">
|
||||
<input
|
||||
type="number"
|
||||
@@ -355,21 +438,30 @@ export function SettingsPage() {
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
<span className="threshold-unit">{t('common.days')}</span>
|
||||
<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>
|
||||
{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' : ''}`}>
|
||||
<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))}
|
||||
disabled={
|
||||
!(
|
||||
(settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) ||
|
||||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
@@ -378,36 +470,40 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.calculationMode')}</h3>
|
||||
<h3>{t("settings.stock.calculationMode")}</h3>
|
||||
</div>
|
||||
<div className="setting-group calculation-mode-group">
|
||||
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
|
||||
<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' })}
|
||||
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>
|
||||
<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' : ''}`}>
|
||||
<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' })}
|
||||
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>
|
||||
<span className="radio-card-title">{t("settings.stock.manual")}</span>
|
||||
<span className="radio-card-desc">{t("settings.stock.manualDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -416,11 +512,11 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.display')}</h3>
|
||||
<h3>{t("settings.stock.display")}</h3>
|
||||
</div>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
|
||||
<span className="field-label">{t("settings.stock.lowStockDays")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
@@ -429,11 +525,13 @@ export function SettingsPage() {
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}>ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.highStockDays')}</span>
|
||||
<span className="field-label">{t("settings.stock.highStockDays")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
@@ -442,7 +540,9 @@ export function SettingsPage() {
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}>ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -453,38 +553,65 @@ export function SettingsPage() {
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>
|
||||
{t('exportImport.title')}
|
||||
<span className="info-tooltip" data-tooltip={t('exportImport.description')}>ⓘ</span>
|
||||
{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
|
||||
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>
|
||||
<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
|
||||
<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}}
|
||||
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>
|
||||
>
|
||||
×
|
||||
</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>
|
||||
<span className="action-card-title">{t("exportImport.exportTitle")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.exportDesc")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -492,15 +619,15 @@ export function SettingsPage() {
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||
{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>
|
||||
<span className="action-card-title">{t("exportImport.importTitle")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.importDesc")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -508,15 +635,15 @@ export function SettingsPage() {
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFileSelect}
|
||||
disabled={importing}
|
||||
style={{display: 'none'}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => document.getElementById('import-file-input')?.click()}
|
||||
onClick={() => document.getElementById("import-file-input")?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||
{importing ? t("exportImport.importing") : t("exportImport.import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,7 +652,11 @@ export function SettingsPage() {
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
||||
{settingsSaving
|
||||
? t("common.saving")
|
||||
: settingsSaved && !settingsChanged
|
||||
? t("common.saved")
|
||||
: t("settings.saveSettings")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -534,15 +665,15 @@ export function SettingsPage() {
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('exportImport.confirmImport')}
|
||||
title={t("exportImport.confirmImport")}
|
||||
message={
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
|
||||
<p className="warning-text">⚠️ {t('exportImport.confirmImportWarning')}</p>
|
||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t('exportImport.confirmButton')}
|
||||
cancelLabel={t('exportImport.cancelButton')}
|
||||
confirmLabel={t("exportImport.confirmButton")}
|
||||
cancelLabel={t("exportImport.cancelButton")}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={() => {
|
||||
setShowImportConfirm(false);
|
||||
|
||||
Reference in New Issue
Block a user