'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('month'); const [reference, setReference] = useState(() => new Date()); const [reservations, setReservations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [deletingId, setDeletingId] = useState(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) { 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 (
{(['day', 'week', 'month'] as const).map((nextView) => ( ))}
{formatPeriodTitle(view, reference)}
Eigene Andere {maxReservationMinutes > 0 && ( · Max. Dauer: {formatDuration(maxReservationMinutes)} )}
{isCreating && ( setIsCreating(false)} onSubmit={createReservation} /> )} {isLoading ? (

Reservierungen werden geladen …

) : ( <> {view === 'day' && ( )} {view === 'week' && ( )} {view === 'month' && ( { setReference(day); setView('day'); }} /> )} )}
); } /** 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) => 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 (
setStartDate(e.target.value)} />
setEndDate(e.target.value)} />
{durationMinutes !== null && (

Dauer: {formatDuration(durationMinutes)} {exceedsMax && ` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}

)} {isInvalidRange && (

Enddatum/-zeit muss nach dem Start liegen.

)}
); } /** 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 : 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 (
); } /** 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 (
  • {formatTime(reservation.startAt)} – {formatTime(reservation.endAt)} {' '} {reservation.title} {reservation.ownerName} {reservation.notes && (

    {reservation.notes}

    )}
    {isAdmin && ( )}
  • ); } /** 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 (
    {dayReservations.length === 0 ? (

    Keine Reservierungen an diesem Tag.

    ) : (
      {dayReservations.map((reservation) => ( ))}
    )}
    ); } /** 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 (
    {days.map((day) => { const dayReservations = reservations.filter((reservation) => isDayWithinReservation(day, reservation.startAt, reservation.endAt), ); const isToday = day.toDateString() === new Date().toDateString(); return (
    {WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
    {dayReservations.length === 0 ? (

    –

    ) : (
      {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 (
    • {startsOnOtherDay && ( {formatDateShort(new Date(reservation.startAt))}, )} {formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
      {reservation.title}
    • ); })}
    )}
    ); })}
    ); } /** 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(); 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 (
    {WEEKDAYS.map((weekday) => ( {weekday} ))}
    {Array.from({ length: leadingEmpty }, (_, 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 ( ); })}
    ); } /** Muelltonnen-Icon (2D, Outline-Stil) zum Loeschen. */ function TrashIcon() { return ( ); }