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:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user