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,179 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time';
import { ReminderRequestError, createReminder } from '@/lib/reminders-api';
interface ReminderFormModalProps {
onClose: () => void;
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
onSaved: () => void;
}
const TITLE_MAX = 200;
const DESCRIPTION_MAX = 2000;
/**
* Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind
* Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt.
*
* Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem
* `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr
* macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild
* `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`.
*
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im
* Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
* Laden der Seite.
*/
export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) {
const t = useTranslations('widgets');
const initial = useRef(defaultNewReminderInputs(new Date())).current;
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
requestBrowserPermissionOnce();
setError(null);
const trimmedTitle = title.trim();
if (trimmedTitle === '') {
setError(t('reminder.titleRequired'));
return;
}
const dueAt = localInputsToIso(date, time);
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
setError(t('reminder.pastError'));
return;
}
setSaving(true);
try {
await createReminder({ title: trimmedTitle, description: description.trim(), dueAt });
onSaved();
onClose();
} catch (err) {
setError(
err instanceof ReminderRequestError && err.status === 409
? t('reminder.limitReached')
: t('reminder.saveError'),
);
} finally {
setSaving(false);
}
};
if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="reminder-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
{t('reminder.formTitle')}
</h2>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="flex gap-3">
<div className="flex-1 space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium text-foreground">
{t('reminder.dateLabel')}
</label>
<input
id="reminder-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium text-foreground">
{t('reminder.timeLabel')}
</label>
<input
id="reminder-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="reminder-title" className="text-sm font-medium text-foreground">
{t('reminder.titleLabel')}
</label>
<input
id="reminder-title"
ref={titleRef}
type="text"
maxLength={TITLE_MAX}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-description" className="text-sm font-medium text-foreground">
{t('reminder.descriptionLabel')}
</label>
<textarea
id="reminder-description"
rows={3}
maxLength={DESCRIPTION_MAX}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="btn btn-secondary">
{t('reminder.cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{t('reminder.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}