271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Formular fuer neue Reservierungen (plan.md Abschnitt 15):
|
|
* Startdatum, Startzeit, Enddatum, Endzeit (15-Minuten-Schritte),
|
|
* Titel, Notiz.
|
|
*
|
|
* Der Server erwartet Start- UND Endzeit (createReservationSchema).
|
|
* Die maximale Reservierungsdauer wird live gegen die Kalender-Einstellung
|
|
* geprueft und der Benutzer bei Ueberschreitung informiert.
|
|
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
|
|
*/
|
|
import { useMemo, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
|
|
|
/** Stunden 00-23 (deutsches 24h-Format). */
|
|
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
|
|
/** Minuten in 15-Minuten-Schritten. */
|
|
const MINUTES = [0, 15, 30, 45] as const;
|
|
|
|
/** 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`;
|
|
}
|
|
|
|
/** Zwei-Dropdown-Zeitwaehler (Stunde + Minute), 24h-Format. */
|
|
function TimeSelect({
|
|
id,
|
|
name,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
name: string;
|
|
value: string;
|
|
onChange: (next: 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 === 'start-time' ? 'Start' : 'End'}stunde`}
|
|
value={hour}
|
|
onChange={(e) => onChange(`${e.target.value}:${minute}`)}
|
|
className={selectClasses}
|
|
>
|
|
{HOURS.map((h) => (
|
|
<option key={h} value={String(h).padStart(2, '0')}>
|
|
{String(h).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span aria-hidden="true">:</span>
|
|
<select
|
|
aria-label={`${id === 'start-time' ? 'Start' : 'End'}minuten`}
|
|
value={minute}
|
|
onChange={(e) => onChange(`${hour}:${e.target.value}`)}
|
|
className={selectClasses}
|
|
>
|
|
{MINUTES.map((m) => (
|
|
<option key={m} value={String(m).padStart(2, '0')}>
|
|
{String(m).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NewReservationButton({
|
|
calendarId,
|
|
maxReservationMinutes = 0,
|
|
}: {
|
|
calendarId: string;
|
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
|
maxReservationMinutes?: number;
|
|
}) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [startTime, setStartTime] = useState('09:00');
|
|
const [endTime, setEndTime] = useState('10:00');
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [startDate, setStartDate] = useState(today);
|
|
const [endDate, setEndDate] = useState(today);
|
|
|
|
/** Live-Berechnung der Dauer aus Start- und Enddatum/-zeit. */
|
|
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;
|
|
|
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
|
|
const formData = new FormData(event.currentTarget);
|
|
const startAt = new Date(`${startDate}T${startTime}:00`);
|
|
const endAt = new Date(`${endDate}T${endTime}:00`);
|
|
|
|
if (endAt <= startAt) {
|
|
setError('Enddatum/-zeit muss nach dem Start liegen.');
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
|
|
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: formData.get('notes') || undefined,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = (await response.json()) as { error?: string };
|
|
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
router.refresh();
|
|
} catch {
|
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (!open) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
|
>
|
|
+ Neue Reservierung
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<h2 className="mb-4 text-lg font-semibold">Neue Reservierung</h2>
|
|
<form onSubmit={handleSubmit} className="grid gap-4 sm:grid-cols-2">
|
|
<ErrorMessage message={error} />
|
|
<div>
|
|
<Label htmlFor="start-date">Startdatum</Label>
|
|
<Input
|
|
id="start-date"
|
|
name="startDate"
|
|
type="date"
|
|
required
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="end-date">Enddatum</Label>
|
|
<Input
|
|
id="end-date"
|
|
name="endDate"
|
|
type="date"
|
|
required
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="title">Titel</Label>
|
|
<Input id="title" name="title" type="text" required maxLength={120} />
|
|
</div>
|
|
<div className="sm:col-span-2" />
|
|
<div>
|
|
<Label htmlFor="start-time">Startzeit</Label>
|
|
<TimeSelect
|
|
id="start-time"
|
|
name="startTime"
|
|
value={startTime}
|
|
onChange={setStartTime}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="end-time">Endzeit</Label>
|
|
<TimeSelect
|
|
id="end-time"
|
|
name="endTime"
|
|
value={endTime}
|
|
onChange={setEndTime}
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label htmlFor="notes">Notiz (optional)</Label>
|
|
<Input id="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 gap-2 sm:col-span-2">
|
|
<button
|
|
type="submit"
|
|
disabled={busy || exceedsMax || isInvalidRange}
|
|
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
|
>
|
|
{busy ? 'Reservieren …' : 'Reservieren'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-300"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
);
|
|
} |