457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
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<ReturnType<typeof getReservations>>[number]
|
|
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
|
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
|
|
|
|
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<void>
|
|
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
|
|
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<FormState>(() =>
|
|
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
|
|
)
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
|
<div
|
|
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="reservation-dialog-title"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="confirm-dialog-header">
|
|
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
|
|
{title}
|
|
</h2>
|
|
{reservation && !isEditing && (
|
|
<div className="confirm-dialog-header-actions">
|
|
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
|
|
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
|
Löschen
|
|
</button>
|
|
</ActionMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
|
|
<label>
|
|
Tisch
|
|
<select
|
|
value={form.tableId}
|
|
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
|
required
|
|
>
|
|
<option value="" disabled>
|
|
Tisch auswählen
|
|
</option>
|
|
{tables.map((table) => (
|
|
<option key={table.id} value={table.id}>
|
|
{table.name} ({table.seats} Plätze)
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="form-row">
|
|
<label>
|
|
Name
|
|
<input
|
|
type="text"
|
|
value={form.customerName}
|
|
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
E-Mail
|
|
<input
|
|
type="email"
|
|
value={form.customerEmail}
|
|
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
|
required
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label>
|
|
Telefon
|
|
<input
|
|
type="tel"
|
|
inputMode="tel"
|
|
pattern={PHONE_PATTERN_SOURCE}
|
|
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
|
value={form.customerPhone}
|
|
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Anzahl Personen
|
|
<input
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={20}
|
|
value={form.partySize}
|
|
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
|
required
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
Datum & Uhrzeit
|
|
<ReservationDateTimeField
|
|
value={form.reservationTime}
|
|
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
|
|
bookedTimes={bookedTimesForForm}
|
|
intervalMinutes={settings.reservationIntervalMinutes}
|
|
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
|
durationMinutes={settings.reservationDurationMinutes}
|
|
maxLeadTimeDays={settings.maxLeadTimeDays}
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Nachricht (optional)
|
|
<textarea
|
|
rows={2}
|
|
maxLength={500}
|
|
value={form.message}
|
|
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
|
|
/>
|
|
</label>
|
|
|
|
{error && <p className="message message-error">{error}</p>}
|
|
|
|
<div className="confirm-dialog-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
|
|
Abbrechen
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
|
|
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
reservation && (
|
|
<>
|
|
<div className="status-options">
|
|
{STATUS_OPTIONS.map((status) => (
|
|
<button
|
|
key={status}
|
|
type="button"
|
|
className={`status-badge status-option status-${status}${
|
|
reservation.status === status ? ' is-active' : ''
|
|
}`}
|
|
disabled={isUpdatingStatus}
|
|
onClick={() => onStatusChange(status)}
|
|
>
|
|
{RESERVATION_STATUS_LABELS[status]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<dl className="detail-list">
|
|
{reservation.tableNumber !== null && (
|
|
<div>
|
|
<dt>Tischnummer</dt>
|
|
<dd>{reservation.tableNumber}</dd>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<dt>Tisch</dt>
|
|
<dd>{reservation.tableName}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Datum</dt>
|
|
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Start</dt>
|
|
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Ende</dt>
|
|
<dd>
|
|
{timeFormatter.format(
|
|
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
|
|
)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Personen</dt>
|
|
<dd>{reservation.partySize}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>E-Mail</dt>
|
|
<dd>
|
|
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
|
|
</dd>
|
|
</div>
|
|
{reservation.customerPhone && (
|
|
<div>
|
|
<dt>Telefon</dt>
|
|
<dd>
|
|
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
|
|
</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
|
|
{reservation.message && (
|
|
<div className="detail-message">
|
|
<span className="detail-message-label">Nachricht</span>
|
|
<p>{reservation.message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{statusError && <p className="message message-error">{statusError}</p>}
|
|
|
|
<div className="confirm-dialog-actions confirm-dialog-actions-split">
|
|
<button type="button" className="btn btn-ghost" onClick={startEditing}>
|
|
Bearbeiten
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|