Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
271
components/reservation/NewReservationButton.tsx
Normal file
271
components/reservation/NewReservationButton.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user