Compare commits

..
1 Commits
Author SHA1 Message Date
lukas c64889c3b1 wip
CI / build-and-test (pull_request) Failing after 49s
2026-08-27 10:04:59 +02:00
11 changed files with 1193 additions and 449 deletions
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
onClose: () => void
}
function FlameBadgeIcon() {
return (
<svg className="reservation-confirmed-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)
}
export function ReservationConfirmedModal({
open,
customerName,
customerEmail,
tableName,
date,
startTime,
endTime,
partySize,
onClose,
}: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-confirmed-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="reservation-confirmed-title"
onClick={(event) => event.stopPropagation()}
>
<FlameBadgeIcon />
<h2 id="reservation-confirmed-title" className="reservation-confirmed-title">
Tisch reserviert!
</h2>
<p className="reservation-confirmed-message">
Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
</p>
<dl className="detail-list">
<div>
<dt>Tisch</dt>
<dd>{tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date}</dd>
</div>
<div>
<dt>Uhrzeit</dt>
<dd>
{startTime} – {endTime}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{partySize}</dd>
</div>
</dl>
<p className="reservation-confirmed-footnote">
Eine Bestätigung ist unterwegs an <strong>{customerEmail}</strong>. Dort finden Sie auch einen
Link, falls sich doch noch etwas ändert.
</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
Alles klar
</button>
</div>
</div>
</div>
)
}
@@ -14,6 +14,9 @@ type Props = {
// made — both configurable restaurant settings, falling back to sane defaults. // made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number intervalMinutes?: number
minLeadTimeMinutes?: number minLeadTimeMinutes?: number
// How long a reservation booked at a slot occupies the table — a slot too close
// to closing time to fit the whole reservation is left out of the list entirely.
durationMinutes?: number
// How far into the future a booking may be made. Undefined leaves the horizon open, // How far into the future a booking may be made. Undefined leaves the horizon open,
// since not every caller of this field needs the restriction. // since not every caller of this field needs the restriction.
maxLeadTimeDays?: number maxLeadTimeDays?: number
@@ -101,6 +104,7 @@ export function ReservationDateTimeField({
bookedTimes = [], bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES, intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0, minLeadTimeMinutes = 0,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
maxLeadTimeDays, maxLeadTimeDays,
}: Props) { }: Props) {
const { date, time } = splitValue(value) const { date, time } = splitValue(value)
@@ -189,7 +193,7 @@ export function ReservationDateTimeField({
// otherwise) can't slip through with a leftover, still-"valid"-looking time. // otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) { function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return [] if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes) return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
} }
function isSelectableDate(dateStr: string) { function isSelectableDate(dateStr: string) {
@@ -1,136 +0,0 @@
import { useEffect } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations } from '#/lib/reservations'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type Props = {
reservation: ReservationRow | null
onClose: () => void
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onEdit: () => void
onDelete: () => void
isDeleting?: boolean
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
export function ReservationDetailDialog({
reservation,
onClose,
onStatusChange,
isUpdatingStatus,
onEdit,
onDelete,
isDeleting,
}: Props) {
useEffect(() => {
if (!reservation) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [reservation, onClose])
if (!reservation) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="reservation-detail-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-detail-title" className="confirm-dialog-title">
Reservierung von {reservation.customerName}
</h2>
<ActionMenu label={`Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-secondary" onClick={onEdit}>
Bearbeiten
</button>
<button type="button" className="btn btn-danger" disabled={isDeleting} onClick={onDelete}>
{isDeleting ? 'Löschen …' : 'Löschen'}
</button>
</ActionMenu>
</div>
<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 &amp; Uhrzeit</dt>
<dd>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</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>
)}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+456
View File
@@ -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 &amp; 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>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
onClose: () => void
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="m8.2 12.3 2.6 2.6 5-5.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function SuccessModal({ open, title, message, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog success-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="success-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="success-modal-icon">
<CheckIcon />
</div>
<h2 id="success-modal-title" className="confirm-dialog-title">
{title}
</h2>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
OK
</button>
</div>
</div>
</div>
)
}
+15 -4
View File
@@ -57,12 +57,16 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0 return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
} }
export function isWithinOpeningHours(date: Date) { // `durationMinutes` requires the whole reservation — not just its start — to fit
// before closing, so a slot too close to close time is rejected outright.
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date) const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some( return ranges.some(
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end), (range) =>
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
) )
} }
@@ -89,9 +93,15 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
} }
// `durationMinutes` (how long a reservation booked at a slot would occupy the
// table) defaults to `intervalMinutes` so a bare call still behaves sensibly —
// but every real caller should pass the restaurant's actual reservation
// duration, so a slot too close to closing time to fit the whole reservation
// is excluded rather than offered and then silently running past close.
export function generateTimeOptions( export function generateTimeOptions(
ranges: OpeningRange[], ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] { ): string[] {
const options: string[] = [] const options: string[] = []
@@ -99,7 +109,7 @@ export function generateTimeOptions(
const startMinutes = toMinutes(range.start) const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end) const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) { for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const remainder = minutes % 60 const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`) options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
@@ -112,13 +122,14 @@ export function generateTimeOptions(
export function getNextAvailableSlot( export function getNextAvailableSlot(
from: Date, from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null { ): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) { for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from) const day = new Date(from)
day.setDate(from.getDate() + dayOffset) day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day) const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes) const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
for (const time of options) { for (const time of options) {
const slot = new Date(`${dateStr}T${time}`) const slot = new Date(`${dateStr}T${time}`)
+28 -5
View File
@@ -29,6 +29,7 @@ function assertValidReservationSlot(
intervalMinutes: number, intervalMinutes: number,
minLeadTimeMinutes: number, minLeadTimeMinutes: number,
maxLeadTimeDays: number, maxLeadTimeDays: number,
durationMinutes: number,
) { ) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000 const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
@@ -50,28 +51,33 @@ function assertValidReservationSlot(
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`) throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
} }
if (!isWithinOpeningHours(reservationTime)) { if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
throw new Error('Zu dieser Zeit haben wir leider geschlossen') throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
} }
} }
// A table is occupied for its own configured duration, not just the exact minute it // A table is occupied for its own configured duration, not just the exact minute it
// was booked for — so overlapping bookings (not just identical ones) are conflicts. // was booked for — so overlapping bookings (not just identical ones) are conflicts.
// Each reservation's own stored duration is used (not the current setting), since an // Each reservation's own stored duration is used (not the current setting), since an
// older booking keeps whatever duration was in effect when it was made. // older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
// added on both sides of the existing reservation, so back-to-back bookings need at
// least that much breathing room between one guest leaving and the next arriving.
async function findOverlappingReservation( async function findOverlappingReservation(
tableId: number, tableId: number,
start: Date, start: Date,
durationMinutes: number, durationMinutes: number,
bufferMinutes: number,
excludeId?: number, excludeId?: number,
) { ) {
const end = start.getTime() + durationMinutes * 60_000 const end = start.getTime() + durationMinutes * 60_000
const bufferMs = bufferMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId)) const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => { return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime() const existingStart = r.reservationTime.getTime() - bufferMs
const existingEnd = existingStart + r.durationMinutes * 60_000 const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
return existingStart < end && start.getTime() < existingEnd return existingStart < end && start.getTime() < existingEnd
}) })
} }
@@ -217,6 +223,19 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
.orderBy(reservations.reservationTime) .orderBy(reservations.reservationTime)
}) })
// Unauthenticated — powers the public booking form's time picker, so it only
// exposes what's needed to block already-taken slots (table + time span), never
// any customer data.
export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => {
return db
.select({
tableId: reservations.tableId,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
.from(reservations)
})
const createReservationSchema = z.object({ const createReservationSchema = z.object({
tableId: z.number().int().positive(), tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120), customerName: z.string().trim().min(1, 'Name is required').max(120),
@@ -245,6 +264,7 @@ export const createReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -264,6 +284,7 @@ export const createReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
) )
if (conflict) { if (conflict) {
@@ -314,6 +335,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -333,6 +355,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
data.id, data.id,
) )
+1 -1
View File
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers. bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p> </p>
<h2>Datenschutz</h2> <h2 id="datenschutz">Datenschutz</h2>
<p> <p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR. oben genannte Drenica &amp; Mousa GbR.
+127 -12
View File
@@ -1,23 +1,53 @@
import { useState } from 'react' import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router' import { createFileRoute, useRouter } from '@tanstack/react-router'
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer' import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header' import { SiteHeader } from '#/components/site-header'
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours' import { formatDateOnly } from '#/lib/datetime'
import { createReservation, getTables } from '#/lib/reservations' import {
generateTimeOptions,
getNextAvailableSlot,
getOpeningHoursForDisplay,
getOpeningRangesForWeekday,
getTodayLabelBerlin,
} from '#/lib/opening-hours'
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings' import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Home, component: Home,
loader: async () => { loader: async () => {
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()]) const [tables, settings, availability] = await Promise.all([
return { tables, settings } getTables(),
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
}, },
}) })
const OPENING_HOURS = getOpeningHoursForDisplay() const OPENING_HOURS = getOpeningHoursForDisplay()
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
const confirmedTimeFormatter = 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}$`)
type ConfirmedReservation = {
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
}
function FlameIcon() { function FlameIcon() {
return ( return (
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -33,7 +63,7 @@ function FlameIcon() {
function Home() { function Home() {
const router = useRouter() const router = useRouter()
const { tables, settings } = Route.useLoaderData() const { tables, settings, availability } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '') const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('') const [customerName, setCustomerName] = useState('')
@@ -41,24 +71,62 @@ function Home() {
const [customerPhone, setCustomerPhone] = useState('') const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2) const [partySize, setPartySize] = useState(2)
const [acceptsTerms, setAcceptsTerms] = useState(false)
const [reservationTime, setReservationTime] = useState(() => { const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot( const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
) )
return slot ? `${slot.date}T${slot.time}` : '' return slot ? `${slot.date}T${slot.time}` : ''
}) })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = getTodayLabelBerlin() const todayName = getTodayLabelBerlin()
const selectedTable = tables.find((table) => String(table.id) === String(tableId)) const selectedTable = tables.find((table) => String(table.id) === String(tableId))
const [selectedDate] = reservationTime.split('T')
const otherReservationsOnSelection = availability.filter(
(r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
// `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
// existing reservation's taken window on both sides so back-to-back bookings on the
// same table always leave that much of a gap.
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 fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
// the submit button is disabled until they'd all actually pass — instead of only
// catching a missing table/time/consent on submit and leaving everything else to
// the browser's native validation popup.
const isFormValid =
Boolean(tableId) &&
customerName.trim().length > 0 &&
EMAIL_PATTERN.test(customerEmail) &&
PHONE_PATTERN.test(customerPhone) &&
partySize >= 1 &&
partySize <= 20 &&
Boolean(reservationTime.split('T')[1]) &&
acceptsTerms
async function handleSubmit(event: React.FormEvent) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
setSuccess(null)
if (!tableId) { if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus') setError('Bitte wählen Sie einen Tisch aus')
@@ -70,9 +138,14 @@ function Home() {
return return
} }
if (!acceptsTerms) {
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await createReservation({ const created = await createReservation({
data: { data: {
tableId: Number(tableId), tableId: Number(tableId),
customerName, customerName,
@@ -84,12 +157,23 @@ function Home() {
}, },
}) })
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.') const start = new Date(created.reservationTime)
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
setConfirmedReservation({
customerName: created.customerName,
customerEmail: created.customerEmail,
tableName: selectedTable?.name ?? '',
date: confirmedDateFormatter.format(start),
startTime: confirmedTimeFormatter.format(start),
endTime: confirmedTimeFormatter.format(end),
partySize: created.partySize,
})
setCustomerName('') setCustomerName('')
setCustomerEmail('') setCustomerEmail('')
setCustomerPhone('') setCustomerPhone('')
setMessage('') setMessage('')
setPartySize(2) setPartySize(2)
setAcceptsTerms(false)
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -223,7 +307,7 @@ function Home() {
<input <input
type="tel" type="tel"
inputMode="tel" inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}" pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ." title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={customerPhone} value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)} onChange={(e) => setCustomerPhone(e.target.value)}
@@ -250,8 +334,10 @@ function Home() {
<ReservationDateTimeField <ReservationDateTimeField
value={reservationTime} value={reservationTime}
onChange={setReservationTime} onChange={setReservationTime}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes} intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes} minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays} maxLeadTimeDays={settings.maxLeadTimeDays}
/> />
</label> </label>
@@ -272,12 +358,27 @@ function Home() {
<a href="#oeffnungszeiten">Öffnungszeiten</a>. <a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p> </p>
<button type="submit" disabled={isSubmitting}> <label className="radio-option consent-option">
<input
type="checkbox"
checked={acceptsTerms}
onChange={(e) => setAcceptsTerms(e.target.checked)}
required
/>
<span>
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
Datenschutzerklärung
</a>{' '}
zu.
</span>
</label>
<button type="submit" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'} {isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button> </button>
{error && <p className="message message-error">{error}</p>} {error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form> </form>
</div> </div>
</section> </section>
@@ -311,6 +412,20 @@ function Home() {
</main> </main>
<SiteFooter /> <SiteFooter />
{confirmedReservation && (
<ReservationConfirmedModal
open
customerName={confirmedReservation.customerName}
customerEmail={confirmedReservation.customerEmail}
tableName={confirmedReservation.tableName}
date={confirmedReservation.date}
startTime={confirmedReservation.startTime}
endTime={confirmedReservation.endTime}
partySize={confirmedReservation.partySize}
onClose={() => setConfirmedReservation(null)}
/>
)}
</> </>
) )
} }
+106 -277
View File
@@ -3,14 +3,12 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog' import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts' import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
import { ReservationDetailDialog } from '#/components/reservation-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs' import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge' import { StatusBadge } from '#/components/status-badge'
import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth' import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import { import {
RESERVATION_STATUS_LABELS, RESERVATION_STATUS_LABELS,
createReservation, createReservation,
@@ -42,8 +40,11 @@ export const Route = createFileRoute('/staff/')({
}, },
}) })
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', { const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium', dateStyle: 'medium',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short', timeStyle: 'short',
}) })
@@ -55,53 +56,23 @@ function isSameDay(a: Date, b: Date) {
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number] type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
)
return {
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function StaffDashboard() { function StaffDashboard() {
const router = useRouter() const router = useRouter()
const { staffUser, reservations, tables, settings } = Route.useLoaderData() const { staffUser, reservations, tables, settings } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? '', settings))
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null) const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null) const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false) const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all') const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all') const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all') const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const dialogOpen = viewingReservation !== null || isCreating
const now = new Date() const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now)) const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0) const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
@@ -125,96 +96,27 @@ function StaffDashboard() {
return true return true
}) })
// Other reservations already sitting on the currently selected table + date, so the function closeDialog() {
// time picker can block them instead of letting staff pick a slot the server will setViewingReservation(null)
// just reject as double-booked. A reservation occupies its whole configured setIsCreating(false)
// duration, not just its exact start time, so every interval-grid slot that falls }
// inside that window counts as taken too.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== editingId &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime()
const otherEnd = otherStart + r.durationMinutes * 60_000
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
function startEdit(reservation: ReservationRow) { async function handleCreate(payload: ReservationPayload) {
setEditingId(reservation.id) await createReservation({ data: payload })
setForm({ setSuccessModal({
tableId: reservation.tableId, title: 'Reservierung angelegt',
customerName: reservation.customerName, message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
}) })
setError(null) await router.invalidate()
setSuccess(null)
} }
function cancelEdit() { async function handleUpdate(id: number, payload: ReservationPayload) {
setEditingId(null) await updateReservation({ data: { id, ...payload } })
setForm(emptyForm(tables[0]?.id ?? '', settings)) setSuccessModal({
setError(null) title: 'Reservierung aktualisiert',
} message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
})
async function handleSubmit(event: React.FormEvent) { await router.invalidate()
event.preventDefault()
setError(null)
setSuccess(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 = {
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 (editingId) {
await updateReservation({ data: { id: editingId, ...payload } })
setSuccess('Reservierung wurde aktualisiert.')
} else {
await createReservation({ data: payload })
setSuccess('Reservierung wurde angelegt.')
}
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? '', settings))
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
} }
async function confirmDelete() { async function confirmDelete() {
@@ -222,33 +124,27 @@ function StaffDashboard() {
if (!reservation) return if (!reservation) return
setPendingDelete(null) setPendingDelete(null)
setDeletingId(reservation.id)
setError(null) setError(null)
setSuccess(null)
try { try {
await deleteReservation({ data: { id: reservation.id } }) await deleteReservation({ data: { id: reservation.id } })
if (editingId === reservation.id) { setSuccessModal({
cancelEdit() title: 'Reservierung gelöscht',
} message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
setSuccess('Reservierung wurde gelöscht.') })
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
} }
} }
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) { async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
setIsUpdatingStatus(true) setIsUpdatingStatus(true)
setError(null) setError(null)
setSuccess(null)
try { try {
await updateReservationStatus({ data: { id: reservation.id, status } }) await updateReservationStatus({ data: { id: reservation.id, status } })
setViewingReservation((current) => (current ? { ...current, status } : current)) setViewingReservation((current) => (current ? { ...current, status } : current))
setSuccess(`Status wurde auf „${RESERVATION_STATUS_LABELS[status]}“ geändert.`)
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -286,6 +182,12 @@ function StaffDashboard() {
<main className="page staff-main"> <main className="page staff-main">
<h1 className="section-heading">Reservierungen</h1> <h1 className="section-heading">Reservierungen</h1>
{!dialogOpen && error && (
<div className="staff-page-messages">
<p className="message message-error">{error}</p>
</div>
)}
<div className="staff-stats"> <div className="staff-stats">
<div className="stat-card"> <div className="stat-card">
<span className="stat-value">{reservations.length}</span> <span className="stat-value">{reservations.length}</span>
@@ -307,119 +209,14 @@ function StaffDashboard() {
<ReservationCharts reservations={reservations} tables={tables} /> <ReservationCharts reservations={reservations} tables={tables} />
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
</h2>
<form className="reservation-form" 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="[+]?[0-9()\-.\s]{6,30}"
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 &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
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>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section"> <section className="staff-table-section">
<div className="table-toolbar">
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
+ Neue Reservierung
</button>
</div>
{reservations.length > 0 && ( {reservations.length > 0 && (
<div className="table-filters"> <div className="table-filters">
<input <input
@@ -474,7 +271,9 @@ function StaffDashboard() {
<table className="reservations-table reservations-table-clickable"> <table className="reservations-table reservations-table-clickable">
<thead> <thead>
<tr> <tr>
<th>Datum &amp; Uhrzeit</th> {timeFilter !== 'today' && <th>Datum</th>}
<th>Start</th>
<th>Ende</th>
<th>Nr.</th> <th>Nr.</th>
<th>Tisch</th> <th>Tisch</th>
<th>Kunde</th> <th>Kunde</th>
@@ -485,15 +284,30 @@ function StaffDashboard() {
</thead> </thead>
<tbody> <tbody>
{filteredReservations.map((reservation) => ( {filteredReservations.map((reservation) => (
<tr <tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
key={reservation.id} {timeFilter !== 'today' && (
className={editingId === reservation.id ? 'is-editing' : undefined} <td data-label="Datum" className="cell-datetime">
onClick={() => setViewingReservation(reservation)} {dateFormatter.format(new Date(reservation.reservationTime))}
> </td>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td> )}
<td>{reservation.tableNumber ?? '—'}</td> <td data-label="Start" className="cell-datetime">
<td>{reservation.tableName}</td> {timeFormatter.format(new Date(reservation.reservationTime))}
<td> </td>
<td data-label="Ende" className="cell-datetime">
{timeFormatter.format(
new Date(
new Date(reservation.reservationTime).getTime() +
reservation.durationMinutes * 60_000,
),
)}
</td>
<td data-label="Nr." className="cell-table-nr">
{reservation.tableNumber ?? '—'}
</td>
<td data-label="Tisch" className="cell-table-name">
{reservation.tableName}
</td>
<td data-label="Kunde" className="cell-customer">
{reservation.customerName} {reservation.customerName}
<br /> <br />
<span className="muted">{reservation.customerEmail}</span> <span className="muted">{reservation.customerEmail}</span>
@@ -504,11 +318,15 @@ function StaffDashboard() {
</> </>
)} )}
</td> </td>
<td>{reservation.partySize}</td> <td data-label="Personen" className="cell-party">
<td> {reservation.partySize}
</td>
<td data-label="Status" className="cell-status">
<StatusBadge status={reservation.status as ReservationStatus} /> <StatusBadge status={reservation.status as ReservationStatus} />
</td> </td>
<td className="message-cell">{reservation.message || <span className="muted">—</span>}</td> <td data-label="Nachricht" className="message-cell">
{reservation.message || <span className="muted">—</span>}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -536,22 +354,33 @@ function StaffDashboard() {
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
/> />
<ReservationDetailDialog {dialogOpen && (
reservation={viewingReservation} <ReservationDialog
onClose={() => setViewingReservation(null)} key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)} open={dialogOpen}
isUpdatingStatus={isUpdatingStatus} reservation={viewingReservation}
onEdit={() => { tables={tables}
if (!viewingReservation) return settings={settings}
startEdit(viewingReservation) reservations={reservations}
setViewingReservation(null) onClose={closeDialog}
}} onCreate={handleCreate}
onDelete={() => { onUpdate={handleUpdate}
if (!viewingReservation) return onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
setPendingDelete(viewingReservation) isUpdatingStatus={isUpdatingStatus}
setViewingReservation(null) statusError={error}
}} onDelete={() => {
isDeleting={viewingReservation !== null && deletingId === viewingReservation.id} if (!viewingReservation) return
setPendingDelete(viewingReservation)
setViewingReservation(null)
}}
/>
)}
<SuccessModal
open={successModal !== null}
title={successModal?.title ?? ''}
message={successModal?.message ?? ''}
onClose={() => setSuccessModal(null)}
/> />
</div> </div>
) )
+280 -13
View File
@@ -183,6 +183,11 @@ a {
border: 1px solid transparent; border: 1px solid transparent;
} }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary { .btn-primary {
background: var(--gold); background: var(--gold);
color: #201709; color: #201709;
@@ -203,6 +208,17 @@ a {
color: var(--text); color: var(--text);
} }
.btn-ghost {
background: none;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--text);
background: rgba(243, 234, 217, 0.06);
}
/* Sections */ /* Sections */
.section { .section {
@@ -626,6 +642,18 @@ a {
color: var(--text); color: var(--text);
} }
/* `.radio-option` centers its input against a single-line label, which breaks
down for the consent checkbox's multi-line sentence — the checkbox needs to
sit at the top of the text instead of drifting to its vertical center. */
.reservation-form .consent-option {
align-items: flex-start;
}
.reservation-form .consent-option input {
margin-top: 0.2rem;
flex-shrink: 0;
}
/* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already /* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat and shouldn't be overridden here (a previous version of this rule beat
@@ -690,6 +718,13 @@ a {
border: 1px solid #23502c; border: 1px solid #23502c;
} }
.staff-page-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.25rem 0 0;
}
/* Contact */ /* Contact */
.contact-list { .contact-list {
@@ -846,6 +881,10 @@ a {
.confirm-dialog { .confirm-dialog {
animation: none; animation: none;
} }
.reservation-confirmed-flame {
animation: none;
}
} }
.staff-main { .staff-main {
@@ -1090,6 +1129,19 @@ a {
margin-top: 2.5rem; margin-top: 2.5rem;
} }
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.table-toolbar .staff-subheading {
margin: 0;
}
.table-filters { .table-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1176,6 +1228,71 @@ a {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@media (max-width: 40rem) {
/* On mobile the column headers are hidden and each row becomes a card laid
out on a 2-column grid — related fields (table nr/name, party/status)
sit side by side, while date/time, customer and message span full width. */
.reservations-table thead {
display: none;
}
.reservations-table,
.reservations-table tbody {
display: block;
width: 100%;
}
.reservations-table tbody {
padding: 0.75rem;
}
.reservations-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
row-gap: 0.6rem;
padding: 0.9rem 1rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
}
.reservations-table tbody tr + tr {
margin-top: 0.75rem;
}
.reservations-table td {
display: block;
max-width: none;
padding: 0;
white-space: normal;
word-break: break-word;
border-bottom: none;
}
.reservations-table td[data-label]::before {
content: attr(data-label);
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.15rem;
}
.reservations-table .cell-datetime,
.reservations-table .cell-customer,
.reservations-table .message-cell {
grid-column: 1 / -1;
}
.reservations-table .message-cell {
max-width: none;
overflow: visible;
text-overflow: unset;
}
}
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1207,35 +1324,57 @@ a {
border: 1px solid #23502c; border: 1px solid #23502c;
} }
/* A single grouped 3-way toggle track, not three loose floating pills — the
active segment gets a solid fill, the other two stay flat text until hovered. */
.status-options { .status-options {
display: flex; display: flex;
flex-wrap: wrap; gap: 0.25rem;
gap: 0.5rem;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 0.25rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
} }
.status-option { .status-option {
flex: 1;
cursor: pointer; cursor: pointer;
padding: 0.4rem 0.75rem; padding: 0.45rem 0.75rem;
opacity: 0.55; transition:
background-color 0.15s ease,
color 0.15s ease;
} }
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.) /* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
on specificity so this actually wins and overrides their `border`, rather than on specificity so this actually wins and overrides their fixed `width`/`border`,
losing to them by cascade order. */ rather than losing to them by cascade order. */
.status-badge.status-option { .status-badge.status-option {
width: auto;
border: none; border: none;
} }
.status-option:hover { .status-option:disabled {
opacity: 0.85; cursor: not-allowed;
} }
.status-option.is-active { .status-option:hover:not(:disabled):not(.is-active) {
opacity: 1; color: var(--text);
/* `currentColor` picks up each status's own `color` (gold/red/green) instead background: rgba(243, 234, 217, 0.06);
of one fixed ring color that wouldn't match every status. */ }
box-shadow: 0 0 0 2px currentColor;
.status-option.is-active.status-confirmed {
background: var(--gold);
color: #201709;
}
.status-option.is-active.status-no_show {
background: var(--ember);
color: #fdeceb;
}
.status-option.is-active.status-completed {
background: #2f7a3c;
color: #eafcee;
} }
.table-actions { .table-actions {
@@ -1475,6 +1614,18 @@ a {
margin: 0; margin: 0;
} }
.confirm-dialog-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.confirm-dialog-header-actions .btn {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
}
.confirm-dialog-message { .confirm-dialog-message {
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
color: var(--text-muted); color: var(--text-muted);
@@ -1515,11 +1666,115 @@ a {
gap: 0.75rem; gap: 0.75rem;
} }
/* Puts "Bearbeiten" and "Schließen" on opposite ends of the row instead of the
usual right-aligned button cluster, since they aren't a confirm/cancel pair. */
.confirm-dialog-actions-split {
justify-content: space-between;
}
.confirm-dialog-actions .btn { .confirm-dialog-actions .btn {
padding: 0.6rem 1.25rem; padding: 0.6rem 1.25rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Beats the rule above on source order (same specificity), so it reads as a
plain text action instead of a button-shaped one. */
.confirm-dialog-actions .btn-ghost {
padding: 0;
font-size: 0.8rem;
text-decoration: underline;
}
.reservation-confirmed-dialog {
max-width: 26rem;
text-align: center;
}
.reservation-confirmed-flame {
width: 3.25rem;
height: 3.25rem;
margin: 0 auto 1rem;
color: var(--ember);
animation: reservation-confirmed-flicker 2.2s ease-in-out infinite;
}
@keyframes reservation-confirmed-flicker {
0%,
100% {
transform: rotate(0deg) scale(1);
color: var(--ember);
}
25% {
transform: rotate(-3deg) scale(1.05);
}
50% {
transform: rotate(2deg) scale(0.97);
color: var(--gold-bright);
}
75% {
transform: rotate(-2deg) scale(1.03);
}
}
.reservation-confirmed-title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--gold-bright);
}
.reservation-confirmed-message {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
.reservation-confirmed-dialog .detail-list {
text-align: left;
}
.reservation-confirmed-footnote {
margin: 0 0 1.5rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.reservation-confirmed-footnote strong {
color: var(--text);
font-weight: 600;
}
.reservation-confirmed-dialog .confirm-dialog-actions {
justify-content: center;
}
.success-modal {
max-width: 22rem;
text-align: center;
}
.success-modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin: 0 auto 1rem;
border: 1px solid var(--gold);
border-radius: 50%;
color: var(--gold-bright);
}
.success-modal .confirm-dialog-title {
margin-bottom: 0.5rem;
}
.success-modal .confirm-dialog-message {
margin-bottom: 1.5rem;
}
.success-modal .confirm-dialog-actions {
justify-content: center;
}
.reservation-detail-dialog { .reservation-detail-dialog {
max-width: 28rem; max-width: 28rem;
} }
@@ -1528,6 +1783,18 @@ a {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.reservation-dialog-editing {
max-width: 34rem;
}
/* The dialog card already provides the background/border/padding — strip
`.reservation-form`'s own so the form doesn't render as a card-in-a-card. */
.reservation-form-dialog {
background: none;
border: none;
padding: 0;
}
.detail-list { .detail-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;