374 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|