Files
promo_steakhouse/src/routes/staff.tables.tsx
T
lukas e732b08b64
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 4m2s
CI / deploy (push) Failing after 12s
✨ first version
2026-08-27 23:27:43 +02:00

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