Files
kalendartool/components/calendar/CalendarModal.tsx

876 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 { appFetch } from '@/lib/client-fetch';
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 appFetch(
`/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 appFetch(`/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 appFetch(`/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>
);
}