Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
'use client';
/**
* Kalenderverwaltung (Admin): alle Kalender als Karten.
*
* - Klick auf die Karte oeffnet das Kalender-Modal (Tag/Woche/Monat
* mit Reservierungen ansehen/anlegen/loeschen).
* - Stift-Icon oeffnet ein Bearbeiten-Modal (PATCH /api/calendars/[id]).
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell';
import { CalendarCard, getCardGradient } from '@/components/calendar/CalendarCard';
import { CalendarModal } from '@/components/calendar/CalendarModal';
import { EditCalendarModal } from '@/components/admin/EditCalendarModal';
/** Daten eines Kalenders fuer die Karten-Ansicht (serverseitig geladen). */
export interface ManagedCalendar {
id: string;
title: string;
description: string | null;
timezone: string;
isActive: boolean;
ownerEmail: string;
isOwn: boolean;
memberCount: number;
reservationCount: number;
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
maxReservationMinutes: number;
/** Duerven Member die Titel fremder Reservierungen sehen? */
showTitleToMembers: boolean;
/** Duerven Member die Notizen fremder Reservierungen sehen? */
showNotesToMembers: boolean;
}
/** Formularwerte zum Erstellen/Bearbeiten eines Kalenders. */
interface CalendarFormValues {
title: string;
description: string | null;
timezone: string;
}
export function CalendarManager({
initialCalendars,
currentUserId,
isAdmin,
}: {
initialCalendars: ManagedCalendar[];
currentUserId: string;
isAdmin: boolean;
}) {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
/** Aktuell im Bearbeiten-Modal geoeffneter Kalender (null = geschlossen). */
const [editingCalendar, setEditingCalendar] = useState<ManagedCalendar | null>(null);
/** Aktuell im Kalender-Modal geoeffneter Kalender (null = geschlossen). */
const [openCalendar, setOpenCalendar] = useState<ManagedCalendar | null>(null);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Erstellt einen neuen Kalender (POST /api/calendars). */
async function createCalendar(values: CalendarFormValues) {
setIsPending(true);
setError(null);
try {
const response = await fetch('/api/calendars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Erstellen fehlgeschlagen.');
return;
}
setIsCreating(false);
router.refresh();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsPending(false);
}
}
/**
* Aktualisiert einen Kalender: wird von EditCalendarModal selbst
* ausgefuehrt (PATCH inkl. router.refresh()); hier nur Schliessen.
*/
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Kalender verwalten</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{initialCalendars.length} Kalender · eigene und Kalender anderer Benutzer
</p>
</div>
<Button
type="button"
onClick={() => {
setError(null);
setIsCreating(true);
}}
>
+ Kalender erstellen
</Button>
</div>
{error && !isCreating && !editingCalendar && !openCalendar && (
<ErrorMessage message={error} />
)}
{initialCalendars.length === 0 ? (
<Card>
<p className="text-sm text-slate-600 dark:text-slate-400">
Noch keine Kalender vorhanden. Erstelle deinen ersten Kalender.
</p>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{initialCalendars.map((calendar, index) => (
<li key={calendar.id}>
<CalendarCard
title={calendar.title}
subtitle={calendar.isOwn ? 'Mein Kalender' : calendar.ownerEmail}
isActive={calendar.isActive}
memberCount={calendar.memberCount}
href={`/admin/calendars/${calendar.id}`}
linkLabel={`Kalender ${calendar.title} verwalten`}
gradient={getCardGradient(index)}
iconTitle={`${calendar.memberCount} Mitglieder · ${calendar.reservationCount} Reservierungen`}
editLabel={`Kalender ${calendar.title} bearbeiten`}
onEdit={() => {
setError(null);
setEditingCalendar(calendar);
}}
onOpen={() => {
setError(null);
setOpenCalendar(calendar);
}}
/>
</li>
))}
</ul>
)}
{openCalendar && (
<CalendarModal
key={openCalendar.id}
calendarId={openCalendar.id}
calendarTitle={openCalendar.title}
currentUserId={currentUserId}
isAdmin={isAdmin}
maxReservationMinutes={openCalendar.maxReservationMinutes}
onClose={() => setOpenCalendar(null)}
/>
)}
{isCreating && (
<CreateCalendarModal
isPending={isPending}
error={error}
onClose={() => {
setIsCreating(false);
setError(null);
}}
onCreate={createCalendar}
/>
)}
{editingCalendar && (
<EditCalendarModal
key={editingCalendar.id}
calendar={editingCalendar}
onClose={() => {
setEditingCalendar(null);
setError(null);
}}
/>
)}
</div>
);
}
/** Modal: Neuen Kalender erstellen (POST /api/calendars). */
function CreateCalendarModal({
isPending,
error,
onClose,
onCreate,
}: {
isPending: boolean;
error: string | null;
onClose: () => void;
onCreate: (values: CalendarFormValues) => void;
}) {
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
onCreate({
title: String(formData.get('title') ?? '').trim(),
description: String(formData.get('description') ?? '').trim() || null,
timezone: String(formData.get('timezone') ?? '').trim(),
});
}
return (
<ModalShell
title="Kalender erstellen"
ariaLabel="Kalender erstellen"
isPending={isPending}
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} />
<div>
<Label htmlFor="calendar-title">Titel</Label>
<Input id="calendar-title" name="title" type="text" required maxLength={120} />
</div>
<div>
<Label htmlFor="calendar-description">Beschreibung (optional)</Label>
<Input
id="calendar-description"
name="description"
type="text"
maxLength={2000}
/>
</div>
<div>
<Label htmlFor="calendar-timezone">Zeitzone</Label>
<Input
id="calendar-timezone"
name="timezone"
type="text"
required
defaultValue="Europe/Berlin"
maxLength={64}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
Abbrechen
</Button>
<Button disabled={isPending}>{isPending ? 'Erstellen …' : 'Erstellen'}</Button>
</div>
</form>
</ModalShell>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
/** Formular zum Erstellen eines Kalenders (nur Admin). */
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
export function CreateCalendarForm() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setError(null);
const formData = new FormData(event.currentTarget);
try {
const response = await fetch('/api/calendars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
description: formData.get('description') || undefined,
timezone: formData.get('timezone'),
}),
});
if (!response.ok) {
const data = (await response.json()) as { error?: string };
setError(data.error ?? 'Erstellen fehlgeschlagen.');
return;
}
router.push('/dashboard');
router.refresh();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setBusy(false);
}
}
return (
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} />
<div>
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" type="text" required maxLength={120} />
</div>
<div>
<Label htmlFor="description">Beschreibung (optional)</Label>
<Input id="description" name="description" type="text" maxLength={2000} />
</div>
<div>
<Label htmlFor="timezone">Zeitzone</Label>
<Input
id="timezone"
name="timezone"
type="text"
required
defaultValue="Europe/Berlin"
maxLength={64}
/>
</div>
<Button disabled={busy}>{busy ? 'Erstellen …' : 'Kalender erstellen'}</Button>
</form>
</Card>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
export function EditCalendarForm({
calendarId,
initialTitle,
initialDescription,
initialTimezone,
initialIsActive,
}: {
calendarId: string;
initialTitle: string;
initialDescription: string;
initialTimezone: string;
initialIsActive: boolean;
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setError(null);
const formData = new FormData(event.currentTarget);
try {
const response = await fetch(`/api/calendars/${calendarId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
description: formData.get('description') || null,
timezone: formData.get('timezone'),
isActive: formData.get('isActive') === 'on',
}),
});
if (!response.ok) {
const data = (await response.json()) as { error?: string };
setError(data.error ?? 'Speichern fehlgeschlagen.');
return;
}
router.refresh();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setBusy(false);
}
}
return (
<Card>
<h2 className="mb-4 text-lg font-semibold">Kalender bearbeiten</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} />
<div>
<Label htmlFor="title">Titel</Label>
<Input
id="title"
name="title"
type="text"
required
maxLength={120}
defaultValue={initialTitle}
/>
</div>
<div>
<Label htmlFor="description">Beschreibung</Label>
<Input
id="description"
name="description"
type="text"
maxLength={2000}
defaultValue={initialDescription}
/>
</div>
<div>
<Label htmlFor="timezone">Zeitzone</Label>
<Input
id="timezone"
name="timezone"
type="text"
required
maxLength={64}
defaultValue={initialTimezone}
/>
</div>
<div className="flex items-center gap-2">
<input
id="isActive"
name="isActive"
type="checkbox"
defaultChecked={initialIsActive}
className="h-4 w-4"
/>
<Label htmlFor="isActive">Kalender aktiv</Label>
</div>
<Button disabled={busy}>{busy ? 'Speichern …' : 'Speichern'}</Button>
</form>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
/**
* Modal zum Bearbeiten eines Kalenders (PATCH /api/calendars/[id])
* inkl. Mitgliederverwaltung (DB-Suche) und Reservierungsregeln.
*
* Einstellungen:
* - Titel, Beschreibung, Zeitzone, Aktiv-Status
* - Maximale Reservierungsdauer (15-Minuten-Schritte, 0 = unbegrenzt)
* - Sichtbarkeit von Titel/Notizen fremder Reservierungen fuer Member
* - Mitglieder per E-Mail-Suche hinzufuegen/entfernen
*
* Selbststaendig: fuehrt den PATCH selbst aus und aktualisiert die
* Seite per router.refresh(). Wird von der Kalenderverwaltung und vom
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell';
import { MemberSection } from '@/components/admin/MemberSection';
import type { ManagedCalendar } from '@/components/admin/CalendarManager';
/** Auswahl der maximalen Reservierungsdauer in Minuten. */
const MAX_DURATION_OPTIONS = [
{ value: 30, label: '30 Minuten' },
{ value: 45, label: '45 Minuten' },
{ value: 60, label: '1 Stunde' },
{ value: 90, label: '1,5 Stunden' },
{ value: 120, label: '2 Stunden' },
{ value: 180, label: '3 Stunden' },
{ value: 240, label: '4 Stunden' },
{ value: 480, label: '8 Stunden' },
{ value: 720, label: '12 Stunden' },
{ value: 1440, label: '24 Stunden' },
{ value: 2880, label: '48 Stunden' },
{ value: 4320, label: '72 Stunden' },
{ value: 0, label: 'Unbegrenzt' },
] as const;
export function EditCalendarModal({
calendar,
isAdmin = true,
onClose,
}: {
calendar: ManagedCalendar;
/** Nur Admins sehen Einstellungen und Mitgliederverwaltung. */
isAdmin?: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Sendet die Formularwerte an PATCH /api/calendars/[id]. */
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsPending(true);
setError(null);
const formData = new FormData(event.currentTarget);
try {
const response = await fetch(`/api/calendars/${calendar.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: String(formData.get('title') ?? '').trim(),
description: String(formData.get('description') ?? '').trim() || null,
timezone: String(formData.get('timezone') ?? '').trim(),
isActive: formData.get('isActive') === 'on',
maxReservationMinutes: Number(formData.get('maxReservationMinutes')),
showTitleToMembers: formData.get('showTitleToMembers') === 'on',
showNotesToMembers: formData.get('showNotesToMembers') === 'on',
}),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Speichern fehlgeschlagen.');
return;
}
router.refresh();
onClose();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsPending(false);
}
}
return (
<ModalShell
title="Kalender verwalten"
ariaLabel={`Kalender ${calendar.title} verwalten`}
isPending={isPending}
size="lg"
onClose={onClose}
>
<form id="edit-calendar-form" onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} />
<div>
<Label htmlFor="edit-calendar-title">Titel</Label>
<Input
id="edit-calendar-title"
name="title"
type="text"
required
maxLength={120}
defaultValue={calendar.title}
/>
</div>
<div>
<Label htmlFor="edit-calendar-description">Beschreibung</Label>
<Input
id="edit-calendar-description"
name="description"
type="text"
maxLength={2000}
defaultValue={calendar.description ?? ''}
/>
</div>
<div>
<Label htmlFor="edit-calendar-timezone">Zeitzone</Label>
<Input
id="edit-calendar-timezone"
name="timezone"
type="text"
required
maxLength={64}
defaultValue={calendar.timezone}
/>
</div>
<div className="flex items-center gap-2">
<input
id="edit-calendar-is-active"
name="isActive"
type="checkbox"
defaultChecked={calendar.isActive}
className="h-4 w-4"
/>
<Label htmlFor="edit-calendar-is-active">Kalender aktiv</Label>
</div>
{/* Reservierungsregeln. */}
<fieldset className="rounded-md border border-slate-200 p-4 dark:border-slate-700">
<legend className="px-1 text-sm font-semibold">Reservierungsregeln</legend>
<div className="mb-3">
<Label htmlFor="edit-calendar-max-duration">
Maximale Reservierungsdauer
</Label>
<select
id="edit-calendar-max-duration"
name="maxReservationMinutes"
defaultValue={calendar.maxReservationMinutes}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
>
{MAX_DURATION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
id="edit-calendar-show-title"
name="showTitleToMembers"
type="checkbox"
defaultChecked={calendar.showTitleToMembers}
className="h-4 w-4"
/>
<Label htmlFor="edit-calendar-show-title">
Member sehen Titel aller Reservierungen
</Label>
</div>
<div className="mt-2 flex items-center gap-2">
<input
id="edit-calendar-show-notes"
name="showNotesToMembers"
type="checkbox"
defaultChecked={calendar.showNotesToMembers}
className="h-4 w-4"
/>
<Label htmlFor="edit-calendar-show-notes">
Member sehen Notizen aller Reservierungen
</Label>
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Ohne Haken sehen Member nur ihre eigenen Titel/Notizen; fremde
Reservierungen erscheinen als &quot;Gebucht&quot;.
</p>
</fieldset>
<p className="text-xs text-slate-500 dark:text-slate-400">
{calendar.memberCount} Mitglieder · {calendar.reservationCount} Reservierungen
</p>
</form>
{isAdmin && (
<div className="mt-6 border-t border-slate-200 pt-4 dark:border-slate-700">
<MemberSection calendarId={calendar.id} />
</div>
)}
{/* Aktionsleiste unten im Modal (ausserhalb des Formulars; der
Speichern-Button ist via form-Attribut mit dem Formular
verbunden und loest dessen Submit aus). */}
<div className="mt-6 flex justify-end gap-2 border-t border-slate-200 pt-4 dark:border-slate-700">
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" form="edit-calendar-form" disabled={isPending}>
{isPending ? 'Speichern …' : 'Speichern'}
</Button>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
/**
* Invite-Verwaltung (plan.md Abschnitt 3): Link erstellen, Liste mit
* Status (Aktiv/Abgelaufen/Verwendet) und Zaehleruebersicht.
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
export interface InviteRow {
id: string;
expiresAt: Date | null;
usedAt: Date | null;
createdAt: Date;
}
export function InviteManager({
calendarId,
invites,
}: {
calendarId: string;
invites: InviteRow[];
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
async function createInvite() {
setBusy(true);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendarId }),
});
if (!response.ok) {
const data = (await response.json()) as { error?: string };
setError(data.error ?? 'Erstellen fehlgeschlagen.');
return;
}
const data = (await response.json()) as { inviteUrl: string };
setLastInviteUrl(data.inviteUrl);
router.refresh();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setBusy(false);
}
}
const now = new Date();
const active = invites.filter(
(invite) =>
invite.usedAt === null &&
(invite.expiresAt === null || invite.expiresAt > now),
).length;
const expired = invites.filter(
(invite) =>
invite.usedAt === null &&
invite.expiresAt !== null &&
invite.expiresAt <= now,
).length;
const used = invites.filter((invite) => invite.usedAt !== null).length;
return (
<Card>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Einladungen</h2>
<Button type="button" variant="secondary" disabled={busy} onClick={createInvite}>
{busy ? 'Erstelle …' : '+ Benutzer einladen'}
</Button>
</div>
<ErrorMessage message={error} />
{lastInviteUrl && (
<div className="mb-4 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200">
<p className="mb-1 font-medium">Neuer Invite-Link (einmalig anzeigen):</p>
<code className="break-all text-xs">{lastInviteUrl}</code>
</div>
)}
<div className="mb-4 flex gap-4 text-sm">
<Badge variant="success">Aktiv: {active}</Badge>
<Badge variant="warning">Abgelaufen: {expired}</Badge>
<Badge variant="neutral">Verwendet: {used}</Badge>
</div>
{invites.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Einladungen erstellt.</p>
) : (
<ul className="space-y-1 text-sm text-slate-600">
{invites.slice(0, 10).map((invite) => (
<li key={invite.id} className="flex justify-between rounded bg-slate-50 px-3 py-1.5">
<span>Erstellt {invite.createdAt.toLocaleDateString('de-DE')}</span>
<span>
{invite.usedAt
? 'Verwendet'
: invite.expiresAt && invite.expiresAt <= now
? 'Abgelaufen'
: 'Aktiv'}
</span>
</li>
))}
</ul>
)}
</Card>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
/**
* Einladungs-Sektion fuer Modals: laedt die Einladungen eines
* Kalenders per GET /api/calendars/[id]/invites und ermoeglicht das
* Erstellen neuer Invite-Links (POST, gleicher Endpoint).
*
* Der Klartext-Link wird nur einmal direkt nach dem Erstellen
* angezeigt (in der DB liegt nur der Hash - plan.md Abschnitt 9).
* Wird als eigenstaendige Komponente gefuehrt, damit sie sowohl im
* Bearbeiten-Modal als auch kuenftig anderswo verwendet werden kann.
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, ErrorMessage, Badge } from '@/components/ui/ui';
/** Einladung, wie der GET-Endpoint sie liefert (Datumswerte als ISO). */
interface InviteApiRow {
id: string;
expiresAt: string | null;
usedAt: string | null;
createdAt: string;
}
export function InviteSection({ calendarId }: { calendarId: string }) {
const [invites, setInvites] = useState<InviteApiRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
/** Laedt die Einladungen des Kalenders. */
const loadInvites = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/invites`);
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Einladungen konnten nicht geladen werden.');
return;
}
const data = (await response.json()) as { invites: InviteApiRow[] };
setInvites(data.invites);
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsLoading(false);
}
}, [calendarId]);
useEffect(() => {
void loadInvites();
}, [loadInvites]);
/** Erstellt einen neuen Invite-Link und zeigt ihn einmalig an. */
async function createInvite() {
setIsCreating(true);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendarId }),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Erstellen fehlgeschlagen.');
return;
}
const data = (await response.json()) as { inviteUrl: string };
setLastInviteUrl(data.inviteUrl);
await loadInvites();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsCreating(false);
}
}
const now = new Date();
const active = invites.filter(
(invite) =>
invite.usedAt === null &&
(invite.expiresAt === null || new Date(invite.expiresAt) > now),
).length;
const expired = invites.filter(
(invite) =>
invite.usedAt === null &&
invite.expiresAt !== null &&
new Date(invite.expiresAt) <= now,
).length;
const used = invites.filter((invite) => invite.usedAt !== null).length;
return (
<section aria-label="Einladungen">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold">Einladungen</h3>
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={createInvite}
>
{isCreating ? 'Erstelle …' : '+ Benutzer einladen'}
</Button>
</div>
<ErrorMessage message={error} />
{lastInviteUrl && (
<div className="mb-3 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200">
<p className="mb-1 font-medium">Neuer Invite-Link (einmalig anzeigen):</p>
<code className="break-all text-xs">{lastInviteUrl}</code>
</div>
)}
{!isLoading && (
<div className="mb-3 flex gap-3">
<Badge variant="success">Aktiv: {active}</Badge>
<Badge variant="warning">Abgelaufen: {expired}</Badge>
<Badge variant="neutral">Verwendet: {used}</Badge>
</div>
)}
{isLoading ? (
<p className="text-sm text-slate-500">Einladungen werden geladen …</p>
) : invites.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Noch keine Einladungen erstellt.
</p>
) : (
<ul className="space-y-1 text-sm text-slate-600 dark:text-slate-300">
{invites.slice(0, 10).map((invite) => {
const isUsed = invite.usedAt !== null;
const isExpired =
!isUsed && invite.expiresAt !== null && new Date(invite.expiresAt) <= now;
return (
<li
key={invite.id}
className="flex justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
>
<span>
Erstellt {new Date(invite.createdAt).toLocaleDateString('de-DE')}
</span>
<span>
{isUsed
? 'Verwendet'
: isExpired
? 'Abgelaufen'
: 'Aktiv'}
</span>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
/**
* Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem).
*
* Statt Invite-Links werden bestehende Benutzer direkt per E-Mail
* ueber die Datenbank gesucht und dem Kalender zugeordnet:
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
*
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ErrorMessage } from '@/components/ui/ui';
/** Mitglied, wie der Members-Endpoint es liefert. */
interface MemberRow {
id: string;
createdAt: string;
user: { id: string; email: string; username: string | null };
}
/** Treffer der Benutzer-Suche. */
interface UserSuggestion {
id: string;
email: string;
username: string | null;
}
export function MemberSection({ calendarId }: { calendarId: string }) {
const [members, setMembers] = useState<MemberRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [addingEmail, setAddingEmail] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Laedt die aktuelle Mitgliederliste. */
const loadMembers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/members`);
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
return;
}
const data = (await response.json()) as { members: MemberRow[] };
setMembers(data.members);
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsLoading(false);
}
}, [calendarId]);
useEffect(() => {
void loadMembers();
}, [loadMembers]);
/** Entfernt die Suchvorschlaege, wenn das Suchfeld den Fokus verliert.
* Die kleine Verzoegerung stellt sicher, dass Klicks auf Vorschlaege
* (die nach dem Blur-Event kommen) noch verarbeitet werden. */
function hideSuggestionsDelayed() {
window.setTimeout(() => setSuggestions([]), 150);
}
/** Fuegt einen Benutzer per E-Mail zum Kalender hinzu. */
async function addMember(email: string) {
setAddingEmail(email);
setError(null);
try {
const response = await fetch(`/api/calendars/${calendarId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Hinzufuegen fehlgeschlagen.');
return;
}
setQuery('');
setSuggestions([]);
await loadMembers();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setAddingEmail(null);
}
}
/** Entfernt ein Mitglied aus dem Kalender. */
async function removeMember(memberId: string) {
setRemovingId(memberId);
setError(null);
try {
const response = await fetch(
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Entfernen fehlgeschlagen.');
return;
}
await loadMembers();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setRemovingId(null);
}
}
/** Live-Suche mit 300 ms Debounce (schont die Datenbank). */
function handleQueryChange(nextQuery: string) {
setQuery(nextQuery);
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
if (nextQuery.trim().length < 2) {
setSuggestions([]);
return;
}
searchTimer.current = setTimeout(async () => {
setIsSearching(true);
try {
const response = await fetch(
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
);
if (response.ok) {
const data = (await response.json()) as { users: UserSuggestion[] };
setSuggestions(data.users);
}
} catch {
// Suche ist optional; Fehler still ignorieren, Vorschlaege bleiben leer.
} finally {
setIsSearching(false);
}
}, 300);
}
return (
<section aria-label="Mitglieder">
<h3 className="mb-3 text-base font-semibold">
Mitglieder ({members.length})
</h3>
<ErrorMessage message={error} />
{/* Suchfeld mit DB-Anbindung (Live-Vorschlaege). */}
<div className="relative mb-3">
<input
type="search"
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
onBlur={hideSuggestionsDelayed}
placeholder="Benutzer per E-Mail suchen und hinzufuegen …"
aria-label="Benutzer suchen"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
{(suggestions.length > 0 || isSearching) && query.trim().length >= 2 && (
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
{isSearching && (
<li className="px-3 py-2 text-sm text-slate-500">Suche …</li>
)}
{suggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => addMember(suggestion.email)}
disabled={addingEmail !== null}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-slate-100 disabled:opacity-50 dark:hover:bg-slate-700"
>
<span>
{suggestion.username ?? suggestion.email}
{suggestion.username && (
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
{suggestion.email}
</span>
)}
</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{addingEmail === suggestion.email ? 'Fuege hinzu …' : '+ Hinzufuegen'}
</span>
</button>
</li>
))}
{!isSearching && suggestions.length === 0 && (
<li className="px-3 py-2 text-sm text-slate-500">
Kein Benutzer gefunden.
</li>
)}
</ul>
)}
</div>
{isLoading ? (
<p className="text-sm text-slate-500">Mitglieder werden geladen …</p>
) : members.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Noch keine Mitglieder. Suche oben nach Benutzern, um sie hinzuzufuegen.
</p>
) : (
<ul className="space-y-1 text-sm">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
>
<span className="text-slate-700 dark:text-slate-200">
{member.user.username ?? member.user.email}
{member.user.username && (
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
{member.user.email}
</span>
)}
</span>
<button
type="button"
onClick={() => removeMember(member.id)}
disabled={removingId === member.id}
aria-label={`Mitglied ${member.user.username ?? member.user.email} entfernen`}
className="text-xs font-medium text-slate-500 transition-colors hover:text-red-600 disabled:opacity-50 dark:text-slate-400 dark:hover:text-red-400"
>
{removingId === member.id ? 'Entferne …' : 'Entfernen'}
</button>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
/**
* Benutzerverwaltung (Client): Liste aller Benutzer mit Edit-Modal.
* Im Modal laesst sich die Rolle aendern (PATCH /api/users/[id]);
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
* (Server lehnt ab).
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell';
interface ManagedUser {
id: string;
email: string;
username: string | null;
role: 'ADMIN' | 'USER';
createdAt: string;
memberships: { calendar: { id: string; title: string; isActive: boolean } }[];
ownedCalendars: { id: string; title: string; isActive: boolean }[];
}
export function UserManagement({ currentUserId }: { currentUserId: string }) {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
/** Aktuell im Edit-Modal geoeffneter Benutzer (null = geschlossen). */
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const loadUsers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/users', { cache: 'no-store' });
if (!response.ok) {
const data = (await response.json().catch(() => null)) as
| { error?: string }
| null;
throw new Error(data?.error ?? 'Benutzer konnten nicht geladen werden.');
}
const data = (await response.json()) as { users: ManagedUser[] };
setUsers(data.users);
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: 'Benutzer konnten nicht geladen werden.',
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
/**
* Aktualisiert die Rolle eines Benutzers und schliesst bei Erfolg
* das Modal. Fehler werden im Modal (falls offen) bzw. inline
* angezeigt.
*/
async function changeRole(userId: string, nextRole: 'ADMIN' | 'USER') {
setPendingId(userId);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }),
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as
| { error?: string }
| null;
throw new Error(data?.error ?? 'Rolle konnte nicht geaendert werden.');
}
const data = (await response.json()) as { user: ManagedUser };
setUsers((previous) =>
previous.map((user) =>
user.id === data.user.id ? { ...user, role: data.user.role } : user,
),
);
setEditingUser((previous) =>
previous && previous.id === data.user.id
? { ...previous, role: data.user.role }
: previous,
);
} catch (changeError) {
setError(
changeError instanceof Error
? changeError.message
: 'Rolle konnte nicht geaendert werden.',
);
} finally {
setPendingId(null);
}
}
if (isLoading) {
return (
<Card>
<p className="text-sm text-slate-600 dark:text-slate-400">
Benutzer werden geladen...
</p>
</Card>
);
}
return (
<div className="space-y-4">
{error && <ErrorMessage message={error} />}
{users.length === 0 ? (
<Card>
<p className="text-sm text-slate-600 dark:text-slate-400">
Noch keine Benutzer registriert.
</p>
</Card>
) : (
<ul className="space-y-3">
{users.map((user) => {
const isSelf = user.id === currentUserId;
return (
<li key={user.id}>
<Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium text-slate-900 dark:text-slate-100">
{user.username ?? user.email}
{user.username && (
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
{user.email}
</span>
)}
{isSelf && (
<span className="ml-2">
<Badge variant="accent">Du</Badge>
</span>
)}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
Registriert am{' '}
{new Date(user.createdAt).toLocaleDateString('de-DE')}
{user.ownedCalendars.length > 0 &&
` · ${user.ownedCalendars.length} Kalender`}
{user.memberships.length > 0 &&
` · Mitglied in ${user.memberships.length} Kalender(n)`}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setError(null);
setEditingUser(user);
}}
aria-label={`Benutzer ${user.email} bearbeiten`}
title="Bearbeiten"
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{/* Stift-Icon (2D, Outline-Stil). */}
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
</svg>
</button>
</div>
</div>
</Card>
</li>
);
})}
</ul>
)}
{editingUser && (
<EditUserModal
user={editingUser}
isSelf={editingUser.id === currentUserId}
isPending={pendingId === editingUser.id}
error={error}
onClose={() => {
setEditingUser(null);
setError(null);
}}
onSave={(nextRole) => changeRole(editingUser.id, nextRole)}
/>
)}
</div>
);
}
/**
* Modal zum Bearbeiten eines Benutzers (derzeit: Rolle).
* Schliessen via Abbrechen, X-Button, ESC oder Klick auf den Hintergrund.
*/
function EditUserModal({
user,
isSelf,
isPending,
error,
onClose,
onSave,
}: {
user: ManagedUser;
isSelf: boolean;
isPending: boolean;
error: string | null;
onClose: () => void;
onSave: (nextRole: 'ADMIN' | 'USER') => void;
}) {
const [selectedRole, setSelectedRole] = useState<'ADMIN' | 'USER'>(user.role);
return (
<ModalShell
title="Benutzer bearbeiten"
ariaLabel={`Benutzer ${user.email} bearbeiten`}
isPending={isPending}
onClose={onClose}
>
<div className="mb-4">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{user.username ?? user.email}
{user.username && (
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
{user.email}
</span>
)}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
Registriert am{' '}
{new Date(user.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
<div className="mb-4">
<label
htmlFor="edit-user-role"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Rolle
</label>
<select
id="edit-user-role"
value={selectedRole}
disabled={isSelf || isPending}
onChange={(event) =>
setSelectedRole(event.target.value as 'ADMIN' | 'USER')
}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
>
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
</select>
{isSelf && (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Die eigene Rolle kann nicht geaendert werden.
</p>
)}
</div>
{error && <ErrorMessage message={error} />}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
Abbrechen
</Button>
<Button
type="button"
disabled={isSelf || isPending || selectedRole === user.role}
onClick={() => onSave(selectedRole)}
>
{isPending ? 'Speichern …' : 'Speichern'}
</Button>
</div>
</ModalShell>
);
}