875 lines
29 KiB
TypeScript
875 lines
29 KiB
TypeScript
'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>
|
||
);
|
||
} |