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

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

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

View 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_;