import { useEffect, useState } from 'react' import { ActionMenu } from '#/components/action-menu' import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime' import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours' import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations, type getTables, } from '#/lib/reservations' import { type getReservationSettings } from '#/lib/settings' type ReservationRow = Awaited>[number] type TableRow = Awaited>[number] type SettingsRow = Awaited> export type ReservationPayload = { tableId: number customerName: string customerEmail: string customerPhone: string message: string partySize: number reservationTime: string } type FormState = { tableId: number | string customerName: string customerEmail: string customerPhone: string message: string partySize: number reservationTime: string } type Props = { // `null` reservation with `open: true` is "create" mode. open: boolean reservation: ReservationRow | null tables: TableRow[] settings: SettingsRow reservations: ReservationRow[] onClose: () => void onCreate: (payload: ReservationPayload) => Promise onUpdate: (id: number, payload: ReservationPayload) => Promise onStatusChange: (status: ReservationStatus) => void isUpdatingStatus?: boolean onDelete: () => void statusError?: string | null } const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[] const dateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full', }) const timeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short', }) const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // Also used as the phone input's `pattern` attribute below, so the two stay in sync. const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}` const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`) function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState { const slot = getNextAvailableSlot( new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), settings.reservationIntervalMinutes, settings.reservationDurationMinutes, ) return { tableId: defaultTableId, customerName: '', customerEmail: '', customerPhone: '', message: '', partySize: 2, reservationTime: slot ? `${slot.date}T${slot.time}` : '', } } function formFromReservation(reservation: ReservationRow): FormState { return { tableId: reservation.tableId, customerName: reservation.customerName, customerEmail: reservation.customerEmail, customerPhone: reservation.customerPhone ?? '', message: reservation.message ?? '', partySize: reservation.partySize, reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)), } } // Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time // the dialog opens — that's what lets this component safely derive its initial // state from props once, instead of syncing it via effects. export function ReservationDialog({ open, reservation, tables, settings, reservations, onClose, onCreate, onUpdate, onStatusChange, isUpdatingStatus, onDelete, statusError, }: Props) { const [isEditing, setIsEditing] = useState(reservation === null) const [form, setForm] = useState(() => reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings), ) const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') onClose() } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [onClose]) if (!open) return null // Other reservations already sitting on the currently selected table + date, so the // time picker can block them instead of letting staff pick a slot the server will // just reject as double-booked. A reservation occupies its whole configured // duration, not just its exact start time, so every interval-grid slot that falls // inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a // table turnover buffer, extending the taken window on both sides so back-to-back // bookings on the same table always leave that much of a gap. const [selectedDate] = form.reservationTime.split('T') const otherReservationsOnSelection = reservations.filter( (r) => r.id !== reservation?.id && String(r.tableId) === String(form.tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate, ) const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000 const bookedTimesForForm = selectedDate ? generateTimeOptions( getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()), settings.reservationIntervalMinutes, settings.reservationDurationMinutes, ).filter((option) => { const slotStart = new Date(`${selectedDate}T${option}`).getTime() return otherReservationsOnSelection.some((r) => { const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs return slotStart >= otherStart && slotStart < otherEnd }) }) : [] // Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints, // so the submit button is disabled until they'd all actually pass — instead of only // catching an invalid table/time on submit and leaving everything else to the // browser's native validation popup. const isFormValid = Boolean(form.tableId) && form.customerName.trim().length > 0 && EMAIL_PATTERN.test(form.customerEmail) && PHONE_PATTERN.test(form.customerPhone) && form.partySize >= 1 && form.partySize <= 20 && Boolean(form.reservationTime.split('T')[1]) function startEditing() { if (reservation) setForm(formFromReservation(reservation)) setError(null) setIsEditing(true) } function cancelEditing() { if (!reservation) { onClose() return } setForm(formFromReservation(reservation)) setError(null) setIsEditing(false) } async function handleSubmit(event: React.FormEvent) { event.preventDefault() setError(null) if (!form.tableId) { setError('Bitte wählen Sie einen Tisch aus') return } if (!form.reservationTime.split('T')[1]) { setError('Bitte wählen Sie eine gültige Uhrzeit aus') return } setIsSubmitting(true) try { const payload: ReservationPayload = { tableId: Number(form.tableId), customerName: form.customerName, customerEmail: form.customerEmail, customerPhone: form.customerPhone, message: form.message, partySize: Number(form.partySize), reservationTime: new Date(form.reservationTime).toISOString(), } if (reservation) { await onUpdate(reservation.id, payload) } else { await onCreate(payload) } onClose() } catch (err) { setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') } finally { setIsSubmitting(false) } } const title = reservation ? isEditing ? `Reservierung von ${reservation.customerName} bearbeiten` : `Reservierung von ${reservation.customerName}` : 'Neue Reservierung' return (
event.stopPropagation()} >

{title}

{reservation && !isEditing && (
)}
{isEditing ? (