Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
875
components/calendar/CalendarModal.tsx
Normal file
875
components/calendar/CalendarModal.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Kalender-Modal: echte Kalenderansicht (Monat/Woche/Tag) im Dialog.
|
||||
*
|
||||
* Features:
|
||||
* - Ansichten Monat (Standard) / Woche / Tag mit Vor/Zurueck und "Heute"
|
||||
* - Reservierungen werden pro Ansicht dargestellt (eigene hervorgehoben)
|
||||
* - Reservierung anlegen mit Start- UND Endzeit; die maximale Dauer
|
||||
* wird live gegen die Kalender-Einstellung geprueft und der Benutzer
|
||||
* bei Ueberschreitung informiert ("Maximale Reservierungsdauer ist X")
|
||||
* - Loeschen ausschliesslich durch Admins (Server prueft final)
|
||||
* - Legende: eigene vs. fremde Reservierungen
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ModalShell } from '@/components/ui/ModalShell';
|
||||
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
/** Reservierung, wie sie vom API-Endpoint geliefert wird. */
|
||||
interface ReservationItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
notes: string | null;
|
||||
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
/** Zeitfenster (ISO-Grenzen) der aktuell gewaehlten Ansicht. */
|
||||
interface Window {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false, // Deutsches 24h-Format (kein am/pm).
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateLong(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Formatiert ein Datum kurz (z. B. "21.09."). */
|
||||
function formatDateShort(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob `date` (00:00) innerhalb des Reservierungszeitraums liegt
|
||||
* (Mehrtages-Reservierungen erscheinen auf allen Folgetagen).
|
||||
*/
|
||||
function isDayWithinReservation(date: Date, startAt: string, endAt: string): boolean {
|
||||
const dayStart = startOfDay(date).getTime();
|
||||
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
||||
return new Date(startAt).getTime() < dayEnd && dayStart < new Date(endAt).getTime();
|
||||
}
|
||||
|
||||
/** Vergleicht zwei Daten auf denselben Kalendertag (lokal). */
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.toDateString() === b.toDateString();
|
||||
}
|
||||
|
||||
/** Start des Tages (00:00 lokal). */
|
||||
function startOfDay(date: Date): Date {
|
||||
const copy = new Date(date);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Montag der Woche, in der `date` liegt. */
|
||||
function startOfWeek(date: Date): Date {
|
||||
const monday = startOfDay(date);
|
||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||
return monday;
|
||||
}
|
||||
|
||||
/** Erster Tag des Monats (00:00 lokal). */
|
||||
function startOfMonth(date: Date): Date {
|
||||
const first = startOfDay(date);
|
||||
first.setDate(1);
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet das Zeitfenster der gewaehlten Ansicht (lokal, inklusive).
|
||||
* `to` ist exklusives Ende (Start des Folgetags/Folgewoche/Folgmonat).
|
||||
*/
|
||||
function computeWindow(view: ViewMode, reference: Date): Window {
|
||||
if (view === 'day') {
|
||||
const from = startOfDay(reference);
|
||||
const to = new Date(from);
|
||||
to.setDate(to.getDate() + 1);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
if (view === 'week') {
|
||||
const from = startOfWeek(reference);
|
||||
const to = new Date(from);
|
||||
to.setDate(to.getDate() + 7);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
const from = startOfMonth(reference);
|
||||
const to = new Date(from);
|
||||
to.setMonth(to.getMonth() + 1);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
|
||||
/** Verschiebt das Referenzdatum um eine Ansichtseinheit. */
|
||||
function shiftReference(view: ViewMode, reference: Date, amount: number): Date {
|
||||
const next = new Date(reference);
|
||||
if (view === 'day') {
|
||||
next.setDate(next.getDate() + amount);
|
||||
} else if (view === 'week') {
|
||||
next.setDate(next.getDate() + amount * 7);
|
||||
} else {
|
||||
next.setMonth(next.getMonth() + amount, 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Kopfzeile der Ansicht (z. B. "September 2026" oder "KW-Datum"). */
|
||||
function formatPeriodTitle(view: ViewMode, reference: Date): string {
|
||||
if (view === 'month') {
|
||||
return reference.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (view === 'day') {
|
||||
return formatDateLong(reference);
|
||||
}
|
||||
const monday = startOfWeek(reference);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
return `${monday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} – ${sunday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||
}
|
||||
|
||||
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden"). */
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} Minuten`;
|
||||
}
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours)
|
||||
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||
}
|
||||
|
||||
export function CalendarModal({
|
||||
calendarId,
|
||||
calendarTitle,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
maxReservationMinutes,
|
||||
onClose,
|
||||
}: {
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||
maxReservationMinutes: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [view, setView] = useState<ViewMode>('month');
|
||||
const [reference, setReference] = useState(() => new Date());
|
||||
const [reservations, setReservations] = useState<ReservationItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const window = useMemo(() => computeWindow(view, reference), [view, reference]);
|
||||
|
||||
/** Laedt Reservierungen fuer das aktuelle Zeitfenster. */
|
||||
const loadReservations = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Reservierungen konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { reservations: ReservationItem[] };
|
||||
setReservations(data.reservations);
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [calendarId, window.from, window.to]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadReservations();
|
||||
}, [loadReservations]);
|
||||
|
||||
/** Legt eine neue Reservierung an und laedt die Ansicht neu. */
|
||||
async function createReservation(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const startDate = String(formData.get('startDate'));
|
||||
const endDate = String(formData.get('endDate'));
|
||||
const startTime = String(formData.get('startTime'));
|
||||
const endTime = String(formData.get('endTime'));
|
||||
const startAt = new Date(`${startDate}T${startTime}:00`);
|
||||
const endAt = new Date(`${endDate}T${endTime}:00`);
|
||||
|
||||
// Sicherheitsnetz: Falls End- vor Startzeit liegt, eine Tagesdauer
|
||||
// annehmen (sollte durch die Live-Pruefung verhindert werden).
|
||||
if (endAt <= startAt) {
|
||||
endAt.setDate(endAt.getDate() + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
calendarId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
title: formData.get('title'),
|
||||
notes: String(formData.get('notes') ?? '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
setIsCreating(false);
|
||||
await loadReservations();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loescht eine Reservierung (ausschliesslich Admins). */
|
||||
async function deleteReservation(reservationId: string) {
|
||||
setDeletingId(reservationId);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? 'Loeschen fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
await loadReservations();
|
||||
} catch {
|
||||
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={calendarTitle}
|
||||
ariaLabel={`Kalender ${calendarTitle} oeffnen`}
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(['day', 'week', 'month'] as const).map((nextView) => (
|
||||
<button
|
||||
key={nextView}
|
||||
type="button"
|
||||
onClick={() => setView(nextView)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||
view === nextView
|
||||
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference((previous) => shiftReference(view, previous, -1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
aria-label="Zurueck"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference(new Date())}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReference((previous) => shiftReference(view, previous, 1))}
|
||||
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
aria-label="Weiter"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-medium">{formatPeriodTitle(view, reference)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded bg-slate-300 dark:bg-slate-600" />
|
||||
Eigene
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded bg-slate-100 ring-1 ring-slate-300 dark:bg-slate-800 dark:ring-slate-600" />
|
||||
Andere
|
||||
</span>
|
||||
{maxReservationMinutes > 0 && (
|
||||
<span>· Max. Dauer: {formatDuration(maxReservationMinutes)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isCreating}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
}}
|
||||
>
|
||||
+ Reservierung
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
{isCreating && (
|
||||
<ReservationForm
|
||||
reference={reference}
|
||||
maxReservationMinutes={maxReservationMinutes}
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onSubmit={createReservation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-slate-500">Reservierungen werden geladen …</p>
|
||||
) : (
|
||||
<>
|
||||
{view === 'day' && (
|
||||
<DayView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={deleteReservation}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<WeekView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={deleteReservation}
|
||||
/>
|
||||
)}
|
||||
{view === 'month' && (
|
||||
<MonthView
|
||||
reference={reference}
|
||||
reservations={reservations}
|
||||
onSelectDay={(day) => {
|
||||
setReference(day);
|
||||
setView('day');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular fuer neue Reservierungen mit Start-/Endzeit und Live-Pruefung. */
|
||||
function ReservationForm({
|
||||
reference,
|
||||
maxReservationMinutes,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
reference: Date;
|
||||
maxReservationMinutes: number;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
// Live-Pruefung: Sobald Start- und Enddatum/-zeit gesetzt sind,
|
||||
// wird die Dauer berechnet und gegen die Kalender-Einstellung
|
||||
// geprueft. Die Dauer kann ueber Tagesgrenzen hinausgehen.
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
const [startDate, setStartDate] = useState(
|
||||
reference.toISOString().slice(0, 10),
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
reference.toISOString().slice(0, 10),
|
||||
);
|
||||
const durationMinutes = useMemo(() => {
|
||||
const start = new Date(`${startDate}T${startTime}:00`);
|
||||
const end = new Date(`${endDate}T${endTime}:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const diff = (end.getTime() - start.getTime()) / 60_000;
|
||||
return diff > 0 ? diff : null;
|
||||
}, [startDate, startTime, endDate, endTime]);
|
||||
|
||||
const exceedsMax =
|
||||
durationMinutes !== null &&
|
||||
maxReservationMinutes > 0 &&
|
||||
durationMinutes > maxReservationMinutes;
|
||||
|
||||
const isInvalidRange = durationMinutes === null;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mb-4 grid gap-3 rounded-md border border-slate-200 p-4 dark:border-slate-700 sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="reservation-start-date">Startdatum</Label>
|
||||
<Input
|
||||
id="reservation-start-date"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-end-date">Enddatum</Label>
|
||||
<Input
|
||||
id="reservation-end-date"
|
||||
name="endDate"
|
||||
type="date"
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-title">Titel</Label>
|
||||
<Input id="reservation-title" name="title" type="text" required maxLength={120} />
|
||||
</div>
|
||||
<div className="sm:col-span-2" />
|
||||
<div>
|
||||
<Label htmlFor="reservation-start-time">Startzeit</Label>
|
||||
<TimeSelect
|
||||
id="reservation-start-time"
|
||||
name="startTime"
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reservation-end-time">Endzeit</Label>
|
||||
<TimeSelect
|
||||
id="reservation-end-time"
|
||||
name="endTime"
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="reservation-notes">Notiz (optional)</Label>
|
||||
<Input id="reservation-notes" name="notes" type="text" maxLength={2000} />
|
||||
</div>
|
||||
|
||||
{durationMinutes !== null && (
|
||||
<p
|
||||
className={`sm:col-span-2 text-sm ${
|
||||
exceedsMax
|
||||
? 'font-medium text-red-700 dark:text-red-300'
|
||||
: 'text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
role={exceedsMax ? 'alert' : undefined}
|
||||
>
|
||||
Dauer: {formatDuration(durationMinutes)}
|
||||
{exceedsMax &&
|
||||
` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}
|
||||
</p>
|
||||
)}
|
||||
{isInvalidRange && (
|
||||
<p
|
||||
className="sm:col-span-2 text-sm font-medium text-red-700 dark:text-red-300"
|
||||
role="alert"
|
||||
>
|
||||
Enddatum/-zeit muss nach dem Start liegen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || exceedsMax || isInvalidRange}>
|
||||
{isSubmitting ? 'Reservieren …' : 'Reservieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stunden 00-23 (deutsches 24h-Format, kein am/pm). */
|
||||
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, index) => index);
|
||||
|
||||
/** Minuten in 15-Minuten-Schritten. */
|
||||
const MINUTES = [0, 15, 30, 45] as const;
|
||||
|
||||
/**
|
||||
* Eigener Zeit-Auswahl-Dialog (zwei Dropdowns: Stunde + Minute).
|
||||
*
|
||||
* Bewusst KEIN <input type="time">: Der native Timepicker folgt der
|
||||
* Systemsprache des Browsers und zeigt auf englischen Systemen
|
||||
* "AM/PM" an. Diese Komponente erzwingt das deutsche 24h-Format
|
||||
* unabhaengig von der Systemeinstellung.
|
||||
*/
|
||||
function TimeSelect({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
/** Hidden Input tragt den Wert im Format "HH:MM". */
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const [hourPart, minutePart] = value.split(':');
|
||||
const hour = hourPart ?? '09';
|
||||
const minute = minutePart ?? '00';
|
||||
|
||||
const selectClasses =
|
||||
'rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<select
|
||||
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}stunde`}
|
||||
value={hour}
|
||||
onChange={(event) => onChange(`${event.target.value}:${minute}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{HOURS.map((hourOption) => (
|
||||
<option key={hourOption} value={String(hourOption).padStart(2, '0')}>
|
||||
{String(hourOption).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span aria-hidden="true">:</span>
|
||||
<select
|
||||
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}minuten`}
|
||||
value={minute}
|
||||
onChange={(event) => onChange(`${hour}:${event.target.value}`)}
|
||||
className={selectClasses}
|
||||
>
|
||||
{MINUTES.map((minuteOption) => (
|
||||
<option key={minuteOption} value={String(minuteOption).padStart(2, '0')}>
|
||||
{String(minuteOption).padStart(2, '0')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Eigenschaften einer einzelnen Reservierungszeile. */
|
||||
interface ReservationRowProps {
|
||||
reservation: ReservationItem;
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
deletingId: string | null;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Eigenschaften der Listen-basierten Ansichten (Tag/Woche). */
|
||||
interface ReservationListProps {
|
||||
reservations: ReservationItem[];
|
||||
currentUserId: string;
|
||||
isAdmin: boolean;
|
||||
deletingId: string | null;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Eine einzelne Reservierungszeile; Loeschen nur fuer Admins. */
|
||||
function ReservationRow({
|
||||
reservation,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationRowProps) {
|
||||
const isOwn = reservation.userId === currentUserId;
|
||||
return (
|
||||
<li
|
||||
className={`flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm ${
|
||||
isOwn
|
||||
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">
|
||||
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
|
||||
</span>{' '}
|
||||
{reservation.title}
|
||||
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{reservation.ownerName}
|
||||
</span>
|
||||
{reservation.notes && (
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{reservation.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(reservation.id)}
|
||||
disabled={deletingId === reservation.id}
|
||||
aria-label={`Reservierung ${reservation.title} loeschen`}
|
||||
title="Loeschen (nur Admin)"
|
||||
className="shrink-0 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-300 hover:text-slate-800 disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-600 dark:hover:text-slate-100"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tagesansicht: alle Reservierungen, die diesen Tag ueberdecken. */
|
||||
function DayView({
|
||||
reference,
|
||||
reservations,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationListProps & { reference: Date }) {
|
||||
const dayReservations = reservations.filter((reservation) =>
|
||||
isDayWithinReservation(reference, reservation.startAt, reservation.endAt),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{dayReservations.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-500">
|
||||
Keine Reservierungen an diesem Tag.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{dayReservations.map((reservation) => (
|
||||
<ReservationRow
|
||||
key={reservation.id}
|
||||
reservation={reservation}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
deletingId={deletingId}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wochenansicht: 7 Spalten; Mehrtages-Reservierungen auf Folgetagen. */
|
||||
function WeekView({
|
||||
reference,
|
||||
reservations,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
deletingId,
|
||||
onDelete,
|
||||
}: ReservationListProps & { reference: Date }) {
|
||||
const monday = startOfWeek(reference);
|
||||
const days = Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday);
|
||||
day.setDate(day.getDate() + index);
|
||||
return day;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
|
||||
{days.map((day) => {
|
||||
const dayReservations = reservations.filter((reservation) =>
|
||||
isDayWithinReservation(day, reservation.startAt, reservation.endAt),
|
||||
);
|
||||
const isToday = day.toDateString() === new Date().toDateString();
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`rounded border p-2 ${
|
||||
isToday
|
||||
? 'border-slate-500 dark:border-slate-300'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
|
||||
</div>
|
||||
{dayReservations.length === 0 ? (
|
||||
<p className="text-center text-xs text-slate-400">–</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{dayReservations.map((reservation) => {
|
||||
const isOwn = reservation.userId === currentUserId;
|
||||
// Bei Mehrtages-Reservierungen das Startdatum mit angeben,
|
||||
// damit Folgetage klar zugeordnet sind.
|
||||
const startsOnOtherDay =
|
||||
!isSameDay(new Date(reservation.startAt), day);
|
||||
return (
|
||||
<li
|
||||
key={reservation.id}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
isOwn
|
||||
? 'bg-slate-300 text-slate-900 dark:bg-slate-600 dark:text-slate-100'
|
||||
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||
}`}
|
||||
title={`${formatTime(reservation.startAt)}–${formatTime(reservation.endAt)} · ${reservation.title} (${reservation.ownerName})`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{startsOnOtherDay && (
|
||||
<span className="mr-1">{formatDateShort(new Date(reservation.startAt))},</span>
|
||||
)}
|
||||
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
|
||||
</div>
|
||||
<div className="truncate">{reservation.title}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Monatsansicht: Tage mit Anzahl; Klick springt zur Tagesansicht. */
|
||||
function MonthView({
|
||||
reference,
|
||||
reservations,
|
||||
onSelectDay,
|
||||
}: {
|
||||
reference: Date;
|
||||
reservations: ReservationItem[];
|
||||
onSelectDay: (day: Date) => void;
|
||||
}) {
|
||||
const year = reference.getFullYear();
|
||||
const month = reference.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const leadingEmpty = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
const countPerDay = new Map<number, number>();
|
||||
for (const reservation of reservations) {
|
||||
// Mehrtages-Reservierungen auf alle betroffenen Tage verteilen.
|
||||
const cursor = startOfDay(new Date(reservation.startAt));
|
||||
const end = new Date(reservation.endAt);
|
||||
while (cursor < end) {
|
||||
if (cursor.getFullYear() === year && cursor.getMonth() === month) {
|
||||
const day = cursor.getDate();
|
||||
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<span key={weekday}>{weekday}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: leadingEmpty }, (_, index) => (
|
||||
<div key={`empty-${index}`} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||
const day = index + 1;
|
||||
const count = countPerDay.get(day) ?? 0;
|
||||
const dayDate = new Date(year, month, day);
|
||||
const isToday = dayDate.toDateString() === new Date().toDateString();
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onSelectDay(dayDate)}
|
||||
className={`min-h-16 rounded border p-1 text-left text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
||||
isToday
|
||||
? 'border-slate-500 dark:border-slate-300'
|
||||
: 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{day}</div>
|
||||
{count > 0 && (
|
||||
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||
{count} Res.
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muelltonnen-Icon (2D, Outline-Stil) zum Loeschen. */
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user