Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View 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>
);
}