Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3aa45e02 |
@@ -1,108 +0,0 @@
|
||||
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,9 +14,6 @@ type Props = {
|
||||
// made — both configurable restaurant settings, falling back to sane defaults.
|
||||
intervalMinutes?: 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,
|
||||
// since not every caller of this field needs the restriction.
|
||||
maxLeadTimeDays?: number
|
||||
@@ -104,7 +101,6 @@ export function ReservationDateTimeField({
|
||||
bookedTimes = [],
|
||||
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
minLeadTimeMinutes = 0,
|
||||
durationMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
maxLeadTimeDays,
|
||||
}: Props) {
|
||||
const { date, time } = splitValue(value)
|
||||
@@ -193,7 +189,7 @@ export function ReservationDateTimeField({
|
||||
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
|
||||
function optionsForDate(dateStr: string) {
|
||||
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
|
||||
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
|
||||
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
|
||||
}
|
||||
|
||||
function isSelectableDate(dateStr: string) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -57,16 +57,12 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
|
||||
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
||||
}
|
||||
|
||||
// `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) {
|
||||
export function isWithinOpeningHours(date: Date) {
|
||||
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
|
||||
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||
|
||||
return ranges.some(
|
||||
(range) =>
|
||||
minutesSinceMidnight >= toMinutes(range.start) &&
|
||||
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
|
||||
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,15 +89,9 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
|
||||
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(
|
||||
ranges: OpeningRange[],
|
||||
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
|
||||
durationMinutes: number = intervalMinutes,
|
||||
): string[] {
|
||||
const options: string[] = []
|
||||
|
||||
@@ -109,7 +99,7 @@ export function generateTimeOptions(
|
||||
const startMinutes = toMinutes(range.start)
|
||||
const endMinutes = toMinutes(range.end)
|
||||
|
||||
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
|
||||
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = minutes % 60
|
||||
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
|
||||
@@ -122,14 +112,13 @@ export function generateTimeOptions(
|
||||
export function getNextAvailableSlot(
|
||||
from: Date,
|
||||
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
|
||||
durationMinutes: number = intervalMinutes,
|
||||
): { date: string; time: string } | null {
|
||||
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
|
||||
const day = new Date(from)
|
||||
day.setDate(from.getDate() + dayOffset)
|
||||
|
||||
const dateStr = formatDateOnly(day)
|
||||
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
|
||||
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes)
|
||||
|
||||
for (const time of options) {
|
||||
const slot = new Date(`${dateStr}T${time}`)
|
||||
|
||||
+5
-28
@@ -29,7 +29,6 @@ function assertValidReservationSlot(
|
||||
intervalMinutes: number,
|
||||
minLeadTimeMinutes: number,
|
||||
maxLeadTimeDays: number,
|
||||
durationMinutes: number,
|
||||
) {
|
||||
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
|
||||
|
||||
@@ -51,33 +50,28 @@ function assertValidReservationSlot(
|
||||
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
|
||||
}
|
||||
|
||||
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
|
||||
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
|
||||
if (!isWithinOpeningHours(reservationTime)) {
|
||||
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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. `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.
|
||||
// older booking keeps whatever duration was in effect when it was made.
|
||||
async function findOverlappingReservation(
|
||||
tableId: number,
|
||||
start: Date,
|
||||
durationMinutes: number,
|
||||
bufferMinutes: number,
|
||||
excludeId?: number,
|
||||
) {
|
||||
const end = start.getTime() + durationMinutes * 60_000
|
||||
const bufferMs = bufferMinutes * 60_000
|
||||
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
|
||||
|
||||
return candidates.find((r) => {
|
||||
if (excludeId !== undefined && r.id === excludeId) return false
|
||||
const existingStart = r.reservationTime.getTime() - bufferMs
|
||||
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
|
||||
const existingStart = r.reservationTime.getTime()
|
||||
const existingEnd = existingStart + r.durationMinutes * 60_000
|
||||
return existingStart < end && start.getTime() < existingEnd
|
||||
})
|
||||
}
|
||||
@@ -223,19 +217,6 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
|
||||
.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({
|
||||
tableId: z.number().int().positive(),
|
||||
customerName: z.string().trim().min(1, 'Name is required').max(120),
|
||||
@@ -264,7 +245,6 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
@@ -284,7 +264,6 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
@@ -335,7 +314,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
@@ -355,7 +333,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
data.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ function Impressum() {
|
||||
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
|
||||
</p>
|
||||
|
||||
<h2 id="datenschutz">Datenschutz</h2>
|
||||
<h2>Datenschutz</h2>
|
||||
<p>
|
||||
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
|
||||
oben genannte Drenica & Mousa GbR.
|
||||
|
||||
+12
-127
@@ -1,53 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { SiteFooter } from '#/components/site-footer'
|
||||
import { SiteHeader } from '#/components/site-header'
|
||||
import { formatDateOnly } from '#/lib/datetime'
|
||||
import {
|
||||
generateTimeOptions,
|
||||
getNextAvailableSlot,
|
||||
getOpeningHoursForDisplay,
|
||||
getOpeningRangesForWeekday,
|
||||
getTodayLabelBerlin,
|
||||
} from '#/lib/opening-hours'
|
||||
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
|
||||
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
|
||||
import { createReservation, getTables } from '#/lib/reservations'
|
||||
import { getReservationSettings } from '#/lib/settings'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
loader: async () => {
|
||||
const [tables, settings, availability] = await Promise.all([
|
||||
getTables(),
|
||||
getReservationSettings(),
|
||||
getReservationAvailability(),
|
||||
])
|
||||
return { tables, settings, availability }
|
||||
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
|
||||
return { tables, settings }
|
||||
},
|
||||
})
|
||||
|
||||
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() {
|
||||
return (
|
||||
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
@@ -63,7 +33,7 @@ function FlameIcon() {
|
||||
|
||||
function Home() {
|
||||
const router = useRouter()
|
||||
const { tables, settings, availability } = Route.useLoaderData()
|
||||
const { tables, settings } = Route.useLoaderData()
|
||||
|
||||
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
@@ -71,62 +41,24 @@ function Home() {
|
||||
const [customerPhone, setCustomerPhone] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [partySize, setPartySize] = useState(2)
|
||||
const [acceptsTerms, setAcceptsTerms] = useState(false)
|
||||
const [reservationTime, setReservationTime] = useState(() => {
|
||||
const slot = getNextAvailableSlot(
|
||||
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
return slot ? `${slot.date}T${slot.time}` : ''
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const todayName = getTodayLabelBerlin()
|
||||
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) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (!tableId) {
|
||||
setError('Bitte wählen Sie einen Tisch aus')
|
||||
@@ -138,14 +70,9 @@ function Home() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!acceptsTerms) {
|
||||
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const created = await createReservation({
|
||||
await createReservation({
|
||||
data: {
|
||||
tableId: Number(tableId),
|
||||
customerName,
|
||||
@@ -157,23 +84,12 @@ function Home() {
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
|
||||
setCustomerName('')
|
||||
setCustomerEmail('')
|
||||
setCustomerPhone('')
|
||||
setMessage('')
|
||||
setPartySize(2)
|
||||
setAcceptsTerms(false)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
@@ -307,7 +223,7 @@ function Home() {
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
pattern={PHONE_PATTERN_SOURCE}
|
||||
pattern="[+]?[0-9()\-.\s]{6,30}"
|
||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
@@ -334,10 +250,8 @@ function Home() {
|
||||
<ReservationDateTimeField
|
||||
value={reservationTime}
|
||||
onChange={setReservationTime}
|
||||
bookedTimes={bookedTimesForForm}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
durationMinutes={settings.reservationDurationMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
@@ -358,27 +272,12 @@ function Home() {
|
||||
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
||||
</p>
|
||||
|
||||
<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}>
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
||||
</button>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -412,20 +311,6 @@ function Home() {
|
||||
</main>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+266
-95
@@ -3,12 +3,14 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { ReservationCharts } from '#/components/reservation-charts'
|
||||
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { ReservationDetailDialog } from '#/components/reservation-detail-dialog'
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { StatusBadge } from '#/components/status-badge'
|
||||
import { SuccessModal } from '#/components/success-modal'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
|
||||
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
|
||||
import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
createReservation,
|
||||
@@ -40,11 +42,8 @@ export const Route = createFileRoute('/staff/')({
|
||||
},
|
||||
})
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
@@ -56,23 +55,53 @@ function isSameDay(a: Date, b: Date) {
|
||||
|
||||
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() {
|
||||
const router = useRouter()
|
||||
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 [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
|
||||
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [tableFilter, setTableFilter] = useState('all')
|
||||
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
|
||||
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
|
||||
|
||||
const dialogOpen = viewingReservation !== null || isCreating
|
||||
|
||||
const now = new Date()
|
||||
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
|
||||
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
|
||||
@@ -96,27 +125,96 @@ function StaffDashboard() {
|
||||
return true
|
||||
})
|
||||
|
||||
function closeDialog() {
|
||||
setViewingReservation(null)
|
||||
setIsCreating(false)
|
||||
// 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.
|
||||
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) {
|
||||
setEditingId(reservation.id)
|
||||
setForm({
|
||||
tableId: reservation.tableId,
|
||||
customerName: reservation.customerName,
|
||||
customerEmail: reservation.customerEmail,
|
||||
customerPhone: reservation.customerPhone ?? '',
|
||||
message: reservation.message ?? '',
|
||||
partySize: reservation.partySize,
|
||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
||||
})
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
async function handleCreate(payload: ReservationPayload) {
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm(tables[0]?.id ?? '', settings))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
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 })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung angelegt',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
|
||||
})
|
||||
await router.invalidate()
|
||||
setSuccess('Reservierung wurde angelegt.')
|
||||
}
|
||||
|
||||
async function handleUpdate(id: number, payload: ReservationPayload) {
|
||||
await updateReservation({ data: { id, ...payload } })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung aktualisiert',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
|
||||
})
|
||||
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() {
|
||||
@@ -124,27 +222,33 @@ function StaffDashboard() {
|
||||
if (!reservation) return
|
||||
|
||||
setPendingDelete(null)
|
||||
setDeletingId(reservation.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await deleteReservation({ data: { id: reservation.id } })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung gelöscht',
|
||||
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
|
||||
})
|
||||
if (editingId === reservation.id) {
|
||||
cancelEdit()
|
||||
}
|
||||
setSuccess('Reservierung wurde gelöscht.')
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
|
||||
setIsUpdatingStatus(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await updateReservationStatus({ data: { id: reservation.id, status } })
|
||||
setViewingReservation((current) => (current ? { ...current, status } : current))
|
||||
setSuccess(`Status wurde auf „${RESERVATION_STATUS_LABELS[status]}“ geändert.`)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
@@ -182,12 +286,6 @@ function StaffDashboard() {
|
||||
<main className="page staff-main">
|
||||
<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="stat-card">
|
||||
<span className="stat-value">{reservations.length}</span>
|
||||
@@ -209,14 +307,119 @@ function StaffDashboard() {
|
||||
|
||||
<ReservationCharts reservations={reservations} tables={tables} />
|
||||
|
||||
<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>
|
||||
<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 & 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">
|
||||
{reservations.length > 0 && (
|
||||
<div className="table-filters">
|
||||
<input
|
||||
@@ -271,9 +474,7 @@ function StaffDashboard() {
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
{timeFilter !== 'today' && <th>Datum</th>}
|
||||
<th>Start</th>
|
||||
<th>Ende</th>
|
||||
<th>Datum & Uhrzeit</th>
|
||||
<th>Nr.</th>
|
||||
<th>Tisch</th>
|
||||
<th>Kunde</th>
|
||||
@@ -284,30 +485,15 @@ function StaffDashboard() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredReservations.map((reservation) => (
|
||||
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
|
||||
{timeFilter !== 'today' && (
|
||||
<td data-label="Datum" className="cell-datetime">
|
||||
{dateFormatter.format(new Date(reservation.reservationTime))}
|
||||
</td>
|
||||
)}
|
||||
<td data-label="Start" className="cell-datetime">
|
||||
{timeFormatter.format(new Date(reservation.reservationTime))}
|
||||
</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">
|
||||
<tr
|
||||
key={reservation.id}
|
||||
className={editingId === reservation.id ? 'is-editing' : undefined}
|
||||
onClick={() => setViewingReservation(reservation)}
|
||||
>
|
||||
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
|
||||
<td>{reservation.tableNumber ?? '—'}</td>
|
||||
<td>{reservation.tableName}</td>
|
||||
<td>
|
||||
{reservation.customerName}
|
||||
<br />
|
||||
<span className="muted">{reservation.customerEmail}</span>
|
||||
@@ -318,15 +504,11 @@ function StaffDashboard() {
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Personen" className="cell-party">
|
||||
{reservation.partySize}
|
||||
</td>
|
||||
<td data-label="Status" className="cell-status">
|
||||
<td>{reservation.partySize}</td>
|
||||
<td>
|
||||
<StatusBadge status={reservation.status as ReservationStatus} />
|
||||
</td>
|
||||
<td data-label="Nachricht" className="message-cell">
|
||||
{reservation.message || <span className="muted">—</span>}
|
||||
</td>
|
||||
<td className="message-cell">{reservation.message || <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -354,33 +536,22 @@ function StaffDashboard() {
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
{dialogOpen && (
|
||||
<ReservationDialog
|
||||
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
|
||||
open={dialogOpen}
|
||||
<ReservationDetailDialog
|
||||
reservation={viewingReservation}
|
||||
tables={tables}
|
||||
settings={settings}
|
||||
reservations={reservations}
|
||||
onClose={closeDialog}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onClose={() => setViewingReservation(null)}
|
||||
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
|
||||
isUpdatingStatus={isUpdatingStatus}
|
||||
statusError={error}
|
||||
onEdit={() => {
|
||||
if (!viewingReservation) return
|
||||
startEdit(viewingReservation)
|
||||
setViewingReservation(null)
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (!viewingReservation) return
|
||||
setPendingDelete(viewingReservation)
|
||||
setViewingReservation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SuccessModal
|
||||
open={successModal !== null}
|
||||
title={successModal?.title ?? ''}
|
||||
message={successModal?.message ?? ''}
|
||||
onClose={() => setSuccessModal(null)}
|
||||
isDeleting={viewingReservation !== null && deletingId === viewingReservation.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+13
-280
@@ -183,11 +183,6 @@ a {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gold);
|
||||
color: #201709;
|
||||
@@ -208,17 +203,6 @@ a {
|
||||
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 */
|
||||
|
||||
.section {
|
||||
@@ -642,18 +626,6 @@ a {
|
||||
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
|
||||
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
|
||||
and shouldn't be overridden here (a previous version of this rule beat
|
||||
@@ -718,13 +690,6 @@ a {
|
||||
border: 1px solid #23502c;
|
||||
}
|
||||
|
||||
.staff-page-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Contact */
|
||||
|
||||
.contact-list {
|
||||
@@ -881,10 +846,6 @@ a {
|
||||
.confirm-dialog {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.reservation-confirmed-flame {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-main {
|
||||
@@ -1129,19 +1090,6 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1228,71 +1176,6 @@ a {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1324,57 +1207,35 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.45rem 0.75rem;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
padding: 0.4rem 0.75rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
|
||||
on specificity so this actually wins and overrides their fixed `width`/`border`,
|
||||
rather than losing to them by cascade order. */
|
||||
on specificity so this actually wins and overrides their `border`, rather than
|
||||
losing to them by cascade order. */
|
||||
.status-badge.status-option {
|
||||
width: auto;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-option:disabled {
|
||||
cursor: not-allowed;
|
||||
.status-option:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.status-option:hover:not(:disabled):not(.is-active) {
|
||||
color: var(--text);
|
||||
background: rgba(243, 234, 217, 0.06);
|
||||
}
|
||||
|
||||
.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;
|
||||
.status-option.is-active {
|
||||
opacity: 1;
|
||||
/* `currentColor` picks up each status's own `color` (gold/red/green) instead
|
||||
of one fixed ring color that wouldn't match every status. */
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
@@ -1614,18 +1475,6 @@ a {
|
||||
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 {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--text-muted);
|
||||
@@ -1666,115 +1515,11 @@ a {
|
||||
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 {
|
||||
padding: 0.6rem 1.25rem;
|
||||
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 {
|
||||
max-width: 28rem;
|
||||
}
|
||||
@@ -1783,18 +1528,6 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user