Files
promo_steakhouse/src/routes/staff.settings.tsx
T
lukas c64889c3b1
CI / build-and-test (pull_request) Failing after 49s
wip
2026-08-27 10:04:59 +02:00

374 lines
14 KiB
TypeScript

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>
)
}