@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user