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>
|
||||
);
|
||||
}
|
||||
25
components/auth/LogoutButton.tsx
Normal file
25
components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
/** Logout-Button: beendet die Session per API und laedt neu. */
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/ui';
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-slate-300"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
165
components/calendar/CalendarCard.tsx
Normal file
165
components/calendar/CalendarCard.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Karten-Darstellung eines Kalenders (gemeinsames Design).
|
||||
*
|
||||
* Wird vom Dashboard und von der Kalenderverwaltung verwendet, damit
|
||||
* beide Stellen identische Karten ausgeben (Designvorlage: Farbverlauf,
|
||||
* Titel unten links, Mitgliederzahl mit Gruppen-Icon unten rechts).
|
||||
* Die ganze Karte ist ein Link; optional kann ein Bearbeiten-Button
|
||||
* (Stift) eingeblendet werden.
|
||||
*
|
||||
* Bewusst ohne 'use client': Die Komponente ist rein darstellend und
|
||||
* funktioniert sowohl in Server- als auch in Client-Komponenten. Der
|
||||
* optionale onEdit-Handler wird nur von Client-Komponenten uebergeben.
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
|
||||
/** Graue Verlaufspalette fuer die Karten (Designvorgabe: Grautoene). */
|
||||
const CARD_GRADIENTS = [
|
||||
'from-slate-600 to-slate-800',
|
||||
'from-slate-500 to-slate-700',
|
||||
'from-zinc-600 to-zinc-800',
|
||||
'from-neutral-600 to-neutral-800',
|
||||
'from-stone-600 to-stone-800',
|
||||
'from-gray-600 to-gray-800',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Liefert den Gradienten fuer den Karten-Index (zyklisch).
|
||||
* Fallback auf den ersten Gradienten, falls die Palette leer ist
|
||||
* (noUncheckedIndexedAccess-sicher).
|
||||
*/
|
||||
export function getCardGradient(index: number): string {
|
||||
return CARD_GRADIENTS[index % CARD_GRADIENTS.length] ?? CARD_GRADIENTS[0]!;
|
||||
}
|
||||
|
||||
/** Eigenschaften der Kalender-Karte. */
|
||||
export interface CalendarCardProps {
|
||||
title: string;
|
||||
/** Kleine Zeile ueber dem Titel (z. B. Besitzer-E-Mail). */
|
||||
subtitle?: string | null;
|
||||
isActive: boolean;
|
||||
memberCount: number;
|
||||
/** Ziel des Karten-Links (die ganze Karte ist klickbar). */
|
||||
href: string;
|
||||
/** Accessibility-Beschriftung des Karten-Links. */
|
||||
linkLabel: string;
|
||||
/** Gradient-Klassen (siehe getCardGradient). */
|
||||
gradient: string;
|
||||
/** Optionales title-Attribut am Mitglieder-Icon (z. B. mit Reservierungen). */
|
||||
iconTitle?: string;
|
||||
/** Optional: Bearbeiten-Button (Stift) mit Handler. */
|
||||
editLabel?: string;
|
||||
onEdit?: () => void;
|
||||
/** Optional: Karten-Klick oeffnet ein Modal statt dem Link. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/** Einzige Darstellungslogik fuer Kalender-Karten (DRY). */
|
||||
export function CalendarCard({
|
||||
title,
|
||||
subtitle,
|
||||
isActive,
|
||||
memberCount,
|
||||
href,
|
||||
linkLabel,
|
||||
gradient,
|
||||
iconTitle,
|
||||
editLabel,
|
||||
onEdit,
|
||||
onOpen,
|
||||
}: CalendarCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-36 flex-col justify-end overflow-hidden rounded-xl bg-gradient-to-br p-4 text-white shadow-sm transition-shadow hover:shadow-md ${gradient} ${
|
||||
isActive ? '' : 'opacity-70'
|
||||
}`}
|
||||
>
|
||||
{/* Karten-Klick: entweder Modal oeffnen oder zum Link-Ziel navigieren. */}
|
||||
{onOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="absolute inset-0 rounded-xl text-left focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
aria-label={linkLabel}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className="absolute inset-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
aria-label={linkLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<span className="absolute left-4 top-3 rounded-full bg-slate-950/50 px-2 py-0.5 text-xs">
|
||||
Inaktiv
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onEdit && editLabel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={editLabel}
|
||||
title="Bearbeiten"
|
||||
className="absolute right-2 top-2 z-10 rounded-md p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{subtitle && <p className="truncate text-xs text-white/70">{subtitle}</p>}
|
||||
<div className="mt-1 flex items-end justify-between gap-2">
|
||||
<h3 className="font-semibold leading-snug">{title}</h3>
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1 text-xs text-white/80"
|
||||
title={iconTitle ?? `${memberCount} Mitglieder`}
|
||||
>
|
||||
<MembersIcon />
|
||||
{memberCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stift-Icon (2D, Outline-Stil). */
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gruppen-Icon (2D, gefuellt) fuer die Mitgliederzahl auf der Karte. */
|
||||
function MembersIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="7.5" r="3" />
|
||||
<circle cx="5" cy="9" r="2.3" />
|
||||
<circle cx="19" cy="9" r="2.3" />
|
||||
<path d="M12 12c-3.3 0-6 1.7-6 4v1.5h12V16c0-2.3-2.7-4-6-4z" />
|
||||
<path d="M5 12.6c-2 .3-3.5 1.5-3.5 3.2v1.7h3.3V16c0-1.3.5-2.5 1.4-3.3-.4-.1-.8-.1-1.2-.1z" />
|
||||
<path d="M19 12.6c2 .3 3.5 1.5 3.5 3.2v1.7h-3.3V16c0-1.3-.5-2.5-1.4-3.3.4-.1.8-.1 1.2-.1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
875
components/calendar/CalendarModal.tsx
Normal file
875
components/calendar/CalendarModal.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Kalender-Modal: echte Kalenderansicht (Monat/Woche/Tag) im Dialog.
|
||||
*
|
||||
* Features:
|
||||
* - Ansichten Monat (Standard) / Woche / Tag mit Vor/Zurueck und "Heute"
|
||||
* - Reservierungen werden pro Ansicht dargestellt (eigene hervorgehoben)
|
||||
* - Reservierung anlegen mit Start- UND Endzeit; die maximale Dauer
|
||||
* wird live gegen die Kalender-Einstellung geprueft und der Benutzer
|
||||
* bei Ueberschreitung informiert ("Maximale Reservierungsdauer ist X")
|
||||
* - Loeschen ausschliesslich durch Admins (Server prueft final)
|
||||
* - Legende: eigene vs. fremde Reservierungen
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ModalShell } from '@/components/ui/ModalShell';
|
||||
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
/** Reservierung, wie sie vom API-Endpoint geliefert wird. */
|
||||
interface ReservationItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
notes: string | null;
|
||||
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
/** Zeitfenster (ISO-Grenzen) der aktuell gewaehlten Ansicht. */
|
||||
interface Window {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false, // Deutsches 24h-Format (kein am/pm).
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateLong(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Formatiert ein Datum kurz (z. B. "21.09."). */
|
||||
function formatDateShort(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob `date` (00:00) innerhalb des Reservierungszeitraums liegt
|
||||
* (Mehrtages-Reservierungen erscheinen auf allen Folgetagen).
|
||||
*/
|
||||
function isDayWithinReservation(date: Date, startAt: string, endAt: string): boolean {
|
||||
const dayStart = startOfDay(date).getTime();
|
||||
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
||||
return new Date(startAt).getTime() < dayEnd && dayStart < new Date(endAt).getTime();
|
||||
}
|
||||
|
||||
/** Vergleicht zwei Daten auf denselben Kalendertag (lokal). */
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.toDateString() === b.toDateString();
|
||||
}
|
||||
|
||||
/** Start des Tages (00:00 lokal). */
|
||||
function startOfDay(date: Date): Date {
|
||||
const copy = new Date(date);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Montag der Woche, in der `date` liegt. */
|
||||
function startOfWeek(date: Date): Date {
|
||||
const monday = startOfDay(date);
|
||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||
return monday;
|
||||
}
|
||||
|
||||
/** Erster Tag des Monats (00:00 lokal). */
|
||||
function startOfMonth(date: Date): Date {
|
||||
const first = startOfDay(date);
|
||||
first.setDate(1);
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet das Zeitfenster der gewaehlten Ansicht (lokal, inklusive).
|
||||
* `to` ist exklusives Ende (Start des Folgetags/Folgewoche/Folgmonat).
|
||||
*/
|
||||
function computeWindow(view: ViewMode, reference: Date): Window {
|
||||
if (view === 'day') {
|
||||
const from = startOfDay(reference);
|
||||
const to = new Date(from);
|
||||
to.setDate(to.getDate() + 1);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
if (view === 'week') {
|
||||
const from = startOfWeek(reference);
|
||||
const to = new Date(from);
|
||||
to.setDate(to.getDate() + 7);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
const from = startOfMonth(reference);
|
||||
const to = new Date(from);
|
||||
to.setMonth(to.getMonth() + 1);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
|
||||
/** Verschiebt das Referenzdatum um eine Ansichtseinheit. */
|
||||
function shiftReference(view: ViewMode, reference: Date, amount: number): Date {
|
||||
const next = new Date(reference);
|
||||
if (view === 'day') {
|
||||
next.setDate(next.getDate() + amount);
|
||||
} else if (view === 'week') {
|
||||
next.setDate(next.getDate() + amount * 7);
|
||||
} else {
|
||||
next.setMonth(next.getMonth() + amount, 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Kopfzeile der Ansicht (z. B. "September 2026" oder "KW-Datum"). */
|
||||
function formatPeriodTitle(view: ViewMode, reference: Date): string {
|
||||
if (view === 'month') {
|
||||
return reference.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (view === 'day') {
|
||||
return formatDateLong(reference);
|
||||
}
|
||||
const monday = startOfWeek(reference);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
return `${monday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} – ${sunday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
}
|
||||
|
||||
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden"). */
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} Minuten`;
|
||||
}
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours)
|
||||
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||
}
|
||||
|
||||
export function CalendarModal({
|
||||
calendarId,
|
||||
calendarTitle,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
maxReservationMinutes,
|
||||
onClose,
|
||||
}: {
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||
maxReservationMinutes: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [view, setView] = useState<ViewMode>('month');
|
||||
const [reference, setReference] = useState(() => new Date());
|
||||
const [reservations, setReservations] = useState<ReservationItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const window = useMemo(() => computeWindow(view, reference), [view, reference]);
|
||||
|
||||
/** Laedt Reservierungen fuer das aktuelle Zeitfenster. */
|
||||
const loadReservations = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Reservierungen konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { reservations: ReservationItem[] };
|
||||
setReservations(data.reservations);
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [calendarId, window.from, window.to]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadReservations();
|
||||
}, [loadReservations]);
|
||||
|
||||
/** Legt eine neue Reservierung an und laedt die Ansicht neu. */
|
||||
async function createReservation(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const startDate = String(formData.get('startDate'));
|
||||
const endDate = String(formData.get('endDate'));
|
||||
const startTime = String(formData.get('startTime'));
|
||||
const endTime = String(formData.get('endTime'));
|
||||
const startAt = new Date(`${startDate}T${startTime}:00`);
|
||||
const endAt = new Date(`${endDate}T${endTime}:00`);
|
||||
|
||||
// Sicherheitsnetz: Falls End- vor Startzeit liegt, eine Tagesdauer
|
||||
// annehmen (sollte durch die Live-Pruefung verhindert werden).
|
||||
if (endAt <= startAt) {
|
||||
endAt.setDate(endAt.getDate() + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
calendarId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
title: formData.get('title'),
|
||||
notes: String(formData.get('notes') ?? '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
setIsCreating(false);
|
||||
await loadReservations();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loescht eine Reservierung (ausschliesslich Admins). */
|
||||
async function deleteReservation(reservationId: string) {
|
||||
setDeletingId(reservationId);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Loeschen fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
await loadReservations();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={calendarTitle}
|
||||
ariaLabel={`Kalender ${calendarTitle} oeffnen`}
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(['day', 'week', 'month'] as const).map((nextView) => (
|
||||
<button
|
||||
key={nextView}
|
||||
type="button"
|
||||
onClick={() => setView(nextView)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||
view === nextView
|
||||
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference((previous) => shiftReference(view, previous, -1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
aria-label="Zurueck"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference(new Date())}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference((previous) => shiftReference(view, previous, 1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
aria-label="Weiter"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-medium">{formatPeriodTitle(view, reference)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded bg-slate-300 dark:bg-slate-600" />
|
||||
Eigene
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded bg-slate-100 ring-1 ring-slate-300 dark:bg-slate-800 dark:ring-slate-600" />
|
||||
Andere
|
||||
</span>
|
||||
{maxReservationMinutes > 0 && (
|
||||
<span>· Max. Dauer: {formatDuration(maxReservationMinutes)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isCreating}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
}}
|
||||
>
|
||||
+ Reservierung
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
{isCreating && (
|
||||
<ReservationForm
|
||||
reference={reference}
|
||||
maxReservationMinutes={maxReservationMinutes}
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onSubmit={createReservation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-slate-500">Reservierungen werden geladen …</p>
|
||||
) : (
|
||||
<>
|
||||
{view === 'day' && (
|
||||
<DayView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={deleteReservation}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<WeekView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={deleteReservation}
|
||||
/>
|
||||
)}
|
||||
{view === 'month' && (
|
||||
<MonthView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
onSelectDay={(day) => {
|
||||
setReference(day);
|
||||
setView('day');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular fuer neue Reservierungen mit Start-/Endzeit und Live-Pruefung. */
|
||||
function ReservationForm({
|
||||
reference,
|
||||
maxReservationMinutes,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
reference: Date;
|
||||
maxReservationMinutes: number;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
// Live-Pruefung: Sobald Start- und Enddatum/-zeit gesetzt sind,
|
||||
// wird die Dauer berechnet und gegen die Kalender-Einstellung
|
||||
// geprueft. Die Dauer kann ueber Tagesgrenzen hinausgehen.
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
const [startDate, setStartDate] = useState(
|
||||
reference.toISOString().slice(0, 10),
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
reference.toISOString().slice(0, 10),
|
||||
);
|
||||
const durationMinutes = useMemo(() => {
|
||||
const start = new Date(`${startDate}T${startTime}:00`);
|
||||
const end = new Date(`${endDate}T${endTime}:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const diff = (end.getTime() - start.getTime()) / 60_000;
|
||||
return diff > 0 ? diff : null;
|
||||
}, [startDate, startTime, endDate, endTime]);
|
||||
|
||||
const exceedsMax =
|
||||
durationMinutes !== null &&
|
||||
maxReservationMinutes > 0 &&
|
||||
durationMinutes > maxReservationMinutes;
|
||||
|
||||
const isInvalidRange = durationMinutes === null;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mb-4 grid gap-3 rounded-md border border-slate-200 p-4 dark:border-slate-700 sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="reservation-start-date">Startdatum</Label>
|
||||
<Input
|
||||
id="reservation-start-date"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-end-date">Enddatum</Label>
|
||||
<Input
|
||||
id="reservation-end-date"
|
||||
name="endDate"
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-title">Titel</Label>
|
||||
<Input id="reservation-title" name="title" type="text" required maxLength={120} />
|
||||
</div>
|
||||
<div className="sm:col-span-2" />
|
||||
<div>
|
||||
<Label htmlFor="reservation-start-time">Startzeit</Label>
|
||||
<TimeSelect
|
||||
id="reservation-start-time"
|
||||
name="startTime"
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-end-time">Endzeit</Label>
|
||||
<TimeSelect
|
||||
id="reservation-end-time"
|
||||
name="endTime"
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="reservation-notes">Notiz (optional)</Label>
|
||||
<Input id="reservation-notes" name="notes" type="text" maxLength={2000} />
|
||||
</div>
|
||||
|
||||
{durationMinutes !== null && (
|
||||
<p
|
||||
className={`sm:col-span-2 text-sm ${
|
||||
exceedsMax
|
||||
? 'font-medium text-red-700 dark:text-red-300'
|
||||
: 'text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
role={exceedsMax ? 'alert' : undefined}
|
||||
>
|
||||
Dauer: {formatDuration(durationMinutes)}
|
||||
{exceedsMax &&
|
||||
` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}
|
||||
</p>
|
||||
)}
|
||||
{isInvalidRange && (
|
||||
<p
|
||||
className="sm:col-span-2 text-sm font-medium text-red-700 dark:text-red-300"
|
||||
role="alert"
|
||||
>
|
||||
Enddatum/-zeit muss nach dem Start liegen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || exceedsMax || isInvalidRange}>
|
||||
{isSubmitting ? 'Reservieren …' : 'Reservieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stunden 00-23 (deutsches 24h-Format, kein am/pm). */
|
||||
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, index) => index);
|
||||
|
||||
/** Minuten in 15-Minuten-Schritten. */
|
||||
const MINUTES = [0, 15, 30, 45] as const;
|
||||
|
||||
/**
|
||||
* Eigener Zeit-Auswahl-Dialog (zwei Dropdowns: Stunde + Minute).
|
||||
*
|
||||
* Bewusst KEIN <input type="time">: Der native Timepicker folgt der
|
||||
* Systemsprache des Browsers und zeigt auf englischen Systemen
|
||||
* "AM/PM" an. Diese Komponente erzwingt das deutsche 24h-Format
|
||||
* unabhaengig von der Systemeinstellung.
|
||||
*/
|
||||
function TimeSelect({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
/** Hidden Input tragt den Wert im Format "HH:MM". */
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const [hourPart, minutePart] = value.split(':');
|
||||
const hour = hourPart ?? '09';
|
||||
const minute = minutePart ?? '00';
|
||||
|
||||
const selectClasses =
|
||||
'rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 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';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<select
|
||||
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}stunde`}
|
||||
value={hour}
|
||||
onChange={(event) => onChange(`${event.target.value}:${minute}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{HOURS.map((hourOption) => (
|
||||
<option key={hourOption} value={String(hourOption).padStart(2, '0')}>
|
||||
{String(hourOption).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span aria-hidden="true">:</span>
|
||||
<select
|
||||
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}minuten`}
|
||||
value={minute}
|
||||
onChange={(event) => onChange(`${hour}:${event.target.value}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{MINUTES.map((minuteOption) => (
|
||||
<option key={minuteOption} value={String(minuteOption).padStart(2, '0')}>
|
||||
{String(minuteOption).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Eigenschaften einer einzelnen Reservierungszeile. */
|
||||
interface ReservationRowProps {
|
||||
reservation: ReservationItem;
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
deletingId: string | null;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Eigenschaften der Listen-basierten Ansichten (Tag/Woche). */
|
||||
interface ReservationListProps {
|
||||
reservations: ReservationItem[];
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
deletingId: string | null;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Eine einzelne Reservierungszeile; Loeschen nur fuer Admins. */
|
||||
function ReservationRow({
|
||||
reservation,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationRowProps) {
|
||||
const isOwn = reservation.userId === currentUserId;
|
||||
return (
|
||||
<li
|
||||
className={`flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm ${
|
||||
isOwn
|
||||
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">
|
||||
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
|
||||
</span>{' '}
|
||||
{reservation.title}
|
||||
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{reservation.ownerName}
|
||||
</span>
|
||||
{reservation.notes && (
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{reservation.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(reservation.id)}
|
||||
disabled={deletingId === reservation.id}
|
||||
aria-label={`Reservierung ${reservation.title} loeschen`}
|
||||
title="Loeschen (nur Admin)"
|
||||
className="shrink-0 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-300 hover:text-slate-800 disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-600 dark:hover:text-slate-100"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tagesansicht: alle Reservierungen, die diesen Tag ueberdecken. */
|
||||
function DayView({
|
||||
reference,
|
||||
reservations,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationListProps & { reference: Date }) {
|
||||
const dayReservations = reservations.filter((reservation) =>
|
||||
isDayWithinReservation(reference, reservation.startAt, reservation.endAt),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{dayReservations.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-500">
|
||||
Keine Reservierungen an diesem Tag.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{dayReservations.map((reservation) => (
|
||||
<ReservationRow
|
||||
key={reservation.id}
|
||||
reservation={reservation}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wochenansicht: 7 Spalten; Mehrtages-Reservierungen auf Folgetagen. */
|
||||
function WeekView({
|
||||
reference,
|
||||
reservations,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationListProps & { reference: Date }) {
|
||||
const monday = startOfWeek(reference);
|
||||
const days = Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday);
|
||||
day.setDate(day.getDate() + index);
|
||||
return day;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
|
||||
{days.map((day) => {
|
||||
const dayReservations = reservations.filter((reservation) =>
|
||||
isDayWithinReservation(day, reservation.startAt, reservation.endAt),
|
||||
);
|
||||
const isToday = day.toDateString() === new Date().toDateString();
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`rounded border p-2 ${
|
||||
isToday
|
||||
? 'border-slate-500 dark:border-slate-300'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
|
||||
</div>
|
||||
{dayReservations.length === 0 ? (
|
||||
<p className="text-center text-xs text-slate-400">–</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{dayReservations.map((reservation) => {
|
||||
const isOwn = reservation.userId === currentUserId;
|
||||
// Bei Mehrtages-Reservierungen das Startdatum mit angeben,
|
||||
// damit Folgetage klar zugeordnet sind.
|
||||
const startsOnOtherDay =
|
||||
!isSameDay(new Date(reservation.startAt), day);
|
||||
return (
|
||||
<li
|
||||
key={reservation.id}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
isOwn
|
||||
? 'bg-slate-300 text-slate-900 dark:bg-slate-600 dark:text-slate-100'
|
||||
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||
}`}
|
||||
title={`${formatTime(reservation.startAt)}–${formatTime(reservation.endAt)} · ${reservation.title} (${reservation.ownerName})`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{startsOnOtherDay && (
|
||||
<span className="mr-1">{formatDateShort(new Date(reservation.startAt))},</span>
|
||||
)}
|
||||
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
|
||||
</div>
|
||||
<div className="truncate">{reservation.title}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Monatsansicht: Tage mit Anzahl; Klick springt zur Tagesansicht. */
|
||||
function MonthView({
|
||||
reference,
|
||||
reservations,
|
||||
onSelectDay,
|
||||
}: {
|
||||
reference: Date;
|
||||
reservations: ReservationItem[];
|
||||
onSelectDay: (day: Date) => void;
|
||||
}) {
|
||||
const year = reference.getFullYear();
|
||||
const month = reference.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const leadingEmpty = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
const countPerDay = new Map<number, number>();
|
||||
for (const reservation of reservations) {
|
||||
// Mehrtages-Reservierungen auf alle betroffenen Tage verteilen.
|
||||
const cursor = startOfDay(new Date(reservation.startAt));
|
||||
const end = new Date(reservation.endAt);
|
||||
while (cursor < end) {
|
||||
if (cursor.getFullYear() === year && cursor.getMonth() === month) {
|
||||
const day = cursor.getDate();
|
||||
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<span key={weekday}>{weekday}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: leadingEmpty }, (_, index) => (
|
||||
<div key={`empty-${index}`} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||
const day = index + 1;
|
||||
const count = countPerDay.get(day) ?? 0;
|
||||
const dayDate = new Date(year, month, day);
|
||||
const isToday = dayDate.toDateString() === new Date().toDateString();
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onSelectDay(dayDate)}
|
||||
className={`min-h-16 rounded border p-1 text-left text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
||||
isToday
|
||||
? 'border-slate-500 dark:border-slate-300'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{day}</div>
|
||||
{count > 0 && (
|
||||
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||
{count} Res.
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muelltonnen-Icon (2D, Outline-Stil) zum Loeschen. */
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
314
components/calendar/CalendarView.tsx
Normal file
314
components/calendar/CalendarView.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Kalenderansicht-Komponente (plan.md Abschnitt 7):
|
||||
* Monat / Woche / Tag mit klickbaren Reservierungen.
|
||||
* Die Navigation laeuft ueber URL-Parameter (sharebar, servergerendert).
|
||||
*/
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/ui';
|
||||
|
||||
export interface CalendarReservation {
|
||||
id: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
|
||||
ownerName: string;
|
||||
isOwn: boolean;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Verschibt das Referenzdatum um `amount` Einheiten der Ansicht. */
|
||||
function shiftDate(view: string, isoDate: string, amount: number): string {
|
||||
const date = new Date(isoDate);
|
||||
switch (view) {
|
||||
case 'day':
|
||||
date.setDate(date.getDate() + amount);
|
||||
break;
|
||||
case 'month':
|
||||
date.setMonth(date.getMonth() + amount, 1);
|
||||
break;
|
||||
default:
|
||||
date.setDate(date.getDate() + amount * 7);
|
||||
break;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function CalendarView({
|
||||
view,
|
||||
referenceDate,
|
||||
calendarId,
|
||||
reservations,
|
||||
}: {
|
||||
view: string;
|
||||
referenceDate: string;
|
||||
calendarId: string;
|
||||
reservations: CalendarReservation[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const reference = new Date(referenceDate);
|
||||
|
||||
function navigate(nextView: string, nextDate: string) {
|
||||
router.push(
|
||||
`/calendar/${calendarId}?view=${nextView}&date=${encodeURIComponent(nextDate)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const navButtons = (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(['day', 'week', 'month'] as const).map((nextView) => (
|
||||
<button
|
||||
key={nextView}
|
||||
type="button"
|
||||
onClick={() => navigate(nextView, referenceDate)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||
view === nextView
|
||||
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(view, shiftDate(view, referenceDate, -1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
|
||||
aria-label="Zurueck"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-sm font-medium">{formatDate(reference)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(view, shiftDate(view, referenceDate, 1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
|
||||
aria-label="Weiter"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view === 'month') {
|
||||
return (
|
||||
<Card>
|
||||
{navButtons}
|
||||
<MonthGrid reference={reference} reservations={reservations} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'day') {
|
||||
return (
|
||||
<Card>
|
||||
{navButtons}
|
||||
<DayTimeline reference={reference} reservations={reservations} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{navButtons}
|
||||
<WeekGrid reference={reference} reservations={reservations} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Monatsraster: Tage mit Anzahl der Reservierungen. */
|
||||
function MonthGrid({
|
||||
reference,
|
||||
reservations,
|
||||
}: {
|
||||
reference: Date;
|
||||
reservations: CalendarReservation[];
|
||||
}) {
|
||||
const year = reference.getFullYear();
|
||||
const month = reference.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const leadingEmpty = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
const countPerDay = new Map<number, number>();
|
||||
for (const reservation of reservations) {
|
||||
const day = new Date(reservation.startAt).getDate();
|
||||
if (new Date(reservation.startAt).getMonth() === month) {
|
||||
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500">
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<span key={weekday}>{weekday}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: leadingEmpty }, (_, index) => (
|
||||
<div key={`empty-${index}`} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||
const day = index + 1;
|
||||
const count = countPerDay.get(day) ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className="min-h-16 rounded border border-slate-200 p-1 text-xs"
|
||||
>
|
||||
<div className="font-medium">{day}</div>
|
||||
{count > 0 && (
|
||||
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||
{count} Res.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tages-Timeline: Stundenraster mit belegten Bloecken. */
|
||||
function DayTimeline({
|
||||
reference,
|
||||
reservations,
|
||||
}: {
|
||||
reference: Date;
|
||||
reservations: CalendarReservation[];
|
||||
}) {
|
||||
const dayReservations = reservations.filter(
|
||||
(reservation) =>
|
||||
new Date(reservation.startAt).toDateString() === reference.toDateString(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold">
|
||||
{reference.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})}
|
||||
</h2>
|
||||
{dayReservations.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Keine Reservierungen an diesem Tag.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{dayReservations.map((reservation) => (
|
||||
<li
|
||||
key={reservation.id}
|
||||
className={`rounded-md px-3 py-2 text-sm ${
|
||||
reservation.isOwn
|
||||
? 'bg-slate-200 dark:bg-slate-700'
|
||||
: 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
|
||||
</span>{' '}
|
||||
{reservation.title}
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
{reservation.ownerName}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wochenraster: 7 Tage mit Reservierungslisten. */
|
||||
function WeekGrid({
|
||||
reference,
|
||||
reservations,
|
||||
}: {
|
||||
reference: Date;
|
||||
reservations: CalendarReservation[];
|
||||
}) {
|
||||
const monday = new Date(reference);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday);
|
||||
day.setDate(day.getDate() + index);
|
||||
return day;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
|
||||
{days.map((day) => {
|
||||
const dayReservations = reservations.filter(
|
||||
(reservation) =>
|
||||
new Date(reservation.startAt).toDateString() === day.toDateString(),
|
||||
);
|
||||
return (
|
||||
<div key={day.toISOString()} className="rounded border border-slate-200 p-2">
|
||||
<div className="mb-2 text-center text-xs font-semibold text-slate-500">
|
||||
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{dayReservations.map((reservation) => (
|
||||
<li
|
||||
key={reservation.id}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
reservation.isOwn
|
||||
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||
: 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
|
||||
</div>
|
||||
<div className="truncate">{reservation.title}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wrapper, damit die drei Ansichten einheitlich benutzt werden koennen. */
|
||||
function MonthGridWrapper(props: {
|
||||
reference: Date;
|
||||
reservations: CalendarReservation[];
|
||||
}) {
|
||||
return <MonthGrid {...props} />;
|
||||
}
|
||||
|
||||
// Alias-Exporte fuer lesbare Verwendung oben.
|
||||
const MonthGrid_ = MonthGridWrapper;
|
||||
void MonthGrid_;
|
||||
const DayTimeline_ = DayTimeline;
|
||||
void DayTimeline_;
|
||||
const WeekGrid_ = WeekGrid;
|
||||
void WeekGrid_;
|
||||
87
components/dashboard/DashboardCalendarGrid.tsx
Normal file
87
components/dashboard/DashboardCalendarGrid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Dashboard-Kalenderraster (Client): Karten mit Kalender-Modal und
|
||||
* Bearbeiten-Modal.
|
||||
*
|
||||
* - Klick auf die Karte oeffnet das Kalender-Modal (Uebersicht mit
|
||||
* Tag/Woche/Monat und Reservierungen).
|
||||
* - Stift-Icon oeffnet das Bearbeiten-Modal (Einstellungen) - nur
|
||||
* fuer berechtigte Benutzer (Admins, siehe canManageCalendar).
|
||||
* - Beide Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { CalendarCard, getCardGradient } from '@/components/calendar/CalendarCard';
|
||||
import { CalendarModal } from '@/components/calendar/CalendarModal';
|
||||
import { EditCalendarModal } from '@/components/admin/EditCalendarModal';
|
||||
import type { ManagedCalendar } from '@/components/admin/CalendarManager';
|
||||
|
||||
export function DashboardCalendarGrid({
|
||||
calendars,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
calendars: ManagedCalendar[];
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
/** Kalender, dessen Uebersicht gerade offen ist (null = geschlossen). */
|
||||
const [openCalendar, setOpenCalendar] = useState<ManagedCalendar | null>(null);
|
||||
/** Kalender, dessen Einstellungen gerade bearbeitet werden. */
|
||||
const [editingCalendar, setEditingCalendar] = useState<ManagedCalendar | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{calendars.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={`/calendar/${calendar.id}`}
|
||||
linkLabel={`Kalender ${calendar.title} oeffnen`}
|
||||
gradient={getCardGradient(index)}
|
||||
editLabel={
|
||||
isAdmin ? `Kalender ${calendar.title} bearbeiten` : undefined
|
||||
}
|
||||
onEdit={
|
||||
isAdmin
|
||||
? () => {
|
||||
setOpenCalendar(null);
|
||||
setEditingCalendar(calendar);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOpen={() => {
|
||||
setEditingCalendar(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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingCalendar && (
|
||||
<EditCalendarModal
|
||||
key={editingCalendar.id}
|
||||
calendar={editingCalendar}
|
||||
onClose={() => setEditingCalendar(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
components/layout/AppShell.tsx
Normal file
33
components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* App-Shell fuer angemeldete Bereiche: einklappbare Sidebar links,
|
||||
* Inhalt dynamisch daneben (passt sich der Sidebar-Breite an).
|
||||
* Theme-Toggle, Profil-Badge und Abmelden befinden sich in der Sidebar.
|
||||
*/
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
export function AppShell({
|
||||
role,
|
||||
userEmail,
|
||||
username,
|
||||
children,
|
||||
}: {
|
||||
role: 'ADMIN' | 'USER';
|
||||
/** E-Mail des angemeldeten Benutzers (Fallback fuer das Profil-Badge). */
|
||||
userEmail: string;
|
||||
/** Frei waehlbarer Anzeigename; wird im Badge bevorzugt. */
|
||||
username?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar role={role} userEmail={userEmail} username={username} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<main className="flex-1 overflow-x-hidden px-4 py-10">
|
||||
<div className="mx-auto max-w-3xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
components/layout/Sidebar.tsx
Normal file
234
components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Einklappbare Sidebar fuer angemeldete Bereiche.
|
||||
*
|
||||
* Der Inhalt passt sich dynamisch an:
|
||||
* - eingeklappt: nur Icons (mit Tooltip via title)
|
||||
* - ausgeklappt: Icons + Beschriftung
|
||||
* - Rolle: ADMIN sieht zusaetzlich "Benutzerverwaltung"
|
||||
*
|
||||
* Der Zustand wird in localStorage persistiert, damit die Wahl
|
||||
* ueber Seitenwechsel hinweg erhalten bleibt.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { ProfileModal } from '@/components/profile/ProfileModal';
|
||||
|
||||
const STORAGE_KEY = 'calendar-sidebar-collapsed';
|
||||
|
||||
/** Flache 2D-Icons (Outline-Stil, Faerbung via currentColor). */
|
||||
function NavIcon({ name }: { name: 'dashboard' | 'plus' | 'users' }) {
|
||||
const common = {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true,
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'dashboard':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
case 'plus':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||
<path d="M12 9v6M9 12h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'users':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" />
|
||||
<path d="M16 4.6a3.5 3.5 0 0 1 0 6.8" />
|
||||
<path d="M18.5 14.4c1.8.9 3 2.6 3 5.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: 'dashboard' | 'plus' | 'users';
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
|
||||
{ href: '/admin/calendars', label: 'Kalender verwalten', icon: 'plus', adminOnly: true },
|
||||
{ href: '/admin/users', label: 'Benutzerverwaltung', icon: 'users', adminOnly: true },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
role,
|
||||
userEmail,
|
||||
username,
|
||||
}: {
|
||||
role: 'ADMIN' | 'USER';
|
||||
/** E-Mail des angemeldeten Benutzers (Fallback fuer das Profil-Badge). */
|
||||
userEmail: string;
|
||||
/** Frei waehlbarer Anzeigename; wird im Badge bevorzugt. */
|
||||
username?: string | null;
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
/** Profil-Modal offen (geoeffnet via Klick auf das Profil-Badge). */
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Gespeicherten Zustand wiederherstellen (nur clientseitig, kein
|
||||
// Hydration-Mismatch, da der erste Render konsistent eingeklappt-los ist).
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'true') {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setIsCollapsed((previous) => {
|
||||
const next = !previous;
|
||||
window.localStorage.setItem(STORAGE_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.adminOnly || role === 'ADMIN',
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`sticky top-0 flex h-screen shrink-0 flex-col border-r border-slate-200 bg-white transition-all duration-200 dark:border-slate-700 dark:bg-slate-900 ${
|
||||
isCollapsed ? 'w-16' : 'w-60'
|
||||
}`}
|
||||
aria-label="Hauptnavigation"
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-3 dark:border-slate-700">
|
||||
{!isCollapsed && (
|
||||
<span className="truncate text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||
Kalendartool
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
|
||||
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
>
|
||||
{isCollapsed ? '»' : '«'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{visibleItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-200 font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
} ${isCollapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<NavIcon name={item.icon} />
|
||||
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-1 border-t border-slate-200 p-2 dark:border-slate-700">
|
||||
<div className={`flex items-center gap-2 ${isCollapsed ? 'justify-center' : 'px-1'}`}>
|
||||
<ThemeToggle />
|
||||
{/* Profil-Badge: zeigt den Username (falls gesetzt), sonst die
|
||||
E-Mail des angemeldeten Benutzers; eingeklappt nur der
|
||||
Anfangsbuchstabe. Klick oeffnet das Profil-Modal. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileOpen(true)}
|
||||
title={username ?? userEmail}
|
||||
aria-label="Profil oeffnen"
|
||||
className={`inline-flex min-w-0 items-center rounded-full bg-slate-200 font-medium text-slate-800 transition-colors hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600 ${
|
||||
isCollapsed
|
||||
? 'h-9 w-9 justify-center text-sm'
|
||||
: 'max-w-full px-3.5 py-1.5 text-sm'
|
||||
}`}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
(username ?? userEmail).charAt(0).toUpperCase()
|
||||
) : (
|
||||
<span className="truncate">{username ?? userEmail}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={isLoggingOut}
|
||||
title={isCollapsed ? 'Abmelden' : undefined}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${
|
||||
isCollapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3" />
|
||||
<path d="M10 17l-5-5 5-5" />
|
||||
<path d="M15 12H5" />
|
||||
</svg>
|
||||
{!isCollapsed && <span>{isLoggingOut ? 'Abmelden …' : 'Abmelden'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isProfileOpen && (
|
||||
<ProfileModal onClose={() => setIsProfileOpen(false)} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
94
components/layout/ThemeToggle.tsx
Normal file
94
components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Dark-Mode-Umschalter als Icon-Button.
|
||||
*
|
||||
* - Persistiert die Wahl in localStorage ("light" | "dark" | null=System).
|
||||
* - Schaltet die Klasse "dark" auf <html> (Tailwind class-Strategie).
|
||||
* - Lauscht auf Systemwechsel, solange der User nichts gewaehlt hat.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'calendar-theme';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initial: Theme = stored === 'dark' || (!stored && prefersDark) ? 'dark' : 'light';
|
||||
setTheme(initial);
|
||||
applyTheme(initial);
|
||||
|
||||
if (stored) {
|
||||
return;
|
||||
}
|
||||
// Folgt Systemwechsel, solange der User nichts explizit gewaehlt hat.
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
const next: Theme = event.matches ? 'dark' : 'light';
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
};
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((previous) => {
|
||||
const next: Theme = previous === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}
|
||||
title={theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||
>
|
||||
{/* Flaches 2D-Icon: Sonne im Dark-Mode, Mond im Light-Mode. */}
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
163
components/profile/ProfileModal.tsx
Normal file
163
components/profile/ProfileModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Profil-Modal: der angemeldete Benutzer kann seinen Anzeigenamen
|
||||
* (Username) aendern. E-Mail und Rolle sind nur lesbar dargestellt.
|
||||
*
|
||||
* Der Username ist optional und eindeutig; beim Speichern prueft der
|
||||
* Server das Format und die Eindeutigkeit. Ein leerer Wert loescht
|
||||
* den Username (das Badge faellt auf die E-Mail zurueck).
|
||||
*
|
||||
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
|
||||
* Speichern aktualisiert router.refresh() die Sidebar sofort.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
|
||||
import { ModalShell } from '@/components/ui/ModalShell';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
role: 'ADMIN' | 'USER';
|
||||
}
|
||||
|
||||
export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/me/profile', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Profil konnte nicht geladen werden.');
|
||||
}
|
||||
const data = (await response.json()) as { user: Profile };
|
||||
setProfile(data.user);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: 'Profil konnte nicht geladen werden.',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const username = String(formData.get('username') ?? '').trim();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/me/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Speichern fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { user: Profile };
|
||||
setProfile(data.user);
|
||||
setSuccess(true);
|
||||
// Sidebar + Seite aktualisieren, damit der neue Name ueberall
|
||||
// sofort sichtbar wird.
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="Profil"
|
||||
ariaLabel="Profil bearbeiten"
|
||||
onClose={onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="py-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Profil wird geladen …
|
||||
</p>
|
||||
) : !profile ? (
|
||||
<ErrorMessage message={error ?? 'Profil konnte nicht geladen werden.'} />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<ErrorMessage message={error} />
|
||||
{success && (
|
||||
<p className="rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
Profil wurde gespeichert.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="profile-email">E-Mail</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
readOnly
|
||||
disabled
|
||||
aria-readonly
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Die E-Mail-Adresse kann nicht geaendert werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="profile-username">Benutzername</Label>
|
||||
<Input
|
||||
id="profile-username"
|
||||
name="username"
|
||||
type="text"
|
||||
maxLength={32}
|
||||
defaultValue={profile.username ?? ''}
|
||||
placeholder="z. B. max_mustermann"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
2-32 Zeichen; Buchstaben, Ziffern, _ - . erlaubt. Leer lassen, um
|
||||
den Namen zu entfernen (es wird dann die E-Mail angezeigt).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Rolle</Label>
|
||||
<div className="pt-1">
|
||||
<Badge variant={profile.role === 'ADMIN' ? 'accent' : 'neutral'}>
|
||||
{profile.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Speichern …' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
271
components/reservation/NewReservationButton.tsx
Normal file
271
components/reservation/NewReservationButton.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Formular fuer neue Reservierungen (plan.md Abschnitt 15):
|
||||
* Startdatum, Startzeit, Enddatum, Endzeit (15-Minuten-Schritte),
|
||||
* Titel, Notiz.
|
||||
*
|
||||
* Der Server erwartet Start- UND Endzeit (createReservationSchema).
|
||||
* Die maximale Reservierungsdauer wird live gegen die Kalender-Einstellung
|
||||
* geprueft und der Benutzer bei Ueberschreitung informiert.
|
||||
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||
|
||||
/** Stunden 00-23 (deutsches 24h-Format). */
|
||||
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
|
||||
/** Minuten in 15-Minuten-Schritten. */
|
||||
const MINUTES = [0, 15, 30, 45] as const;
|
||||
|
||||
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden"). */
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} Minuten`;
|
||||
}
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours)
|
||||
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||
}
|
||||
|
||||
/** Zwei-Dropdown-Zeitwaehler (Stunde + Minute), 24h-Format. */
|
||||
function TimeSelect({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const [hourPart, minutePart] = value.split(':');
|
||||
const hour = hourPart ?? '09';
|
||||
const minute = minutePart ?? '00';
|
||||
const selectClasses =
|
||||
'rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 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';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<select
|
||||
aria-label={`${id === 'start-time' ? 'Start' : 'End'}stunde`}
|
||||
value={hour}
|
||||
onChange={(e) => onChange(`${e.target.value}:${minute}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{HOURS.map((h) => (
|
||||
<option key={h} value={String(h).padStart(2, '0')}>
|
||||
{String(h).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span aria-hidden="true">:</span>
|
||||
<select
|
||||
aria-label={`${id === 'start-time' ? 'Start' : 'End'}minuten`}
|
||||
value={minute}
|
||||
onChange={(e) => onChange(`${hour}:${e.target.value}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{MINUTES.map((m) => (
|
||||
<option key={m} value={String(m).padStart(2, '0')}>
|
||||
{String(m).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewReservationButton({
|
||||
calendarId,
|
||||
maxReservationMinutes = 0,
|
||||
}: {
|
||||
calendarId: string;
|
||||
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||
maxReservationMinutes?: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [startDate, setStartDate] = useState(today);
|
||||
const [endDate, setEndDate] = useState(today);
|
||||
|
||||
/** Live-Berechnung der Dauer aus Start- und Enddatum/-zeit. */
|
||||
const durationMinutes = useMemo(() => {
|
||||
const start = new Date(`${startDate}T${startTime}:00`);
|
||||
const end = new Date(`${endDate}T${endTime}:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const diff = (end.getTime() - start.getTime()) / 60_000;
|
||||
return diff > 0 ? diff : null;
|
||||
}, [startDate, startTime, endDate, endTime]);
|
||||
|
||||
const exceedsMax =
|
||||
durationMinutes !== null &&
|
||||
maxReservationMinutes > 0 &&
|
||||
durationMinutes > maxReservationMinutes;
|
||||
|
||||
const isInvalidRange = durationMinutes === null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const startAt = new Date(`${startDate}T${startTime}:00`);
|
||||
const endAt = new Date(`${endDate}T${endTime}:00`);
|
||||
|
||||
if (endAt <= startAt) {
|
||||
setError('Enddatum/-zeit muss nach dem Start liegen.');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
calendarId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
title: formData.get('title'),
|
||||
notes: formData.get('notes') || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json()) as { error?: string };
|
||||
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
+ Neue Reservierung
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold">Neue Reservierung</h2>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 sm:grid-cols-2">
|
||||
<ErrorMessage message={error} />
|
||||
<div>
|
||||
<Label htmlFor="start-date">Startdatum</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end-date">Enddatum</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
name="endDate"
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" type="text" required maxLength={120} />
|
||||
</div>
|
||||
<div className="sm:col-span-2" />
|
||||
<div>
|
||||
<Label htmlFor="start-time">Startzeit</Label>
|
||||
<TimeSelect
|
||||
id="start-time"
|
||||
name="startTime"
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end-time">Endzeit</Label>
|
||||
<TimeSelect
|
||||
id="end-time"
|
||||
name="endTime"
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="notes">Notiz (optional)</Label>
|
||||
<Input id="notes" name="notes" type="text" maxLength={2000} />
|
||||
</div>
|
||||
|
||||
{durationMinutes !== null && (
|
||||
<p
|
||||
className={`sm:col-span-2 text-sm ${
|
||||
exceedsMax
|
||||
? 'font-medium text-red-700 dark:text-red-300'
|
||||
: 'text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
role={exceedsMax ? 'alert' : undefined}
|
||||
>
|
||||
Dauer: {formatDuration(durationMinutes)}
|
||||
{exceedsMax &&
|
||||
` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}
|
||||
</p>
|
||||
)}
|
||||
{isInvalidRange && (
|
||||
<p
|
||||
className="sm:col-span-2 text-sm font-medium text-red-700 dark:text-red-300"
|
||||
role="alert"
|
||||
>
|
||||
Enddatum/-zeit muss nach dem Start liegen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || exceedsMax || isInvalidRange}
|
||||
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
{busy ? 'Reservieren …' : 'Reservieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
components/ui/ModalShell.tsx
Normal file
133
components/ui/ModalShell.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Gemeinsamer Modal-Rahmen fuer alle Dialoge der App.
|
||||
*
|
||||
* Schliessen moeglich via:
|
||||
* - X-Button (immer, auch waehrend einer laufenden Aktion)
|
||||
* - ESC-Taste (ausser waehrend isPending, um Abbrueche zu vermeiden)
|
||||
* - Klick auf den dunklen Hintergrund (ausser waehrend isPending)
|
||||
*
|
||||
* Alle Modals der App MUESSEN diesen Rahmen verwenden, damit das
|
||||
* Schliess-Verhalten einheitlich ist (DRY).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/** Verfuegbare Modal-Groessen. */
|
||||
export type ModalSize = 'md' | 'lg' | 'xl';
|
||||
|
||||
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-5xl',
|
||||
};
|
||||
|
||||
export function ModalShell({
|
||||
title,
|
||||
ariaLabel,
|
||||
isPending = false,
|
||||
size = 'md',
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
/** Accessibility-Beschriftung (faellt auf title zurueck). */
|
||||
ariaLabel?: string;
|
||||
/** Blockiert ESC/Backdrop-Schliessen waehrend einer laufenden Aktion. */
|
||||
isPending?: boolean;
|
||||
size?: ModalSize;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// ESC schliesst das Modal. Der Listener wird bei jedem Render von
|
||||
// onClose/isPending neu registriert (guenstig und immer aktuell).
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !isPending) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isPending, onClose]);
|
||||
|
||||
// Merkt sich, ob der mousedown auf dem Backdrop (ausserhalb des
|
||||
// Modal-Inhalts) begann. Nur dann ist ein click ein echtes
|
||||
// "Klick ausserhalb". Grund: Bei einer Textauswahl, die IM Modal
|
||||
// beginnt und ausserhalb endet, feuert der Browser das click-Event
|
||||
// auf dem gemeinsamen Vorfahren beider Ziele - dem Backdrop. Ohne
|
||||
// diese Pruefung wuerde das Modal beim Markieren von Text
|
||||
// fälschlich geschlossen.
|
||||
const mouseDownOnBackdropRef = useRef(false);
|
||||
|
||||
// Portal: Das Modal wird auf document.body gerendert, nicht innerhalb
|
||||
// der aufrufenden Komponente. Grund: Wird ein Modal z. B. in der
|
||||
// Sidebar (sticky, eigener Stacking-Kontext) gerendert, wuerde sein
|
||||
// z-50 nur innerhalb der Sidebar gelten - Hauptinhalte daneben
|
||||
// wuerden es überlappen. Auf body-Ebene gilt z-50 global.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel ?? title}
|
||||
onMouseDown={(event) => {
|
||||
mouseDownOnBackdropRef.current =
|
||||
event.target === event.currentTarget;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
mouseDownOnBackdropRef.current &&
|
||||
!isPending
|
||||
) {
|
||||
mouseDownOnBackdropRef.current = false;
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${SIZE_CLASSES[size]} max-h-[90vh] overflow-y-auto rounded-lg border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900`}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schliessen"
|
||||
className="rounded-md p-1 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"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** X-Icon (2D, Outline-Stil) zum Schliessen des Modals. */
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
120
components/ui/ui.tsx
Normal file
120
components/ui/ui.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/** Gemeinsame UI-Bausteine (Forms, Buttons, Badges) im einheitlichen Look. */
|
||||
|
||||
/**
|
||||
* Einheitliches Badge fuer Status/Rollen-Labels.
|
||||
* variant: neutral (Standard), success (aktiv), warning (Hinweis),
|
||||
* danger (Fehler), accent (hervorgehoben, z. B. eigene Rolle).
|
||||
*/
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent';
|
||||
}) {
|
||||
const styles = {
|
||||
neutral:
|
||||
'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||
success:
|
||||
'bg-slate-200 text-slate-800 ring-1 ring-slate-400 dark:bg-slate-600 dark:text-slate-100 dark:ring-slate-500',
|
||||
warning:
|
||||
'bg-slate-100 text-slate-700 ring-1 ring-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600',
|
||||
danger:
|
||||
'bg-slate-800 text-slate-100 dark:bg-slate-100 dark:text-slate-900',
|
||||
accent:
|
||||
'bg-slate-700 text-slate-50 dark:bg-slate-300 dark:text-slate-900',
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${styles}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
type = 'submit',
|
||||
variant = 'primary',
|
||||
disabled,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
type?: 'submit' | 'button';
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick' | 'disabled'>) {
|
||||
const styles = {
|
||||
primary: 'bg-slate-800 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200',
|
||||
secondary:
|
||||
'bg-slate-200 text-slate-900 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${styles}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { className = '', ...rest } = props;
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
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 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Label({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorMessage({ message }: { message: string | null }) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-300"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user