feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste

- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage
- MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung
- Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:10:06 +02:00
parent 709b41a007
commit 6879c756f2
21 changed files with 1038 additions and 21 deletions
@@ -7,8 +7,10 @@ import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
} from '@/lib/reminders-api';
@@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) {
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
const refetch = useCallback(async () => {
try {
@@ -55,6 +59,18 @@ export function ReminderWidget(_props: WidgetProps) {
}
}, []);
useEffect(() => {
let cancelled = false;
getReminderEmailStatus()
.then((status) => {
if (!cancelled) setEmailStatus(status);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void refetch();
const refresh = setInterval(() => void refetch(), REFRESH_MS);
@@ -160,8 +176,33 @@ export function ReminderWidget(_props: WidgetProps) {
: 'rounded-md border border-border bg-background/60 px-3 py-2'
}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
{r.emailEnabled && (
<span
className="shrink-0 text-muted-foreground"
role="img"
aria-label={t('reminder.emailOn')}
title={t('reminder.emailOn')}
data-testid="reminder-email-icon"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</span>
)}
{due && (
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
{t('reminder.due')}
@@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) {
{modal && (
<ReminderFormModal
reminder={modal.reminder}
emailStatus={emailStatus}
onClose={() => setModal(null)}
onSaved={afterMutation}
onStale={() => void refetch()}