@@ -0,0 +1,338 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user