339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|