feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)

- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
@@ -0,0 +1,108 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
listReminders,
} from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry';
import { ReminderFormModal } from './reminder-form-modal';
/** Abstand der Auffrischung beim Server. */
const REFRESH_MS = 60_000;
/**
* Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des
* Benutzers, die naechste Faelligkeit zuerst, dazu „Neue Erinnerung“. Die
* Benachrichtigung selbst macht der globale `ReminderNotifier` im Portal-
* Rahmen — die Kachel muss dafuer nicht sichtbar sein. Nach jeder Aenderung
* laedt sie neu und meldet `REMINDERS_CHANGED_EVENT`, damit der Melder eine
* neue Faelligkeit sofort kennt.
*/
export function ReminderWidget(_props: WidgetProps) {
const t = useTranslations('widgets');
const locale = useLocale();
const [reminders, setReminders] = useState<Reminder[] | null>(null);
const [loadError, setLoadError] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const refetch = useCallback(async () => {
try {
setReminders(await listReminders());
setLoadError(false);
} catch {
setLoadError(true);
}
}, []);
useEffect(() => {
void refetch();
const timer = setInterval(() => void refetch(), REFRESH_MS);
const onChanged = () => void refetch();
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
return () => {
clearInterval(timer);
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
};
}, [refetch]);
const sorted = useMemo(
() =>
[...(reminders ?? [])].sort(
(a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime(),
),
[reminders],
);
const formatter = useMemo(
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
[locale],
);
const handleSaved = () => {
void refetch();
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
};
return (
<div className="flex h-full flex-col gap-2 p-3" data-testid="reminder-widget">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
{loadError && (
<p role="alert" className="text-sm text-destructive">
{t('reminder.loadError')}
</p>
)}
{!loadError && reminders !== null && sorted.length === 0 && (
<p className="text-sm text-muted-foreground">{t('reminder.empty')}</p>
)}
{sorted.map((r) => (
<div
key={r.id}
data-testid="reminder-row"
className="rounded-md border border-border bg-background/60 px-3 py-2"
>
<p className="truncate text-sm font-medium text-foreground">{r.title}</p>
<p className="text-xs text-muted-foreground">{formatter.format(new Date(r.dueAt))}</p>
{r.description !== '' && (
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
{r.description}
</p>
)}
</div>
))}
</div>
<button
type="button"
onClick={() => setModalOpen(true)}
className="btn btn-secondary shrink-0 self-start"
>
{t('reminder.add')}
</button>
{modalOpen && (
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
)}
</div>
);
}