✨ first version
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user