Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
253
components/admin/CalendarManager.tsx
Normal file
253
components/admin/CalendarManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
components/admin/CreateCalendarForm.tsx
Normal file
71
components/admin/CreateCalendarForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
components/admin/EditCalendarForm.tsx
Normal file
107
components/admin/EditCalendarForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
components/admin/EditCalendarModal.tsx
Normal file
219
components/admin/EditCalendarModal.tsx
Normal 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 "Gebucht".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
113
components/admin/InviteManager.tsx
Normal file
113
components/admin/InviteManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
components/admin/InviteSection.tsx
Normal file
159
components/admin/InviteSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
components/admin/MemberSection.tsx
Normal file
240
components/admin/MemberSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
components/admin/UserManagement.tsx
Normal file
288
components/admin/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user