✨ first version
CI / test (push) Successful in 5m16s
CI / deploy (push) Successful in 32s
CI / build (push) Successful in 4m2s

This commit is contained in:
2026-08-28 10:24:15 +02:00
parent 55a51a521d
commit ecbc42846b
72 changed files with 12788 additions and 483 deletions
+108
View File
@@ -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,
)}
</>
)
}
+128
View File
@@ -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>
)
}
+99
View File
@@ -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>
)
}
+321
View File
@@ -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>
)
}
+456
View File
@@ -0,0 +1,456 @@
import { useEffect, useState } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
type ReservationStatus,
type getReservations,
type getTables,
} from '#/lib/reservations'
import { type getReservationSettings } from '#/lib/settings'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
export type ReservationPayload = {
tableId: number
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type Props = {
// `null` reservation with `open: true` is "create" mode.
open: boolean
reservation: ReservationRow | null
tables: TableRow[]
settings: SettingsRow
reservations: ReservationRow[]
onClose: () => void
onCreate: (payload: ReservationPayload) => Promise<void>
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onDelete: () => void
statusError?: string | null
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
)
return {
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function formFromReservation(reservation: ReservationRow): FormState {
return {
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
}
}
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
// the dialog opens — that's what lets this component safely derive its initial
// state from props once, instead of syncing it via effects.
export function ReservationDialog({
open,
reservation,
tables,
settings,
reservations,
onClose,
onCreate,
onUpdate,
onStatusChange,
isUpdatingStatus,
onDelete,
statusError,
}: Props) {
const [isEditing, setIsEditing] = useState(reservation === null)
const [form, setForm] = useState<FormState>(() =>
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
if (!open) return null
// Other reservations already sitting on the currently selected table + date, so the
// time picker can block them instead of letting staff pick a slot the server will
// just reject as double-booked. A reservation occupies its whole configured
// duration, not just its exact start time, so every interval-grid slot that falls
// inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
// table turnover buffer, extending the taken window on both sides so back-to-back
// bookings on the same table always leave that much of a gap.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== reservation?.id &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
// so the submit button is disabled until they'd all actually pass — instead of only
// catching an invalid table/time on submit and leaving everything else to the
// browser's native validation popup.
const isFormValid =
Boolean(form.tableId) &&
form.customerName.trim().length > 0 &&
EMAIL_PATTERN.test(form.customerEmail) &&
PHONE_PATTERN.test(form.customerPhone) &&
form.partySize >= 1 &&
form.partySize <= 20 &&
Boolean(form.reservationTime.split('T')[1])
function startEditing() {
if (reservation) setForm(formFromReservation(reservation))
setError(null)
setIsEditing(true)
}
function cancelEditing() {
if (!reservation) {
onClose()
return
}
setForm(formFromReservation(reservation))
setError(null)
setIsEditing(false)
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (!form.tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload: ReservationPayload = {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
customerPhone: form.customerPhone,
message: form.message,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
if (reservation) {
await onUpdate(reservation.id, payload)
} else {
await onCreate(payload)
}
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
const title = reservation
? isEditing
? `Reservierung von ${reservation.customerName} bearbeiten`
: `Reservierung von ${reservation.customerName}`
: 'Neue Reservierung'
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="reservation-dialog-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
{title}
</h2>
{reservation && !isEditing && (
<div className="confirm-dialog-header-actions">
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-danger" onClick={onDelete}>
Löschen
</button>
</ActionMenu>
</div>
)}
</div>
{isEditing ? (
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
<label>
Tisch
<select
value={form.tableId}
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
required
>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.name} ({table.seats} Plätze)
</option>
))}
</select>
</label>
<div className="form-row">
<label>
Name
<input
type="text"
value={form.customerName}
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={form.customerEmail}
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
required
/>
</label>
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.customerPhone}
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
required
/>
</label>
<label>
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={2}
maxLength={500}
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
/>
</label>
{error && <p className="message message-error">{error}</p>}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
</button>
</div>
</form>
) : (
reservation && (
<>
<div className="status-options">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className={`status-badge status-option status-${status}${
reservation.status === status ? ' is-active' : ''
}`}
disabled={isUpdatingStatus}
onClick={() => onStatusChange(status)}
>
{RESERVATION_STATUS_LABELS[status]}
</button>
))}
</div>
<dl className="detail-list">
{reservation.tableNumber !== null && (
<div>
<dt>Tischnummer</dt>
<dd>{reservation.tableNumber}</dd>
</div>
)}
<div>
<dt>Tisch</dt>
<dd>{reservation.tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Start</dt>
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Ende</dt>
<dd>
{timeFormatter.format(
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
)}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{reservation.partySize}</dd>
</div>
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
</dd>
</div>
{reservation.customerPhone && (
<div>
<dt>Telefon</dt>
<dd>
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
</dd>
</div>
)}
</dl>
{reservation.message && (
<div className="detail-message">
<span className="detail-message-label">Nachricht</span>
<p>{reservation.message}</p>
</div>
)}
{statusError && <p className="message message-error">{statusError}</p>}
<div className="confirm-dialog-actions confirm-dialog-actions-split">
<button type="button" className="btn btn-ghost" onClick={startEditing}>
Bearbeiten
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</>
)
)}
</div>
</div>
)
}
+8 -22
View File
@@ -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>
)
}
+51
View File
@@ -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>
)
}
+5
View File
@@ -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>
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
onClose: () => void
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="m8.2 12.3 2.6 2.6 5-5.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function SuccessModal({ open, title, message, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog success-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="success-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="success-modal-icon">
<CheckIcon />
</div>
<h2 id="success-modal-title" className="confirm-dialog-title">
{title}
</h2>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
OK
</button>
</div>
</div>
</div>
)
}
+68
View File
@@ -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>
)
}