✨ first version
This commit is contained in:
@@ -26,6 +26,11 @@ export const Route = createRootRoute({
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
href: '/favicon.svg',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
|
||||
@@ -57,7 +57,7 @@ function Impressum() {
|
||||
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
|
||||
</p>
|
||||
|
||||
<h2>Datenschutz</h2>
|
||||
<h2 id="datenschutz">Datenschutz</h2>
|
||||
<p>
|
||||
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
|
||||
oben genannte Drenica & Mousa GbR.
|
||||
|
||||
+223
-37
@@ -1,28 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { SiteFooter } from '#/components/site-footer'
|
||||
import { SiteHeader } from '#/components/site-header'
|
||||
import { formatDateTimeLocal } from '#/lib/datetime'
|
||||
import { createReservation, getTables } from '#/lib/reservations'
|
||||
import { formatDateOnly } from '#/lib/datetime'
|
||||
import {
|
||||
generateTimeOptions,
|
||||
getNextAvailableSlot,
|
||||
getOpeningHoursForDisplay,
|
||||
getOpeningRangesForWeekday,
|
||||
getTodayLabelBerlin,
|
||||
} from '#/lib/opening-hours'
|
||||
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
|
||||
import { getReservationSettings } from '#/lib/settings'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
loader: async () => {
|
||||
const tables = await getTables()
|
||||
return { tables }
|
||||
const [tables, settings, availability] = await Promise.all([
|
||||
getTables(),
|
||||
getReservationSettings(),
|
||||
getReservationAvailability(),
|
||||
])
|
||||
return { tables, settings, availability }
|
||||
},
|
||||
})
|
||||
|
||||
const OPENING_HOURS = [
|
||||
{ day: 'Montag', hours: null },
|
||||
{ day: 'Dienstag', hours: ['17:00 – 21:30'] },
|
||||
{ day: 'Mittwoch', hours: ['17:00 – 21:30'] },
|
||||
{ day: 'Donnerstag', hours: ['17:00 – 21:30'] },
|
||||
{ day: 'Freitag', hours: ['17:00 – 22:00'] },
|
||||
{ day: 'Samstag', hours: ['17:00 – 22:00'] },
|
||||
{ day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
|
||||
]
|
||||
const OPENING_HOURS = getOpeningHoursForDisplay()
|
||||
|
||||
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
|
||||
const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||
|
||||
type ConfirmedReservation = {
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
tableName: string
|
||||
date: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
partySize: number
|
||||
}
|
||||
|
||||
function FlameIcon() {
|
||||
return (
|
||||
@@ -39,47 +63,117 @@ function FlameIcon() {
|
||||
|
||||
function Home() {
|
||||
const router = useRouter()
|
||||
const { tables } = Route.useLoaderData()
|
||||
const { tables, settings, availability } = Route.useLoaderData()
|
||||
|
||||
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [customerEmail, setCustomerEmail] = useState('')
|
||||
const [customerPhone, setCustomerPhone] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [partySize, setPartySize] = useState(2)
|
||||
const [reservationTime, setReservationTime] = useState(() =>
|
||||
formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
|
||||
)
|
||||
const [acceptsTerms, setAcceptsTerms] = useState(false)
|
||||
const [reservationTime, setReservationTime] = useState(() => {
|
||||
const slot = getNextAvailableSlot(
|
||||
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
return slot ? `${slot.date}T${slot.time}` : ''
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
|
||||
const todayName = getTodayLabelBerlin()
|
||||
const selectedTable = tables.find((table) => String(table.id) === String(tableId))
|
||||
|
||||
const [selectedDate] = reservationTime.split('T')
|
||||
const otherReservationsOnSelection = availability.filter(
|
||||
(r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||
)
|
||||
// `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
|
||||
// existing reservation's taken window on both sides so back-to-back bookings on the
|
||||
// same table always leave that much of a gap.
|
||||
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
|
||||
const bookedTimesForForm = selectedDate
|
||||
? generateTimeOptions(
|
||||
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
).filter((option) => {
|
||||
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||
return otherReservationsOnSelection.some((r) => {
|
||||
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
|
||||
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
|
||||
return slotStart >= otherStart && slotStart < otherEnd
|
||||
})
|
||||
})
|
||||
: []
|
||||
|
||||
// Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
|
||||
// the submit button is disabled until they'd all actually pass — instead of only
|
||||
// catching a missing table/time/consent on submit and leaving everything else to
|
||||
// the browser's native validation popup.
|
||||
const isFormValid =
|
||||
Boolean(tableId) &&
|
||||
customerName.trim().length > 0 &&
|
||||
EMAIL_PATTERN.test(customerEmail) &&
|
||||
PHONE_PATTERN.test(customerPhone) &&
|
||||
partySize >= 1 &&
|
||||
partySize <= 20 &&
|
||||
Boolean(reservationTime.split('T')[1]) &&
|
||||
acceptsTerms
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (!tableId) {
|
||||
setError('Bitte wählen Sie einen Tisch aus')
|
||||
return
|
||||
}
|
||||
|
||||
if (!reservationTime.split('T')[1]) {
|
||||
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
||||
return
|
||||
}
|
||||
|
||||
if (!acceptsTerms) {
|
||||
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createReservation({
|
||||
const created = await createReservation({
|
||||
data: {
|
||||
tableId: Number(tableId),
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
message,
|
||||
partySize: Number(partySize),
|
||||
reservationTime: new Date(reservationTime).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
|
||||
const start = new Date(created.reservationTime)
|
||||
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
|
||||
setConfirmedReservation({
|
||||
customerName: created.customerName,
|
||||
customerEmail: created.customerEmail,
|
||||
tableName: selectedTable?.name ?? '',
|
||||
date: confirmedDateFormatter.format(start),
|
||||
startTime: confirmedTimeFormatter.format(start),
|
||||
endTime: confirmedTimeFormatter.format(end),
|
||||
partySize: created.partySize,
|
||||
})
|
||||
setCustomerName('')
|
||||
setCustomerEmail('')
|
||||
setCustomerPhone('')
|
||||
setMessage('')
|
||||
setPartySize(2)
|
||||
setAcceptsTerms(false)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
@@ -95,11 +189,11 @@ function Home() {
|
||||
<section className="hero" id="top">
|
||||
<FlameIcon />
|
||||
<h1>
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
||||
</h1>
|
||||
<p>
|
||||
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
|
||||
Sie Ihren Tisch – wir freuen uns auf Sie.
|
||||
Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
|
||||
guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a className="btn btn-primary" href="#reservieren">
|
||||
@@ -155,6 +249,36 @@ function Home() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{settings.tableDisplayMode === 'overview' ? (
|
||||
settings.tableOverviewImageUrl && (
|
||||
<div className="table-overview-wrap">
|
||||
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
|
||||
{tables
|
||||
.filter((table) => table.markerX !== null && table.markerY !== null)
|
||||
.map((table) => (
|
||||
<button
|
||||
key={table.id}
|
||||
type="button"
|
||||
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
|
||||
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
|
||||
onClick={() => setTableId(table.id)}
|
||||
aria-label={`${table.name} auswählen`}
|
||||
>
|
||||
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
selectedTable?.imageUrl && (
|
||||
<img
|
||||
className="table-preview"
|
||||
src={selectedTable.imageUrl}
|
||||
alt={`Ansicht von ${selectedTable.name}`}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Name
|
||||
@@ -178,10 +302,24 @@ function Home() {
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Telefon
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
pattern={PHONE_PATTERN_SOURCE}
|
||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Anzahl Personen
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20}
|
||||
value={partySize}
|
||||
@@ -189,24 +327,58 @@ function Home() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={reservationTime}
|
||||
onChange={(e) => setReservationTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<ReservationDateTimeField
|
||||
value={reservationTime}
|
||||
onChange={setReservationTime}
|
||||
bookedTimes={bookedTimesForForm}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
durationMinutes={settings.reservationDurationMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Nachricht (optional)
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="Allergien, ein besonderer Anlass, Sitzwünsche …"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="field-hint">
|
||||
Verfügbare Uhrzeiten richten sich nach unseren{' '}
|
||||
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
||||
</p>
|
||||
|
||||
<label className="radio-option consent-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptsTerms}
|
||||
onChange={(e) => setAcceptsTerms(e.target.checked)}
|
||||
required
|
||||
/>
|
||||
<span>
|
||||
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
|
||||
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
|
||||
Datenschutzerklärung
|
||||
</a>{' '}
|
||||
zu.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={isSubmitting || !isFormValid}>
|
||||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
||||
</button>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -240,6 +412,20 @@ function Home() {
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
{confirmedReservation && (
|
||||
<ReservationConfirmedModal
|
||||
open
|
||||
customerName={confirmedReservation.customerName}
|
||||
customerEmail={confirmedReservation.customerEmail}
|
||||
tableName={confirmedReservation.tableName}
|
||||
date={confirmedReservation.date}
|
||||
startTime={confirmedReservation.startTime}
|
||||
endTime={confirmedReservation.endTime}
|
||||
partySize={confirmedReservation.partySize}
|
||||
onClose={() => setConfirmedReservation(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+258
-210
@@ -1,16 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { ReservationCharts } from '#/components/reservation-charts'
|
||||
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { StatusBadge } from '#/components/status-badge'
|
||||
import { SuccessModal } from '#/components/success-modal'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { formatDateTimeLocal } from '#/lib/datetime'
|
||||
import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
createReservation,
|
||||
deleteReservation,
|
||||
getReservations,
|
||||
getTables,
|
||||
updateReservation,
|
||||
updateReservationStatus,
|
||||
type ReservationStatus,
|
||||
} from '#/lib/reservations'
|
||||
import { getReservationSettings } from '#/lib/settings'
|
||||
|
||||
export const Route = createFileRoute('/staff/')({
|
||||
component: StaffDashboard,
|
||||
@@ -22,13 +31,20 @@ export const Route = createFileRoute('/staff/')({
|
||||
return { staffUser }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const [reservations, tables] = await Promise.all([getReservations(), getTables()])
|
||||
return { staffUser: context.staffUser, reservations, tables }
|
||||
const [reservations, tables, settings] = await Promise.all([
|
||||
getReservations(),
|
||||
getTables(),
|
||||
getReservationSettings(),
|
||||
])
|
||||
return { staffUser: context.staffUser, reservations, tables, settings }
|
||||
},
|
||||
})
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
@@ -40,117 +56,100 @@ function isSameDay(a: Date, b: Date) {
|
||||
|
||||
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
||||
|
||||
type FormState = {
|
||||
tableId: number | string
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
partySize: number
|
||||
reservationTime: string
|
||||
}
|
||||
|
||||
function emptyForm(defaultTableId: number | string): FormState {
|
||||
return {
|
||||
tableId: defaultTableId,
|
||||
customerName: '',
|
||||
customerEmail: '',
|
||||
partySize: 2,
|
||||
reservationTime: formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
|
||||
}
|
||||
}
|
||||
|
||||
function StaffDashboard() {
|
||||
const router = useRouter()
|
||||
const { staffUser, reservations, tables } = Route.useLoaderData()
|
||||
const { staffUser, reservations, tables, settings } = Route.useLoaderData()
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? ''))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
|
||||
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [tableFilter, setTableFilter] = useState('all')
|
||||
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
|
||||
|
||||
const dialogOpen = viewingReservation !== null || isCreating
|
||||
|
||||
const now = new Date()
|
||||
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
|
||||
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
|
||||
const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime())
|
||||
|
||||
function startEdit(reservation: ReservationRow) {
|
||||
setEditingId(reservation.id)
|
||||
setForm({
|
||||
tableId: reservation.tableId,
|
||||
customerName: reservation.customerName,
|
||||
customerEmail: reservation.customerEmail,
|
||||
partySize: reservation.partySize,
|
||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
||||
const filteredReservations = reservations.filter((r) => {
|
||||
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||
|
||||
const reservationTime = new Date(r.reservationTime)
|
||||
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
|
||||
if (timeFilter === 'upcoming' && reservationTime.getTime() < now.getTime()) return false
|
||||
if (timeFilter === 'past' && reservationTime.getTime() >= now.getTime()) return false
|
||||
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (query) {
|
||||
const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
|
||||
if (!haystack.includes(query)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
function closeDialog() {
|
||||
setViewingReservation(null)
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
async function handleCreate(payload: ReservationPayload) {
|
||||
await createReservation({ data: payload })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung angelegt',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
|
||||
})
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm(tables[0]?.id ?? ''))
|
||||
setError(null)
|
||||
async function handleUpdate(id: number, payload: ReservationPayload) {
|
||||
await updateReservation({ data: { id, ...payload } })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung aktualisiert',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
|
||||
})
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
async function confirmDelete() {
|
||||
const reservation = pendingDelete
|
||||
if (!reservation) return
|
||||
|
||||
setPendingDelete(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (!form.tableId) {
|
||||
setError('Bitte wählen Sie einen Tisch aus')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
tableId: Number(form.tableId),
|
||||
customerName: form.customerName,
|
||||
customerEmail: form.customerEmail,
|
||||
partySize: Number(form.partySize),
|
||||
reservationTime: new Date(form.reservationTime).toISOString(),
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await updateReservation({ data: { id: editingId, ...payload } })
|
||||
setSuccess('Reservierung wurde aktualisiert.')
|
||||
} else {
|
||||
await createReservation({ data: payload })
|
||||
setSuccess('Reservierung wurde angelegt.')
|
||||
}
|
||||
|
||||
setEditingId(null)
|
||||
setForm(emptyForm(tables[0]?.id ?? ''))
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(reservation: ReservationRow) {
|
||||
if (!window.confirm(`Reservierung von ${reservation.customerName} wirklich löschen?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingId(reservation.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await deleteReservation({ data: { id: reservation.id } })
|
||||
if (editingId === reservation.id) {
|
||||
cancelEdit()
|
||||
}
|
||||
setSuccess('Reservierung wurde gelöscht.')
|
||||
setSuccessModal({
|
||||
title: 'Reservierung gelöscht',
|
||||
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
|
||||
})
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
|
||||
setIsUpdatingStatus(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateReservationStatus({ data: { id: reservation.id, status } })
|
||||
setViewingReservation((current) => (current ? { ...current, status } : current))
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
setIsUpdatingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,26 +161,33 @@ function StaffDashboard() {
|
||||
|
||||
return (
|
||||
<div className="staff-page">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
{staffUser.role === 'admin' && (
|
||||
<Link className="btn btn-secondary" to="/staff/team">
|
||||
Mitarbeiter
|
||||
</Link>
|
||||
)}
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
<div className="staff-nav">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="staff-tabs-bar">
|
||||
<StaffTabs role={staffUser.role} />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main className="page staff-main">
|
||||
<h1 className="section-heading">Reservierungen</h1>
|
||||
|
||||
{!dialogOpen && error && (
|
||||
<div className="staff-page-messages">
|
||||
<p className="message message-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="staff-stats">
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{reservations.length}</span>
|
||||
@@ -201,130 +207,125 @@ function StaffDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">
|
||||
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
|
||||
</h2>
|
||||
<ReservationCharts reservations={reservations} tables={tables} />
|
||||
|
||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Tisch
|
||||
<select
|
||||
value={form.tableId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Tisch auswählen
|
||||
</option>
|
||||
<section className="staff-table-section">
|
||||
<div className="table-toolbar">
|
||||
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
|
||||
+ Neue Reservierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
<div className="table-filters">
|
||||
<input
|
||||
type="search"
|
||||
className="table-search"
|
||||
placeholder="Suche nach Name, E-Mail oder Tisch …"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label="Reservierungen durchsuchen"
|
||||
/>
|
||||
<select value={tableFilter} onChange={(e) => setTableFilter(e.target.value)} aria-label="Nach Tisch filtern">
|
||||
<option value="all">Alle Tische</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={table.id}>
|
||||
{table.name} ({table.seats} Plätze)
|
||||
<option key={table.id} value={String(table.id)}>
|
||||
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={timeFilter}
|
||||
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
|
||||
aria-label="Nach Zeitraum filtern"
|
||||
>
|
||||
<option value="all">Alle Zeiträume</option>
|
||||
<option value="today">Heute</option>
|
||||
<option value="upcoming">Anstehend</option>
|
||||
<option value="past">Vergangen</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
aria-label="Nach Status filtern"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(RESERVATION_STATUS_LABELS).map(([status, label]) => (
|
||||
<option key={status} value={status}>
|
||||
{label}
|
||||
</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>
|
||||
Anzahl Personen
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.partySize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.reservationTime}
|
||||
onChange={(e) => setForm((f) => ({ ...f, reservationTime: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="staff-table-section">
|
||||
{reservations.length === 0 ? (
|
||||
<p className="empty-state">Noch keine Reservierungen vorhanden.</p>
|
||||
{filteredReservations.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
{reservations.length === 0
|
||||
? 'Noch keine Reservierungen vorhanden.'
|
||||
: 'Keine Reservierungen für diese Filter gefunden.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum & Uhrzeit</th>
|
||||
{timeFilter !== 'today' && <th>Datum</th>}
|
||||
<th>Start</th>
|
||||
<th>Ende</th>
|
||||
<th>Nr.</th>
|
||||
<th>Tisch</th>
|
||||
<th>Kunde</th>
|
||||
<th>Personen</th>
|
||||
<th></th>
|
||||
<th>Status</th>
|
||||
<th>Nachricht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reservations.map((reservation) => (
|
||||
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
|
||||
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
|
||||
<td>{reservation.tableName}</td>
|
||||
<td>
|
||||
{filteredReservations.map((reservation) => (
|
||||
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
|
||||
{timeFilter !== 'today' && (
|
||||
<td data-label="Datum" className="cell-datetime">
|
||||
{dateFormatter.format(new Date(reservation.reservationTime))}
|
||||
</td>
|
||||
)}
|
||||
<td data-label="Start" className="cell-datetime">
|
||||
{timeFormatter.format(new Date(reservation.reservationTime))}
|
||||
</td>
|
||||
<td data-label="Ende" className="cell-datetime">
|
||||
{timeFormatter.format(
|
||||
new Date(
|
||||
new Date(reservation.reservationTime).getTime() +
|
||||
reservation.durationMinutes * 60_000,
|
||||
),
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Nr." className="cell-table-nr">
|
||||
{reservation.tableNumber ?? '—'}
|
||||
</td>
|
||||
<td data-label="Tisch" className="cell-table-name">
|
||||
{reservation.tableName}
|
||||
</td>
|
||||
<td data-label="Kunde" className="cell-customer">
|
||||
{reservation.customerName}
|
||||
<br />
|
||||
<span className="muted">{reservation.customerEmail}</span>
|
||||
{reservation.customerPhone && (
|
||||
<>
|
||||
<br />
|
||||
<span className="muted">{reservation.customerPhone}</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>{reservation.partySize}</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={deletingId === reservation.id}
|
||||
onClick={() => handleDelete(reservation)}
|
||||
>
|
||||
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
|
||||
</button>
|
||||
<td data-label="Personen" className="cell-party">
|
||||
{reservation.partySize}
|
||||
</td>
|
||||
<td data-label="Status" className="cell-status">
|
||||
<StatusBadge status={reservation.status as ReservationStatus} />
|
||||
</td>
|
||||
<td data-label="Nachricht" className="message-cell">
|
||||
{reservation.message || <span className="muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -334,6 +335,53 @@ function StaffDashboard() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="Reservierung löschen"
|
||||
message={
|
||||
pendingDelete && (
|
||||
<>
|
||||
Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
|
||||
erhält eine E-Mail zur Bestätigung der Stornierung.
|
||||
</>
|
||||
)
|
||||
}
|
||||
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Löschen"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
{dialogOpen && (
|
||||
<ReservationDialog
|
||||
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
|
||||
open={dialogOpen}
|
||||
reservation={viewingReservation}
|
||||
tables={tables}
|
||||
settings={settings}
|
||||
reservations={reservations}
|
||||
onClose={closeDialog}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
|
||||
isUpdatingStatus={isUpdatingStatus}
|
||||
statusError={error}
|
||||
onDelete={() => {
|
||||
if (!viewingReservation) return
|
||||
setPendingDelete(viewingReservation)
|
||||
setViewingReservation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SuccessModal
|
||||
open={successModal !== null}
|
||||
title={successModal?.title ?? ''}
|
||||
message={successModal?.message ?? ''}
|
||||
onClose={() => setSuccessModal(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
|
||||
function ArrowLeftIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M19 12H5M11 6l-6 6 6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/staff/login')({
|
||||
component: StaffLogin,
|
||||
beforeLoad: async () => {
|
||||
@@ -19,6 +62,7 @@ function StaffLogin() {
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
@@ -41,6 +85,11 @@ function StaffLogin() {
|
||||
return (
|
||||
<div className="staff-page staff-login-page">
|
||||
<form className="reservation-form staff-login-form" onSubmit={handleSubmit}>
|
||||
<Link className="back-link" to="/">
|
||||
<ArrowLeftIcon />
|
||||
Zurück zur Startseite
|
||||
</Link>
|
||||
|
||||
<div className="staff-login-heading">
|
||||
<span className="section-kicker">Mitarbeiterbereich</span>
|
||||
<h1 className="section-heading">Anmelden</h1>
|
||||
@@ -59,12 +108,23 @@ function StaffLogin() {
|
||||
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="password-field">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { getTables, setTableMarker } from '#/lib/reservations'
|
||||
import { getReservationSettings, updateReservationSettings } from '#/lib/settings'
|
||||
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
|
||||
|
||||
export const Route = createFileRoute('/staff/settings')({
|
||||
component: StaffSettings,
|
||||
beforeLoad: async () => {
|
||||
const staffUser = await getCurrentStaffUser()
|
||||
if (!staffUser) {
|
||||
throw redirect({ to: '/staff/login' })
|
||||
}
|
||||
if (staffUser.role !== 'admin') {
|
||||
throw redirect({ to: '/staff' })
|
||||
}
|
||||
return { staffUser }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
|
||||
return { staffUser: context.staffUser, settings, tables }
|
||||
},
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
reservationIntervalMinutes: number
|
||||
reservationDurationMinutes: number
|
||||
minLeadTimeMinutes: number
|
||||
maxLeadTimeDays: number
|
||||
tableDisplayMode: 'individual' | 'overview'
|
||||
tableOverviewImageUrl: string | null
|
||||
}
|
||||
|
||||
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
|
||||
|
||||
function StaffSettings() {
|
||||
const router = useRouter()
|
||||
const { staffUser, settings, tables } = Route.useLoaderData()
|
||||
|
||||
const [form, setForm] = useState<FormState>({
|
||||
reservationIntervalMinutes: settings.reservationIntervalMinutes,
|
||||
reservationDurationMinutes: settings.reservationDurationMinutes,
|
||||
minLeadTimeMinutes: settings.minLeadTimeMinutes,
|
||||
maxLeadTimeDays: settings.maxLeadTimeDays,
|
||||
tableDisplayMode: settings.tableDisplayMode === 'overview' ? 'overview' : 'individual',
|
||||
tableOverviewImageUrl: settings.tableOverviewImageUrl,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const overviewFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [markerTableId, setMarkerTableId] = useState<string>('')
|
||||
const [isPlacingMarker, setIsPlacingMarker] = useState(false)
|
||||
const [markerError, setMarkerError] = useState<string | null>(null)
|
||||
const [markerSuccess, setMarkerSuccess] = useState<string | null>(null)
|
||||
|
||||
const markerTable = tables.find((table) => String(table.id) === markerTableId)
|
||||
const tablesWithoutMarker = tables.filter((table) => table.markerX === null || table.markerY === null)
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut()
|
||||
await router.navigate({ to: '/staff/login' })
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function handleOverviewImageChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
setError('Bild muss PNG, JPEG oder WebP sein')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_TABLE_IMAGE_BYTES) {
|
||||
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
const dataUrl = await readImageAsDataUrl(file)
|
||||
setForm((f) => ({ ...f, tableOverviewImageUrl: dataUrl }))
|
||||
}
|
||||
|
||||
async function handlePlaceMarker(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!markerTable) {
|
||||
setMarkerError('Bitte zuerst einen Tisch für den Marker auswählen')
|
||||
return
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100))
|
||||
const y = Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100))
|
||||
|
||||
setMarkerError(null)
|
||||
setMarkerSuccess(null)
|
||||
setIsPlacingMarker(true)
|
||||
try {
|
||||
await setTableMarker({ data: { id: markerTable.id, marker: { x, y } } })
|
||||
setMarkerSuccess(`Marker für ${markerTable.name} wurde gesetzt.`)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsPlacingMarker(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearMarker() {
|
||||
if (!markerTable) return
|
||||
|
||||
setMarkerError(null)
|
||||
setMarkerSuccess(null)
|
||||
setIsPlacingMarker(true)
|
||||
try {
|
||||
await setTableMarker({ data: { id: markerTable.id, marker: null } })
|
||||
setMarkerSuccess(`Marker für ${markerTable.name} wurde entfernt.`)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsPlacingMarker(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await updateReservationSettings({ data: form })
|
||||
setSuccess('Einstellungen wurden gespeichert.')
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="staff-page">
|
||||
<div className="staff-nav">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="staff-tabs-bar">
|
||||
<StaffTabs role={staffUser.role} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="page staff-main">
|
||||
<h1 className="section-heading">Einstellungen</h1>
|
||||
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">Reservierungsregeln</h2>
|
||||
|
||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Reservierungsintervall (Minuten)
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={5}
|
||||
max={120}
|
||||
step={5}
|
||||
value={form.reservationIntervalMinutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, reservationIntervalMinutes: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">Abstand zwischen den wählbaren Uhrzeiten im Buchungsformular.</p>
|
||||
|
||||
<label>
|
||||
Reservierungsdauer (Minuten)
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={15}
|
||||
max={480}
|
||||
step={15}
|
||||
value={form.reservationDurationMinutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, reservationDurationMinutes: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Wie lange ein Tisch nach einer Reservierung als belegt gilt — verhindert überlappende Buchungen.
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Mindestvorlaufzeit (Minuten)
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={10080}
|
||||
step={15}
|
||||
value={form.minLeadTimeMinutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, minLeadTimeMinutes: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Wie kurzfristig frühestens reserviert werden kann, z. B. 60 für „mindestens eine Stunde im Voraus“.
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Buchbar bis zu (Tage im Voraus)
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={365}
|
||||
step={1}
|
||||
value={form.maxLeadTimeDays}
|
||||
onChange={(e) => setForm((f) => ({ ...f, maxLeadTimeDays: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“.
|
||||
</p>
|
||||
|
||||
<fieldset className="table-display-fieldset">
|
||||
<legend>Tischfotos im Buchungsformular</legend>
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="tableDisplayMode"
|
||||
checked={form.tableDisplayMode === 'individual'}
|
||||
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'individual' }))}
|
||||
/>
|
||||
Foto des gewählten Tisches
|
||||
</label>
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="tableDisplayMode"
|
||||
checked={form.tableDisplayMode === 'overview'}
|
||||
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'overview' }))}
|
||||
/>
|
||||
Ein Übersichtsfoto für alle Tische
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="field-hint">
|
||||
„Foto des gewählten Tisches“ zeigt das jeweilige Foto aus der Tischverwaltung, sobald ein
|
||||
Tisch ausgewählt wird. „Übersichtsfoto“ zeigt stattdessen immer dasselbe Bild, z. B. eine
|
||||
Aufnahme des ganzen Raums.
|
||||
</p>
|
||||
|
||||
{form.tableDisplayMode === 'overview' && (
|
||||
<>
|
||||
<label>
|
||||
Übersichtsfoto
|
||||
<div className="table-image-field">
|
||||
{form.tableOverviewImageUrl && (
|
||||
<img className="table-image-preview" src={form.tableOverviewImageUrl} alt="" />
|
||||
)}
|
||||
<input
|
||||
ref={overviewFileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleOverviewImageChange}
|
||||
/>
|
||||
{form.tableOverviewImageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setForm((f) => ({ ...f, tableOverviewImageUrl: null }))}
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<p className="field-hint">PNG, JPEG oder WebP, max. {MAX_IMAGE_MB} MB.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : 'Speichern'}
|
||||
</button>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{settings.tableDisplayMode === 'overview' && settings.tableOverviewImageUrl && (
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">Tisch-Marker</h2>
|
||||
<p className="field-hint">
|
||||
Wählen Sie einen Tisch aus und klicken Sie dann auf das gespeicherte Übersichtsfoto, um
|
||||
seinen Marker zu setzen oder zu verschieben.
|
||||
</p>
|
||||
|
||||
<div className="reservation-form">
|
||||
<label>
|
||||
Marker setzen für
|
||||
<select value={markerTableId} onChange={(e) => setMarkerTableId(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
Tisch auswählen
|
||||
</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={table.id}>
|
||||
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
|
||||
{table.markerX === null || table.markerY === null ? '' : ' ✓'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={`table-overview-wrap${isPlacingMarker ? ' is-busy' : ''}`}
|
||||
onClick={handlePlaceMarker}
|
||||
>
|
||||
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="" />
|
||||
{tables
|
||||
.filter((table) => table.markerX !== null && table.markerY !== null)
|
||||
.map((table) => (
|
||||
<span
|
||||
key={table.id}
|
||||
className={`table-marker${String(table.id) === markerTableId ? ' is-active' : ''}`}
|
||||
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
|
||||
>
|
||||
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{markerTable && (markerTable.markerX !== null || markerTable.markerY !== null) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={isPlacingMarker}
|
||||
onClick={handleClearMarker}
|
||||
>
|
||||
Marker für {markerTable.name} entfernen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{tablesWithoutMarker.length > 0 && (
|
||||
<p className="field-hint">
|
||||
Noch ohne Marker: {tablesWithoutMarker.map((table) => table.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{markerError && <p className="message message-error">{markerError}</p>}
|
||||
{markerSuccess && <p className="message message-success">{markerSuccess}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { TableDetailDialog } from '#/components/table-detail-dialog'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { createTable, deleteTable, getTables, updateTable } from '#/lib/reservations'
|
||||
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
|
||||
|
||||
export const Route = createFileRoute('/staff/tables')({
|
||||
component: StaffTables,
|
||||
beforeLoad: async () => {
|
||||
const staffUser = await getCurrentStaffUser()
|
||||
if (!staffUser) {
|
||||
throw redirect({ to: '/staff/login' })
|
||||
}
|
||||
if (staffUser.role !== 'admin') {
|
||||
throw redirect({ to: '/staff' })
|
||||
}
|
||||
return { staffUser }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const tables = await getTables()
|
||||
return { staffUser: context.staffUser, tables }
|
||||
},
|
||||
})
|
||||
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
tableNumber: number
|
||||
seats: number
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', tableNumber: 1, seats: 2, imageUrl: null }
|
||||
}
|
||||
|
||||
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
|
||||
|
||||
function StaffTables() {
|
||||
const router = useRouter()
|
||||
const { staffUser, tables } = Route.useLoaderData()
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<TableRow | null>(null)
|
||||
const [viewingTable, setViewingTable] = useState<TableRow | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function startEdit(table: TableRow) {
|
||||
setEditingId(table.id)
|
||||
setForm({
|
||||
name: table.name,
|
||||
tableNumber: table.tableNumber ?? 0,
|
||||
seats: table.seats,
|
||||
imageUrl: table.imageUrl,
|
||||
})
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setError(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
setError('Bild muss PNG, JPEG oder WebP sein')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_TABLE_IMAGE_BYTES) {
|
||||
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
const dataUrl = await readImageAsDataUrl(file)
|
||||
setForm((f) => ({ ...f, imageUrl: dataUrl }))
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
tableNumber: Number(form.tableNumber),
|
||||
seats: Number(form.seats),
|
||||
imageUrl: form.imageUrl,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await updateTable({ data: { id: editingId, ...payload } })
|
||||
setSuccess('Tisch wurde aktualisiert.')
|
||||
} else {
|
||||
await createTable({ data: payload })
|
||||
setSuccess('Tisch wurde angelegt.')
|
||||
}
|
||||
|
||||
cancelEdit()
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const table = pendingDelete
|
||||
if (!table) return
|
||||
|
||||
setPendingDelete(null)
|
||||
setDeletingId(table.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await deleteTable({ data: { id: table.id } })
|
||||
if (editingId === table.id) cancelEdit()
|
||||
setSuccess('Tisch wurde gelöscht.')
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut()
|
||||
await router.navigate({ to: '/staff/login' })
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="staff-page">
|
||||
<div className="staff-nav">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="staff-tabs-bar">
|
||||
<StaffTabs role={staffUser.role} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="page staff-main">
|
||||
<h1 className="section-heading">Tische</h1>
|
||||
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">
|
||||
{editingId ? 'Tisch bearbeiten' : 'Neuer Tisch'}
|
||||
</h2>
|
||||
|
||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Tischnummer
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={form.tableNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableNumber: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Plätze
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={50}
|
||||
value={form.seats}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seats: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Die Tischnummer wird auf dem Marker angezeigt; der Name ist nur für die Verwaltung hier.
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Foto
|
||||
<div className="table-image-field">
|
||||
{form.imageUrl && <img className="table-image-preview" src={form.imageUrl} alt="" />}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
{form.imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setForm((f) => ({ ...f, imageUrl: null }))}
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Zeigt Gästen im Buchungsformular, wie der Tisch aussieht. PNG, JPEG oder WebP, max.{' '}
|
||||
{MAX_IMAGE_MB} MB.
|
||||
</p>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="staff-table-section">
|
||||
{tables.length === 0 ? (
|
||||
<p className="empty-state">Noch keine Tische vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nr.</th>
|
||||
<th>Name</th>
|
||||
<th>Plätze</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((table) => (
|
||||
<tr
|
||||
key={table.id}
|
||||
className={editingId === table.id ? 'is-editing' : undefined}
|
||||
onClick={() => setViewingTable(table)}
|
||||
>
|
||||
<td>
|
||||
{table.imageUrl ? (
|
||||
<img className="table-thumb" src={table.imageUrl} alt="" />
|
||||
) : (
|
||||
<span className="table-thumb table-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
</td>
|
||||
<td>{table.tableNumber ?? '—'}</td>
|
||||
<td>{table.name}</td>
|
||||
<td>{table.seats}</td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="table-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => startEdit(table)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={deletingId === table.id}
|
||||
onClick={() => setPendingDelete(table)}
|
||||
>
|
||||
{deletingId === table.id ? 'Löschen …' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="Tisch löschen"
|
||||
message={pendingDelete && <>Tisch <strong>{pendingDelete.name}</strong> wirklich löschen?</>}
|
||||
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Löschen"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
<TableDetailDialog table={viewingTable} onClose={() => setViewingTable(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+146
-49
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { StaffMemberDetailDialog } from '#/components/staff-member-detail-dialog'
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { phoneSchema } from '#/lib/phone'
|
||||
import { listStaffMembers } from '#/lib/staff'
|
||||
|
||||
export const Route = createFileRoute('/staff/team')({
|
||||
@@ -28,12 +32,13 @@ type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
|
||||
type FormState = {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
password: string
|
||||
role: 'admin' | 'user'
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', email: '', password: '', role: 'user' }
|
||||
return { name: '', email: '', phone: '', password: '', role: 'user' }
|
||||
}
|
||||
|
||||
function StaffTeam() {
|
||||
@@ -45,11 +50,20 @@ function StaffTeam() {
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [viewingMember, setViewingMember] = useState<StaffMember | null>(null)
|
||||
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null)
|
||||
|
||||
async function handleCreate(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const phoneResult = phoneSchema.safeParse(form.phone)
|
||||
if (!phoneResult.success) {
|
||||
setError('Bitte geben Sie eine gültige Telefonnummer an')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const { error: createError } = await authClient.admin.createUser({
|
||||
@@ -57,6 +71,7 @@ function StaffTeam() {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
data: { phone: phoneResult.data },
|
||||
})
|
||||
|
||||
if (createError) {
|
||||
@@ -71,11 +86,11 @@ function StaffTeam() {
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function handleRemove(member: StaffMember) {
|
||||
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) {
|
||||
return
|
||||
}
|
||||
async function confirmRemove() {
|
||||
const member = pendingRemove
|
||||
if (!member) return
|
||||
|
||||
setPendingRemove(null)
|
||||
setBusyId(member.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
@@ -129,6 +144,44 @@ function StaffTeam() {
|
||||
setBusyId(null)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut()
|
||||
await router.navigate({ to: '/staff/login' })
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function handleSetPhone(member: StaffMember) {
|
||||
const newPhone = window.prompt(`Telefonnummer für ${member.name}:`, member.phone ?? '')
|
||||
|
||||
if (newPhone === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const phoneResult = phoneSchema.safeParse(newPhone)
|
||||
if (!phoneResult.success) {
|
||||
setError('Bitte geben Sie eine gültige Telefonnummer an')
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(member.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const { error: phoneError } = await authClient.admin.updateUser({
|
||||
userId: member.id,
|
||||
data: { phone: phoneResult.data },
|
||||
})
|
||||
|
||||
if (phoneError) {
|
||||
setError(phoneError.message ?? 'Etwas ist schiefgelaufen')
|
||||
} else {
|
||||
setSuccess(`Telefonnummer für ${member.name} wurde aktualisiert.`)
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
setBusyId(null)
|
||||
}
|
||||
|
||||
async function handleSetPassword(member: StaffMember) {
|
||||
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
|
||||
|
||||
@@ -156,17 +209,23 @@ function StaffTeam() {
|
||||
|
||||
return (
|
||||
<div className="staff-page">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
<Link className="btn btn-secondary" to="/staff">
|
||||
Zu den Reservierungen
|
||||
<div className="staff-nav">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<div className="staff-topbar-right">
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="staff-tabs-bar">
|
||||
<StaffTabs role={staffUser.role} />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main className="page staff-main">
|
||||
<h1 className="section-heading">Mitarbeiter</h1>
|
||||
@@ -197,6 +256,19 @@ function StaffTeam() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Telefon
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
pattern="[+]?[0-9()\-.\s]{6,30}"
|
||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Passwort
|
||||
@@ -232,11 +304,12 @@ function StaffTeam() {
|
||||
|
||||
<section className="staff-table-section">
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
@@ -244,44 +317,55 @@ function StaffTeam() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.id}>
|
||||
<tr key={member.id} onClick={() => setViewingMember(member)}>
|
||||
<td>{member.name}</td>
|
||||
<td>{member.email}</td>
|
||||
<td>{member.phone || <span className="muted">—</span>}</td>
|
||||
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
|
||||
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
|
||||
<td className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id}
|
||||
onClick={() => handleToggleRole(member)}
|
||||
>
|
||||
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id}
|
||||
onClick={() => handleSetPassword(member)}
|
||||
>
|
||||
Passwort setzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id || member.id === staffUser.id}
|
||||
onClick={() => handleToggleBan(member)}
|
||||
>
|
||||
{member.banned ? 'Entsperren' : 'Sperren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={busyId === member.id || member.id === staffUser.id}
|
||||
onClick={() => handleRemove(member)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id}
|
||||
onClick={() => handleToggleRole(member)}
|
||||
>
|
||||
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id}
|
||||
onClick={() => handleSetPhone(member)}
|
||||
>
|
||||
Telefon bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id}
|
||||
onClick={() => handleSetPassword(member)}
|
||||
>
|
||||
Passwort setzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === member.id || member.id === staffUser.id}
|
||||
onClick={() => handleToggleBan(member)}
|
||||
>
|
||||
{member.banned ? 'Entsperren' : 'Sperren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={busyId === member.id || member.id === staffUser.id}
|
||||
onClick={() => setPendingRemove(member)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -290,6 +374,19 @@ function StaffTeam() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StaffMemberDetailDialog member={viewingMember} onClose={() => setViewingMember(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
title="Zugang entfernen"
|
||||
message={pendingRemove && <>Zugang für <strong>{pendingRemove.name}</strong> wirklich entfernen?</>}
|
||||
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Entfernen"
|
||||
danger
|
||||
onConfirm={confirmRemove}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
|
||||
import { SiteFooter } from '#/components/site-footer'
|
||||
import { SiteHeader } from '#/components/site-header'
|
||||
import { cancelReservationByToken, getReservationByCancelToken } from '#/lib/reservations'
|
||||
|
||||
export const Route = createFileRoute('/stornieren/$token')({
|
||||
component: CancelReservationPage,
|
||||
loader: async ({ params }) => {
|
||||
const reservation = await getReservationByCancelToken({ data: { token: params.token } })
|
||||
return { reservation }
|
||||
},
|
||||
})
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function CancelReservationPage() {
|
||||
const { token } = Route.useParams()
|
||||
const { reservation } = Route.useLoaderData()
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleCancel() {
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await cancelReservationByToken({ data: { token } })
|
||||
setIsCancelled(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main className="page">
|
||||
<section className="section">
|
||||
<div className="section-grid">
|
||||
<div>
|
||||
<span className="section-kicker">Reservierung verwalten</span>
|
||||
<h1 className="section-heading">Reservierung stornieren</h1>
|
||||
</div>
|
||||
|
||||
{!reservation ? (
|
||||
<p className="empty-state">
|
||||
Diese Reservierung wurde nicht gefunden oder ist bereits storniert.{' '}
|
||||
<Link to="/">Zurück zur Startseite</Link>
|
||||
</p>
|
||||
) : isCancelled ? (
|
||||
<p className="message message-success">
|
||||
Ihre Reservierung wurde storniert. Wir haben Ihnen eine Bestätigung per E-Mail
|
||||
gesendet. <Link to="/">Zurück zur Startseite</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="reservation-form">
|
||||
<ul className="contact-list">
|
||||
<li>
|
||||
<span className="label">Tisch</span>
|
||||
<span>{reservation.tableName}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">Datum & Uhrzeit</span>
|
||||
<span>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="label">Personen</span>
|
||||
<span>{reservation.partySize}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" className="btn btn-danger" disabled={isSubmitting} onClick={handleCancel}>
|
||||
{isSubmitting ? 'Wird storniert …' : 'Reservierung stornieren'}
|
||||
</button>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user