wip
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function DotsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.8" />
|
||||
<circle cx="12" cy="12" r="1.8" />
|
||||
<circle cx="12" cy="19" r="1.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type Position = { top: number; right: number }
|
||||
|
||||
// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
|
||||
// dropdown — used to tuck row actions away instead of listing every button inline.
|
||||
// The panel is portaled to <body> and positioned with `position: fixed` from a
|
||||
// measured rect rather than anchored with `position: absolute` in place, since
|
||||
// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
|
||||
// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
|
||||
// would otherwise get cut off at the table's bottom edge.
|
||||
export function ActionMenu({ label = 'Aktionen', children }: Props) {
|
||||
const [position, setPosition] = useState<Position | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const open = position !== null
|
||||
|
||||
function close() {
|
||||
setPosition(null)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
// The menu's position is measured once, on open — rather than track scroll to
|
||||
// keep it glued to the trigger, just close it, matching how most native and
|
||||
// web dropdowns behave when the page moves under them.
|
||||
function handleScroll() {
|
||||
close()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="action-menu-trigger"
|
||||
aria-label={label}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<DotsIcon />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
position &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="action-menu-panel"
|
||||
role="menu"
|
||||
style={{ top: position.top, right: position.right }}
|
||||
onClick={close}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
|
||||
type StaffUser = { name: string; role?: string | null }
|
||||
type SectionLink = { label: string; hash: string }
|
||||
|
||||
type Props = {
|
||||
staffUser?: StaffUser | null
|
||||
sectionLinks?: SectionLink[]
|
||||
}
|
||||
|
||||
function MenuIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar({ staffUser, sectionLinks }: Props) {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (navRef.current && !navRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut()
|
||||
await router.navigate({ to: '/staff/login' })
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="app-sidebar" aria-label="Hauptnavigation" ref={navRef}>
|
||||
<div className="app-sidebar-bar">
|
||||
<Link className="wordmark app-sidebar-brand" to="/" onClick={() => setIsOpen(false)}>
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="app-sidebar-toggle"
|
||||
aria-label={isOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={isOpen ? 'app-sidebar-panel is-open' : 'app-sidebar-panel'} onClick={() => setIsOpen(false)}>
|
||||
<ul className="app-sidebar-nav">
|
||||
{sectionLinks && sectionLinks.length > 0 && (
|
||||
<>
|
||||
{sectionLinks.map((section) => (
|
||||
<li key={section.hash}>
|
||||
<Link to="/" hash={section.hash}>
|
||||
{section.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li className="app-sidebar-divider" role="separator" />
|
||||
</>
|
||||
)}
|
||||
{staffUser && (
|
||||
<li>
|
||||
<Link to="/staff" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
|
||||
Reservierungen
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{staffUser?.role === 'admin' && (
|
||||
<li>
|
||||
<Link to="/staff/team" activeProps={{ className: 'is-active' }}>
|
||||
Mitarbeiter
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{staffUser && <li className="app-sidebar-divider" role="separator" />}
|
||||
<li>
|
||||
<Link to="/" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
|
||||
Startseite
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/impressum" activeProps={{ className: 'is-active' }}>
|
||||
Impressum
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="app-sidebar-footer">
|
||||
{staffUser ? (
|
||||
<>
|
||||
<span className="app-sidebar-user">Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link className="btn btn-secondary" to="/staff/login">
|
||||
Mitarbeiter-Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
title: string
|
||||
message: React.ReactNode
|
||||
// A final, visually distinct callout for what irreversible actions this
|
||||
// confirms — kept separate from `message` so callers don't have to hand-roll
|
||||
// the emphasis every time (e.g. "This cannot be undone.").
|
||||
warning?: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function WarningIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3.5 21.5 20h-19L12 3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M12 9.5v4.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16.7" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
warning,
|
||||
confirmLabel = 'Bestätigen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
danger,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
confirmRef.current?.focus()
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onCancel}>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-message"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="confirm-dialog-title" className="confirm-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
<p id="confirm-dialog-message" className="confirm-dialog-message">
|
||||
{message}
|
||||
</p>
|
||||
{warning && (
|
||||
<div className="confirm-dialog-warning">
|
||||
<WarningIcon />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours'
|
||||
|
||||
type ReservationLike = {
|
||||
tableId: number
|
||||
reservationTime: Date
|
||||
}
|
||||
|
||||
type TableLike = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
reservations: ReservationLike[]
|
||||
tables: TableLike[]
|
||||
}
|
||||
|
||||
type BarDatum = {
|
||||
label: string
|
||||
value: number
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
type Granularity = 'today' | 'week' | 'month'
|
||||
type DateBucketGranularity = 'week' | 'month'
|
||||
|
||||
const GRANULARITY_LABELS: Record<Granularity, string> = {
|
||||
today: 'Heute',
|
||||
week: 'Woche',
|
||||
month: 'Monat',
|
||||
}
|
||||
|
||||
// How many buckets — and how far out — each granularity covers: enough weeks/months
|
||||
// to spot a trend without the axis becoming unreadable.
|
||||
const BUCKET_COUNTS: Record<DateBucketGranularity, number> = {
|
||||
week: 8,
|
||||
month: 6,
|
||||
}
|
||||
|
||||
const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' })
|
||||
const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||
|
||||
function reservationsLabel(count: number) {
|
||||
return count === 1 ? 'Reservierung' : 'Reservierungen'
|
||||
}
|
||||
|
||||
function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) {
|
||||
const d = new Date(date)
|
||||
if (granularity === 'week') d.setDate(d.getDate() + amount * 7)
|
||||
else d.setMonth(d.getMonth() + amount)
|
||||
return d
|
||||
}
|
||||
|
||||
// The start of the first bucket: the Monday of this week, or the 1st of this month —
|
||||
// so the current, still-in-progress bucket covers the whole period, not just what's
|
||||
// left of it (the caller is expected to pass in ALL reservations, not just upcoming
|
||||
// ones, so this week's/month's earlier days are still counted).
|
||||
function bucketRangeStart(granularity: DateBucketGranularity) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
|
||||
if (granularity === 'month') {
|
||||
now.setDate(1)
|
||||
return now
|
||||
}
|
||||
const mondayOffset = (now.getDay() + 6) % 7
|
||||
now.setDate(now.getDate() - mondayOffset)
|
||||
return now
|
||||
}
|
||||
|
||||
function formatBucketLabel(start: Date, granularity: DateBucketGranularity) {
|
||||
if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start)
|
||||
return WEEK_LABEL_FORMATTER.format(start)
|
||||
}
|
||||
|
||||
function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) {
|
||||
const countLabel = `${count} ${reservationsLabel(count)}`
|
||||
|
||||
if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}`
|
||||
|
||||
const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
||||
return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}`
|
||||
}
|
||||
|
||||
function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] {
|
||||
const rangeStart = bucketRangeStart(granularity)
|
||||
const bucketCount = BUCKET_COUNTS[granularity]
|
||||
|
||||
return Array.from({ length: bucketCount }, (_, i) => {
|
||||
const start = addBucketUnit(rangeStart, granularity, i)
|
||||
const end = addBucketUnit(rangeStart, granularity, i + 1)
|
||||
const count = reservations.filter((r) => {
|
||||
const time = new Date(r.reservationTime).getTime()
|
||||
return time >= start.getTime() && time < end.getTime()
|
||||
}).length
|
||||
|
||||
return {
|
||||
label: formatBucketLabel(start, granularity),
|
||||
value: count,
|
||||
tooltip: formatBucketTooltip(start, end, granularity, count),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The restaurant can have more than one service window in a day (e.g. lunch +
|
||||
// dinner) — span the whole gap between them rather than faceting the chart, so a
|
||||
// quiet hour between services reads as "no bookings then", not a missing bar.
|
||||
function hourSpanForRanges(ranges: OpeningRange[]) {
|
||||
const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0])))
|
||||
const endHour = Math.max(
|
||||
...ranges.map((r) => {
|
||||
const [hour, minute] = r.end.split(':').map(Number)
|
||||
return minute > 0 ? hour + 1 : hour
|
||||
}),
|
||||
)
|
||||
return { startHour, endHour }
|
||||
}
|
||||
|
||||
function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] {
|
||||
const { startHour, endHour } = hourSpanForRanges(ranges)
|
||||
const dayStart = new Date()
|
||||
dayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
return Array.from({ length: endHour - startHour }, (_, i) => {
|
||||
const hour = startHour + i
|
||||
const start = new Date(dayStart)
|
||||
start.setHours(hour)
|
||||
const end = new Date(dayStart)
|
||||
end.setHours(hour + 1)
|
||||
|
||||
const count = reservations.filter((r) => {
|
||||
const time = new Date(r.reservationTime).getTime()
|
||||
return time >= start.getTime() && time < end.getTime()
|
||||
}).length
|
||||
|
||||
return {
|
||||
label: `${String(hour).padStart(2, '0')}:00`,
|
||||
value: count,
|
||||
tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildTimelineData(
|
||||
reservations: ReservationLike[],
|
||||
granularity: Granularity,
|
||||
): { data: BarDatum[]; emptyMessage: string } {
|
||||
if (granularity === 'today') {
|
||||
const ranges = getOpeningRangesForWeekday(new Date().getDay())
|
||||
if (ranges.length === 0) {
|
||||
return { data: [], emptyMessage: 'Heute ist Ruhetag.' }
|
||||
}
|
||||
return {
|
||||
data: buildHourlyCountsForToday(reservations, ranges),
|
||||
emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: buildBucketedCounts(reservations, granularity),
|
||||
emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.',
|
||||
}
|
||||
}
|
||||
|
||||
// The full date range the timeline chart currently covers, so the per-table chart
|
||||
// can be filtered to the same window and the two charts always tell one consistent
|
||||
// story instead of the table breakdown silently covering a different span.
|
||||
function getTimelineRange(granularity: Granularity): { start: Date; end: Date } {
|
||||
if (granularity === 'today') {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const start = bucketRangeStart(granularity)
|
||||
const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity])
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] {
|
||||
return tables.map((table) => {
|
||||
const count = reservations.filter((r) => r.tableId === table.id).length
|
||||
return {
|
||||
label: table.name,
|
||||
value: count,
|
||||
tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines
|
||||
// never land on an awkward number like 7 or 13.
|
||||
function niceTicks(maxValue: number, tickCount = 4): number[] {
|
||||
if (maxValue <= 0) return [0, 1]
|
||||
|
||||
const rawStep = maxValue / (tickCount - 1)
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
|
||||
const residual = rawStep / magnitude
|
||||
const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10
|
||||
// Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just
|
||||
// round two distinct ticks down to the same displayed integer.
|
||||
const step = Math.max(1, niceResidual * magnitude)
|
||||
const niceMax = Math.ceil(maxValue / step) * step
|
||||
|
||||
const ticks: number[] = []
|
||||
for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v))
|
||||
return ticks
|
||||
}
|
||||
|
||||
function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) {
|
||||
const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0))
|
||||
const chartMax = ticks[ticks.length - 1] || 1
|
||||
|
||||
return (
|
||||
<div className="bar-chart">
|
||||
<div className="bar-chart-plot">
|
||||
{ticks.map((tick) => (
|
||||
<div key={tick} className="bar-chart-gridline" style={{ bottom: `${(tick / chartMax) * 100}%` }}>
|
||||
<span className="bar-chart-tick">{tick}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="bar-chart-bars">
|
||||
{data.map((d, i) => (
|
||||
<div className="bar-chart-col" key={i} tabIndex={0} aria-label={d.tooltip}>
|
||||
<div className="bar-chart-bar" style={{ height: `${(d.value / chartMax) * 100}%` }}>
|
||||
{showValueLabels && d.value > 0 && <span className="bar-chart-value">{d.value}</span>}
|
||||
</div>
|
||||
<div className="bar-chart-tooltip">{d.tooltip}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bar-chart-axis">
|
||||
{data.map((d, i) => (
|
||||
<span key={i} className="bar-chart-axis-label">
|
||||
{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
headerExtra,
|
||||
data,
|
||||
showValueLabels,
|
||||
emptyMessage,
|
||||
}: {
|
||||
title: string
|
||||
headerExtra?: React.ReactNode
|
||||
data: BarDatum[]
|
||||
showValueLabels?: boolean
|
||||
emptyMessage: string
|
||||
}) {
|
||||
const hasData = data.some((d) => d.value > 0)
|
||||
|
||||
return (
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-header">
|
||||
<h3 className="chart-title">{title}</h3>
|
||||
{headerExtra}
|
||||
</div>
|
||||
{hasData ? <BarChartBody data={data} showValueLabels={showValueLabels} /> : <p className="empty-state">{emptyMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) {
|
||||
return (
|
||||
<div className="chart-toggle" role="group" aria-label="Zeitraum">
|
||||
{(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
className={g === value ? 'is-active' : undefined}
|
||||
aria-pressed={g === value}
|
||||
onClick={() => onChange(g)}
|
||||
>
|
||||
{GRANULARITY_LABELS[g]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReservationCharts({ reservations, tables }: Props) {
|
||||
const [granularity, setGranularity] = useState<Granularity>('today')
|
||||
|
||||
const timeline = buildTimelineData(reservations, granularity)
|
||||
|
||||
const { start, end } = getTimelineRange(granularity)
|
||||
const reservationsInRange = reservations.filter((r) => {
|
||||
const time = new Date(r.reservationTime).getTime()
|
||||
return time >= start.getTime() && time < end.getTime()
|
||||
})
|
||||
const tableCounts = buildTableCounts(reservationsInRange, tables)
|
||||
|
||||
return (
|
||||
<div className="charts-grid">
|
||||
<ChartCard
|
||||
title="Reservierungen"
|
||||
headerExtra={<GranularityToggle value={granularity} onChange={setGranularity} />}
|
||||
data={timeline.data}
|
||||
emptyMessage={timeline.emptyMessage}
|
||||
/>
|
||||
<ChartCard
|
||||
title="Reservierungen je Tisch"
|
||||
data={tableCounts}
|
||||
showValueLabels
|
||||
emptyMessage={timeline.emptyMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { formatDateOnly } from '#/lib/datetime'
|
||||
import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
// "HH:MM" times already taken (e.g. by another reservation on the same table)
|
||||
// for the currently selected date — shown disabled rather than removed, so the
|
||||
// slot doesn't look like it doesn't exist, just that it isn't free.
|
||||
bookedTimes?: string[]
|
||||
// How far apart selectable times are, and how far in advance a booking must be
|
||||
// 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
|
||||
}
|
||||
|
||||
type DayCell = { day: number; dateStr: string } | null
|
||||
|
||||
function splitValue(value: string) {
|
||||
const [date = '', time = ''] = value.split('T')
|
||||
return { date, time }
|
||||
}
|
||||
|
||||
function getWeekday(dateStr: string) {
|
||||
return new Date(`${dateStr}T00:00`).getDay()
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function isoToDisplay(iso: string) {
|
||||
if (!iso) return ''
|
||||
const [year, month, day] = iso.split('-')
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
|
||||
// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1".
|
||||
function maskDateInput(raw: string) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8)
|
||||
let out = digits.slice(0, 2)
|
||||
if (digits.length > 2) out += '.' + digits.slice(2, 4)
|
||||
if (digits.length > 4) out += '.' + digits.slice(4, 8)
|
||||
return out
|
||||
}
|
||||
|
||||
// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date.
|
||||
function parseDisplayDate(raw: string) {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (digits.length !== 8) return null
|
||||
|
||||
const day = Number(digits.slice(0, 2))
|
||||
const month = Number(digits.slice(2, 4))
|
||||
const year = Number(digits.slice(4, 8))
|
||||
const parsed = new Date(year, month - 1, day)
|
||||
|
||||
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`
|
||||
}
|
||||
|
||||
// Monday-first weeks covering the given month, with null filler cells for days
|
||||
// that belong to the adjacent month.
|
||||
function getMonthGrid(year: number, month: number): DayCell[][] {
|
||||
const firstOfMonth = new Date(year, month, 1)
|
||||
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
|
||||
const cells: DayCell[] = Array(leadingBlanks).fill(null)
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` })
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
|
||||
const weeks: DayCell[][] = []
|
||||
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
|
||||
return weeks
|
||||
}
|
||||
|
||||
const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<rect x="3.5" y="5" width="17" height="16" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReservationDateTimeField({
|
||||
value,
|
||||
onChange,
|
||||
bookedTimes = [],
|
||||
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
minLeadTimeMinutes = 0,
|
||||
durationMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
maxLeadTimeDays,
|
||||
}: Props) {
|
||||
const { date, time } = splitValue(value)
|
||||
const [dateText, setDateText] = useState(() => isoToDisplay(date))
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
|
||||
const [view, setView] = useState(() => {
|
||||
const [year, month] = (date || formatDateOnly(new Date())).split('-')
|
||||
return { year: Number(year), month: Number(month) - 1 }
|
||||
})
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDateText(isoToDisplay(date))
|
||||
}, [date])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCalendarOpen) return
|
||||
|
||||
const [year, month] = (date || formatDateOnly(new Date())).split('-')
|
||||
setView({ year: Number(year), month: Number(month) - 1 })
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsCalendarOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setIsCalendarOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// Only locks the background on mobile, where the popover becomes a fixed,
|
||||
// full-screen overlay that would otherwise let the page behind it scroll —
|
||||
// on desktop the popover is just a small anchored dropdown, and pinning the
|
||||
// body there only removes the scrollbar and shifts the whole page sideways.
|
||||
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
|
||||
// CSS switches on.
|
||||
const isMobile = window.matchMedia('(max-width: 30rem)').matches
|
||||
|
||||
if (!isMobile) {
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
|
||||
// iOS Safari, so the body is pinned in place instead and the scroll position
|
||||
// restored after.
|
||||
const scrollY = window.scrollY
|
||||
const body = document.body.style
|
||||
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
|
||||
body.position = 'fixed'
|
||||
body.top = `-${scrollY}px`
|
||||
body.left = '0'
|
||||
body.right = '0'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
body.position = previousStyle.position
|
||||
body.top = previousStyle.top
|
||||
body.left = previousStyle.left
|
||||
body.right = previousStyle.right
|
||||
// `behavior: 'instant'` is required, not just the default — the page sets
|
||||
// `scroll-behavior: smooth` globally, which would otherwise animate this
|
||||
// restore from the top of the page instead of snapping back in place.
|
||||
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
|
||||
}
|
||||
// Only re-run when the popover opens/closes — reopening should reset to the
|
||||
// currently selected month, not follow every keystroke in the text field.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCalendarOpen])
|
||||
|
||||
const today = formatDateOnly(new Date())
|
||||
const isPast = date !== '' && date < today
|
||||
const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000)
|
||||
const latestDate =
|
||||
maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000))
|
||||
const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate
|
||||
|
||||
// Past dates and dates beyond the booking horizon never have slots — shared by both
|
||||
// the render below and commitDate, so a date committed from anywhere (typed, or
|
||||
// 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)
|
||||
}
|
||||
|
||||
function isSelectableDate(dateStr: string) {
|
||||
return optionsForDate(dateStr).length > 0
|
||||
}
|
||||
|
||||
const allOptions = optionsForDate(date)
|
||||
// Compares full timestamps (not just "is it today") so a lead time longer than
|
||||
// what's left of today correctly spills over into tomorrow's early slots too.
|
||||
const timeOptions = allOptions.filter(
|
||||
(option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(),
|
||||
)
|
||||
|
||||
const bookedTimesSet = new Set(bookedTimes)
|
||||
const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option))
|
||||
|
||||
const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0
|
||||
const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0
|
||||
const dateIncomplete = parseDisplayDate(dateText) === null
|
||||
|
||||
function commitDate(newDate: string) {
|
||||
const newOptions = optionsForDate(newDate)
|
||||
const stillValid = newOptions.includes(time)
|
||||
onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`)
|
||||
}
|
||||
|
||||
function handleTextChange(raw: string) {
|
||||
const masked = maskDateInput(raw)
|
||||
setDateText(masked)
|
||||
|
||||
const parsed = parseDisplayDate(masked)
|
||||
if (parsed) {
|
||||
commitDate(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDayPick(dateStr: string) {
|
||||
commitDate(dateStr)
|
||||
setIsCalendarOpen(false)
|
||||
}
|
||||
|
||||
function handleTimeChange(newTime: string) {
|
||||
onChange(`${date}T${newTime}`)
|
||||
}
|
||||
|
||||
let timePlaceholder = 'Uhrzeit wählen'
|
||||
if (dateIncomplete) timePlaceholder = 'Datum eingeben'
|
||||
else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit'
|
||||
else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft'
|
||||
else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen'
|
||||
else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar'
|
||||
|
||||
const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
const [todayYear, todayMonthStr] = today.split('-')
|
||||
const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1
|
||||
const isAtLatestMonth = (() => {
|
||||
if (latestDate === null) return false
|
||||
const [latestYear, latestMonthStr] = latestDate.split('-')
|
||||
return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1
|
||||
})()
|
||||
|
||||
function goToMonth(offset: number) {
|
||||
setView((v) => {
|
||||
const total = v.year * 12 + v.month + offset
|
||||
return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="datetime-field">
|
||||
<div className="date-input-group" ref={containerRef}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
pattern="\d{2}\.\d{2}\.\d{4}"
|
||||
maxLength={10}
|
||||
value={dateText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="date-picker-trigger"
|
||||
aria-label="Kalender öffnen"
|
||||
aria-expanded={isCalendarOpen}
|
||||
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||
>
|
||||
<CalendarIcon />
|
||||
</button>
|
||||
|
||||
{isCalendarOpen && (
|
||||
<div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
|
||||
<div
|
||||
className="date-popover"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Datum auswählen"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="date-popover-header">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorheriger Monat"
|
||||
disabled={isAtEarliestMonth}
|
||||
onClick={() => goToMonth(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span>{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächster Monat"
|
||||
disabled={isAtLatestMonth}
|
||||
onClick={() => goToMonth(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-popover-weekdays">
|
||||
{WEEKDAY_HEADER_LABELS.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="date-popover-grid">
|
||||
{getMonthGrid(view.year, view.month)
|
||||
.flat()
|
||||
.map((cell, index) => (
|
||||
<button
|
||||
key={cell?.dateStr ?? `blank-${index}`}
|
||||
type="button"
|
||||
className={cell?.dateStr === date ? 'is-selected' : undefined}
|
||||
disabled={!cell || !isSelectableDate(cell.dateStr)}
|
||||
onClick={() => cell && handleDayPick(cell.dateStr)}
|
||||
>
|
||||
{cell?.day ?? ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={time}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
disabled={availableTimeOptions.length === 0}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
{timePlaceholder}
|
||||
</option>
|
||||
{timeOptions.map((option) => (
|
||||
<option key={option} value={option} disabled={bookedTimesSet.has(option)}>
|
||||
{option}
|
||||
{bookedTimesSet.has(option) ? ' (belegt)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionMenu } from '#/components/action-menu'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
|
||||
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
|
||||
import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
type ReservationStatus,
|
||||
type getReservations,
|
||||
type getTables,
|
||||
} from '#/lib/reservations'
|
||||
import { type getReservationSettings } from '#/lib/settings'
|
||||
|
||||
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
|
||||
|
||||
export type ReservationPayload = {
|
||||
tableId: number
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
customerPhone: string
|
||||
message: string
|
||||
partySize: number
|
||||
reservationTime: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
tableId: number | string
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
customerPhone: string
|
||||
message: string
|
||||
partySize: number
|
||||
reservationTime: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
// `null` reservation with `open: true` is "create" mode.
|
||||
open: boolean
|
||||
reservation: ReservationRow | null
|
||||
tables: TableRow[]
|
||||
settings: SettingsRow
|
||||
reservations: ReservationRow[]
|
||||
onClose: () => void
|
||||
onCreate: (payload: ReservationPayload) => Promise<void>
|
||||
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
|
||||
onStatusChange: (status: ReservationStatus) => void
|
||||
isUpdatingStatus?: boolean
|
||||
onDelete: () => void
|
||||
statusError?: string | null
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'full',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||
|
||||
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
|
||||
const slot = getNextAvailableSlot(
|
||||
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
return {
|
||||
tableId: defaultTableId,
|
||||
customerName: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
message: '',
|
||||
partySize: 2,
|
||||
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
function formFromReservation(reservation: ReservationRow): FormState {
|
||||
return {
|
||||
tableId: reservation.tableId,
|
||||
customerName: reservation.customerName,
|
||||
customerEmail: reservation.customerEmail,
|
||||
customerPhone: reservation.customerPhone ?? '',
|
||||
message: reservation.message ?? '',
|
||||
partySize: reservation.partySize,
|
||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
|
||||
// the dialog opens — that's what lets this component safely derive its initial
|
||||
// state from props once, instead of syncing it via effects.
|
||||
export function ReservationDialog({
|
||||
open,
|
||||
reservation,
|
||||
tables,
|
||||
settings,
|
||||
reservations,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onStatusChange,
|
||||
isUpdatingStatus,
|
||||
onDelete,
|
||||
statusError,
|
||||
}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(reservation === null)
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
// Other reservations already sitting on the currently selected table + date, so the
|
||||
// time picker can block them instead of letting staff pick a slot the server will
|
||||
// just reject as double-booked. A reservation occupies its whole configured
|
||||
// duration, not just its exact start time, so every interval-grid slot that falls
|
||||
// inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
|
||||
// table turnover buffer, extending the taken window on both sides so back-to-back
|
||||
// bookings on the same table always leave that much of a gap.
|
||||
const [selectedDate] = form.reservationTime.split('T')
|
||||
const otherReservationsOnSelection = reservations.filter(
|
||||
(r) =>
|
||||
r.id !== reservation?.id &&
|
||||
String(r.tableId) === String(form.tableId) &&
|
||||
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||
)
|
||||
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
|
||||
const bookedTimesForForm = selectedDate
|
||||
? generateTimeOptions(
|
||||
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
).filter((option) => {
|
||||
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||
return otherReservationsOnSelection.some((r) => {
|
||||
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
|
||||
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
|
||||
return slotStart >= otherStart && slotStart < otherEnd
|
||||
})
|
||||
})
|
||||
: []
|
||||
|
||||
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
|
||||
// so the submit button is disabled until they'd all actually pass — instead of only
|
||||
// catching an invalid table/time on submit and leaving everything else to the
|
||||
// browser's native validation popup.
|
||||
const isFormValid =
|
||||
Boolean(form.tableId) &&
|
||||
form.customerName.trim().length > 0 &&
|
||||
EMAIL_PATTERN.test(form.customerEmail) &&
|
||||
PHONE_PATTERN.test(form.customerPhone) &&
|
||||
form.partySize >= 1 &&
|
||||
form.partySize <= 20 &&
|
||||
Boolean(form.reservationTime.split('T')[1])
|
||||
|
||||
function startEditing() {
|
||||
if (reservation) setForm(formFromReservation(reservation))
|
||||
setError(null)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (!reservation) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setForm(formFromReservation(reservation))
|
||||
setError(null)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!form.tableId) {
|
||||
setError('Bitte wählen Sie einen Tisch aus')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.reservationTime.split('T')[1]) {
|
||||
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload: ReservationPayload = {
|
||||
tableId: Number(form.tableId),
|
||||
customerName: form.customerName,
|
||||
customerEmail: form.customerEmail,
|
||||
customerPhone: form.customerPhone,
|
||||
message: form.message,
|
||||
partySize: Number(form.partySize),
|
||||
reservationTime: new Date(form.reservationTime).toISOString(),
|
||||
}
|
||||
|
||||
if (reservation) {
|
||||
await onUpdate(reservation.id, payload)
|
||||
} else {
|
||||
await onCreate(payload)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = reservation
|
||||
? isEditing
|
||||
? `Reservierung von ${reservation.customerName} bearbeiten`
|
||||
: `Reservierung von ${reservation.customerName}`
|
||||
: 'Neue Reservierung'
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reservation-dialog-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
{reservation && !isEditing && (
|
||||
<div className="confirm-dialog-header-actions">
|
||||
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
|
||||
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
||||
Löschen
|
||||
</button>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Tisch
|
||||
<select
|
||||
value={form.tableId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Tisch auswählen
|
||||
</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={table.id}>
|
||||
{table.name} ({table.seats} Plätze)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={form.customerName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
value={form.customerEmail}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Telefon
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
pattern={PHONE_PATTERN_SOURCE}
|
||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||
value={form.customerPhone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Anzahl Personen
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.partySize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<ReservationDateTimeField
|
||||
value={form.reservationTime}
|
||||
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
|
||||
bookedTimes={bookedTimesForForm}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
durationMinutes={settings.reservationDurationMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Nachricht (optional)
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
reservation && (
|
||||
<>
|
||||
<div className="status-options">
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={`status-badge status-option status-${status}${
|
||||
reservation.status === status ? ' is-active' : ''
|
||||
}`}
|
||||
disabled={isUpdatingStatus}
|
||||
onClick={() => onStatusChange(status)}
|
||||
>
|
||||
{RESERVATION_STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="detail-list">
|
||||
{reservation.tableNumber !== null && (
|
||||
<div>
|
||||
<dt>Tischnummer</dt>
|
||||
<dd>{reservation.tableNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Tisch</dt>
|
||||
<dd>{reservation.tableName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datum</dt>
|
||||
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Start</dt>
|
||||
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ende</dt>
|
||||
<dd>
|
||||
{timeFormatter.format(
|
||||
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Personen</dt>
|
||||
<dd>{reservation.partySize}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
|
||||
</dd>
|
||||
</div>
|
||||
{reservation.customerPhone && (
|
||||
<div>
|
||||
<dt>Telefon</dt>
|
||||
<dd>
|
||||
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{reservation.message && (
|
||||
<div className="detail-message">
|
||||
<span className="detail-message-label">Nachricht</span>
|
||||
<p>{reservation.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusError && <p className="message message-error">{statusError}</p>}
|
||||
|
||||
<div className="confirm-dialog-actions confirm-dialog-actions-split">
|
||||
<button type="button" className="btn btn-ghost" onClick={startEditing}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,28 +3,14 @@ import { Link } from '@tanstack/react-router'
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="site-header">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<nav>
|
||||
<ul className="site-nav">
|
||||
<li>
|
||||
<Link to="/" hash="reservieren">
|
||||
Reservieren
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/" hash="oeffnungszeiten">
|
||||
Öffnungszeiten
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/" hash="kontakt">
|
||||
Kontakt
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="site-header-inner">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<Link className="staff-login-link" to="/staff/login">
|
||||
Mitarbeiter-Login
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { listStaffMembers } from '#/lib/staff'
|
||||
|
||||
type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
|
||||
|
||||
type Props = {
|
||||
member: StaffMember | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
export function StaffMemberDetailDialog({ member, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!member) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [member, onClose])
|
||||
|
||||
if (!member) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog reservation-detail-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="staff-member-detail-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="staff-member-detail-title" className="confirm-dialog-title">
|
||||
{member.name}
|
||||
</h2>
|
||||
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${member.email}`}>{member.email}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Telefon</dt>
|
||||
<dd>{member.phone ? <a href={`tel:${member.phone}`}>{member.phone}</a> : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rolle</dt>
|
||||
<dd>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{member.banned ? 'Gesperrt' : 'Aktiv'}</dd>
|
||||
</div>
|
||||
{member.banned && member.banReason && (
|
||||
<div>
|
||||
<dt>Sperrgrund</dt>
|
||||
<dd>{member.banReason}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Angelegt am</dt>
|
||||
<dd>{dateFormatter.format(new Date(member.createdAt))}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
type Props = {
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export function StaffTabs({ role }: Props) {
|
||||
return (
|
||||
<nav className="staff-tabs" aria-label="Bereich">
|
||||
<Link
|
||||
className="staff-tab"
|
||||
to="/staff"
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{ className: 'is-active' }}
|
||||
viewTransition
|
||||
>
|
||||
Reservierungen
|
||||
</Link>
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
className="staff-tab"
|
||||
to="/staff/tables"
|
||||
activeProps={{ className: 'is-active' }}
|
||||
viewTransition
|
||||
>
|
||||
Tische
|
||||
</Link>
|
||||
)}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
className="staff-tab"
|
||||
to="/staff/team"
|
||||
activeProps={{ className: 'is-active' }}
|
||||
viewTransition
|
||||
>
|
||||
Mitarbeiter
|
||||
</Link>
|
||||
)}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
className="staff-tab"
|
||||
to="/staff/settings"
|
||||
activeProps={{ className: 'is-active' }}
|
||||
viewTransition
|
||||
>
|
||||
Einstellungen
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
|
||||
|
||||
export function StatusBadge({ status }: { status: ReservationStatus }) {
|
||||
return <span className={`status-badge status-${status}`}>{RESERVATION_STATUS_LABELS[status]}</span>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { getTables } from '#/lib/reservations'
|
||||
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
|
||||
type Props = {
|
||||
table: TableRow | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TableDetailDialog({ table, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!table) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [table, onClose])
|
||||
|
||||
if (!table) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog reservation-detail-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="table-detail-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="table-detail-title" className="confirm-dialog-title">
|
||||
{table.name}
|
||||
</h2>
|
||||
|
||||
{table.imageUrl && <img className="table-preview" src={table.imageUrl} alt="" />}
|
||||
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>Tischnummer</dt>
|
||||
<dd>{table.tableNumber ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{table.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Plätze</dt>
|
||||
<dd>{table.seats}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Marker</dt>
|
||||
<dd>{table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user