feat(frontend): add full reservation workflow, timeline gantt, and booking modal

This commit is contained in:
DanielS
2026-08-19 00:32:33 +02:00
parent 737686692a
commit 769268a77e
12 changed files with 1897 additions and 327 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.19.0",
"konva": "^10.3.1",
"lucide-react": "^0.462.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-konva": "^19.2.5",
"tailwindcss": "^4.3.3",
"zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",

View File

@@ -1,42 +1 @@
#root { /* cleared */
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,226 +1,95 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { Calendar, Clock, PlusCircle, LayoutGrid, Users, ShieldAlert, CheckCircle2, UserCheck } from 'lucide-react'; import { useAppStore } from './store/useAppStore';
import FloorPlan from './components/FloorPlan';
import Timeline from './components/Timeline';
import ReservationList from './components/ReservationList';
import BookingModal from './components/BookingModal';
import { ViewMode } from './types';
interface Table { const NAV_ITEMS: { id: ViewMode; label: string; icon: string }[] = [
id: string; { id: 'floor', label: '2D Tischplan', icon: '⬛' },
number: string; { id: 'timeline', label: 'Timeline', icon: '📊' },
seats: number; { id: 'list', label: 'Reservierungen', icon: '📋' },
status: 'FREE' | 'OCCUPIED' | 'RESERVED' | 'BILL'; ];
x: number;
y: number;
shape: 'RECT' | 'ROUND';
}
export function App() { function App() {
const [view, setView] = useState<'floor' | 'timeline'>('floor'); const [view, setView] = useState<ViewMode>('floor');
const [selectedTable, setSelectedTable] = useState<Table | null>(null); const { tables, reservations, showBookingModal, openBookingModal } = useAppStore();
const [showQuickBook, setShowQuickBook] = useState(false);
const [tables, setTables] = useState<Table[]>([ const activeCount = reservations.filter((r) => ['PENDING', 'CONFIRMED', 'ARRIVED'].includes(r.status)).length;
{ id: '1', number: 'T1', seats: 4, status: 'FREE', x: 50, y: 50, shape: 'RECT' }, const freeCount = tables.filter((t) => t.status === 'FREE').length;
{ id: '2', number: 'T2', seats: 2, status: 'OCCUPIED', x: 220, y: 50, shape: 'ROUND' },
{ id: '3', number: 'T3', seats: 6, status: 'RESERVED', x: 50, y: 220, shape: 'RECT' },
{ id: '4', number: 'T4', seats: 4, status: 'BILL', x: 220, y: 220, shape: 'ROUND' },
]);
const getStatusColor = (status: Table['status']) => {
switch (status) {
case 'FREE': return 'bg-emerald-500/20 border-emerald-500 text-emerald-400';
case 'OCCUPIED': return 'bg-rose-500/20 border-rose-500 text-rose-400';
case 'RESERVED': return 'bg-amber-500/20 border-amber-500 text-amber-400';
case 'BILL': return 'bg-sky-500/20 border-sky-500 text-sky-400';
}
};
const handleStatusChange = (id: string, newStatus: Table['status']) => {
setTables(tables.map(t => t.id === id ? { ...t, status: newStatus } : t));
if (selectedTable?.id === id) {
setSelectedTable({ ...selectedTable, status: newStatus });
}
};
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 font-sans"> <div className="min-h-screen bg-slate-950 text-slate-100" style={{ fontFamily: "'Inter', system-ui, sans-serif" }}>
{/* Top Header */} {/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur px-6 py-4 flex flex-wrap items-center justify-between gap-4"> <header className="sticky top-0 z-40 border-b border-slate-800/80 bg-slate-950/95 backdrop-blur-xl px-4 lg:px-6 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
{/* Brand */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-amber-500 to-amber-600 flex items-center justify-center font-bold text-slate-950 shadow-lg shadow-amber-500/20"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center font-black text-slate-950 text-sm shadow-lg shadow-amber-500/25">
PL PL
</div> </div>
<div> <div className="hidden sm:block">
<h1 className="text-xl font-bold tracking-tight">Pizzeria Luigi</h1> <p className="font-bold text-base leading-tight">Pizzeria Luigi</p>
<p className="text-xs text-slate-400 flex items-center gap-2"> <p className="text-xs text-slate-500 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Tenant: pizzeria-luigi (Schema: tenant_pizzeria_luigi) Live · {new Date().toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
</p> </p>
</div> </div>
</div> </div>
{/* View Switcher */} {/* Stats */}
<div className="flex bg-slate-800/80 p-1 rounded-xl border border-slate-700"> <div className="hidden md:flex items-center gap-4 text-xs">
<button <div className="flex items-center gap-2 px-3 py-1.5 bg-slate-900 rounded-xl border border-slate-800">
onClick={() => setView('floor')} <span className="w-2 h-2 rounded-full bg-emerald-500" />
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 ${ <span className="text-slate-400">Frei:</span>
view === 'floor' ? 'bg-amber-500 text-slate-950 shadow' : 'text-slate-400 hover:text-white' <span className="font-bold text-emerald-400">{freeCount}</span>
}`} </div>
> <div className="flex items-center gap-2 px-3 py-1.5 bg-slate-900 rounded-xl border border-slate-800">
<LayoutGrid className="w-4 h-4" /> 2D Tischplan <span className="w-2 h-2 rounded-full bg-amber-400" />
</button> <span className="text-slate-400">Aktiv:</span>
<button <span className="font-bold text-amber-400">{activeCount}</span>
onClick={() => setView('timeline')} </div>
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 ${
view === 'timeline' ? 'bg-amber-500 text-slate-950 shadow' : 'text-slate-400 hover:text-white'
}`}
>
<Clock className="w-4 h-4" /> Timeline (Gantt)
</button>
</div> </div>
{/* Quick Actions */} {/* Nav */}
<div className="flex items-center gap-3"> <nav className="flex bg-slate-900/80 p-1 rounded-xl border border-slate-800 gap-1">
{NAV_ITEMS.map((item) => (
<button <button
onClick={() => setShowQuickBook(true)} key={item.id}
className="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2 rounded-xl transition flex items-center gap-2 shadow-lg shadow-emerald-500/20 active:scale-95" onClick={() => setView(item.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 ${
view === item.id
? 'bg-amber-500 text-slate-950 shadow'
: 'text-slate-400 hover:text-slate-200'
}`}
> >
<PlusCircle className="w-5 h-5" /> Schnellbuchung / Walk-In <span>{item.icon}</span>
<span className="hidden sm:inline">{item.label}</span>
</button>
))}
</nav>
{/* CTA */}
<button
onClick={() => openBookingModal()}
className="bg-emerald-500 hover:bg-emerald-400 active:scale-95 text-slate-950 font-bold px-4 py-2 rounded-xl text-xs transition shadow-lg shadow-emerald-500/20 flex items-center gap-1.5"
>
<span>+</span>
<span className="hidden sm:inline">Walk-In</span>
</button> </button>
</div> </div>
</header> </header>
{/* Main Content */} {/* Main */}
<main className="p-6 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-6"> <main className="max-w-7xl mx-auto p-4 lg:p-6">
{/* Left Interactive Canvas Area */} {view === 'floor' && <FloorPlan tables={tables} />}
<div className="lg:col-span-2 bg-slate-900/60 border border-slate-800 rounded-2xl p-6 min-h-[500px] flex flex-col justify-between relative overflow-hidden shadow-2xl"> {view === 'timeline' && <Timeline />}
<div className="flex items-center justify-between mb-4"> {view === 'list' && <ReservationList />}
<h2 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="w-5 h-5 text-amber-500" />
Gastraum (Hauptbereich)
</h2>
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-emerald-500/30 border border-emerald-500"></span> Frei</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-rose-500/30 border border-rose-500"></span> Belegt</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-500/30 border border-amber-500"></span> Reserviert</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-sky-500/30 border border-sky-500"></span> Rechnung</span>
</div>
</div>
{/* 2D Canvas Interactive Area */}
{view === 'floor' ? (
<div className="relative w-full h-[400px] bg-slate-950/80 rounded-xl border border-slate-800/80 p-4 overflow-hidden flex-1">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#1e293b15_1px,transparent_1px),linear-gradient(to_bottom,#1e293b15_1px,transparent_1px)] bg-[size:20px_20px]" />
{tables.map((t) => (
<div
key={t.id}
onClick={() => setSelectedTable(t)}
style={{ left: `${t.x}px`, top: `${t.y}px` }}
className={`absolute w-32 h-32 cursor-pointer transition-all duration-200 border-2 rounded-2xl p-3 flex flex-col justify-between backdrop-blur-md shadow-xl hover:scale-105 ${getStatusColor(
t.status
)} ${selectedTable?.id === t.id ? 'ring-4 ring-amber-500/50 scale-105' : ''}`}
>
<div className="flex justify-between items-start">
<span className="font-bold text-lg">{t.number}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-900/60 flex items-center gap-1">
<Users className="w-3 h-3" /> {t.seats}
</span>
</div>
<div className="text-center py-1">
<span className="text-xs font-semibold uppercase tracking-wider">{t.status}</span>
</div>
</div>
))}
</div>
) : (
<div className="w-full h-[400px] bg-slate-950/80 rounded-xl border border-slate-800 p-4 flex items-center justify-center text-slate-400">
<div className="text-center">
<Clock className="w-12 h-12 text-amber-500/50 mx-auto mb-2" />
<p>Timeline-Ansicht im 15-Minuten Raster aktiv.</p>
</div>
</div>
)}
</div>
{/* Right Details & Fast Control Panel */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 flex flex-col justify-between shadow-2xl">
<div>
<h3 className="text-lg font-semibold mb-4 text-amber-400">Tisch-Details</h3>
{selectedTable ? (
<div className="space-y-4">
<div className="bg-slate-950/60 p-4 rounded-xl border border-slate-800">
<div className="flex justify-between items-center mb-2">
<span className="text-2xl font-bold">{selectedTable.number}</span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(selectedTable.status)}`}>
{selectedTable.status}
</span>
</div>
<p className="text-sm text-slate-400">Max. Kapazität: {selectedTable.seats} Personen</p>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Status schnell ändern</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleStatusChange(selectedTable.id, 'FREE')}
className="px-3 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 hover:bg-emerald-500/20 text-emerald-400 text-xs font-medium transition"
>
Freigeben
</button>
<button
onClick={() => handleStatusChange(selectedTable.id, 'OCCUPIED')}
className="px-3 py-2 rounded-lg bg-rose-500/10 border border-rose-500/30 hover:bg-rose-500/20 text-rose-400 text-xs font-medium transition"
>
Belegen
</button>
<button
onClick={() => handleStatusChange(selectedTable.id, 'RESERVED')}
className="px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 text-amber-400 text-xs font-medium transition"
>
Reservieren
</button>
<button
onClick={() => handleStatusChange(selectedTable.id, 'BILL')}
className="px-3 py-2 rounded-lg bg-sky-500/10 border border-sky-500/30 hover:bg-sky-500/20 text-sky-400 text-xs font-medium transition"
>
Rechnung
</button>
</div>
</div>
</div>
) : (
<div className="text-center py-12 text-slate-500">
<p>Wähle einen Tisch im Plan aus, um Details und Steuerung anzuzeigen.</p>
</div>
)}
</div>
<div className="border-t border-slate-800 pt-4 mt-6">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Kellner PIN-Auth: Aktiv</span>
<span className="text-emerald-400 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Ready</span>
</div>
</div>
</div>
</main> </main>
{/* Quick Booking Modal */} {/* Booking Modal */}
{showQuickBook && ( {showBookingModal && <BookingModal />}
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-4">
<h3 className="text-xl font-bold text-amber-400 flex items-center gap-2">
<UserCheck className="w-6 h-6" /> Schnellbuchung / Walk-In
</h3>
<p className="text-sm text-slate-400">Direkte Reservierung für Kellner (unter 5 sec).</p>
<div className="space-y-3">
<input type="text" placeholder="Gast Name (optional)" className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-amber-500" />
<input type="number" placeholder="Personenanzahl (z. B. 4)" className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-amber-500" defaultValue={2} />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowQuickBook(false)} className="px-4 py-2 rounded-xl bg-slate-800 text-sm hover:bg-slate-700">Abbrechen</button>
<button onClick={() => setShowQuickBook(false)} className="px-4 py-2 rounded-xl bg-amber-500 text-slate-950 font-semibold text-sm hover:bg-amber-400">Buchen & Zuweisen</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,278 @@
import { useState } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Reservation } from '../types';
const TAGS = ['Hochstuhl', 'Allergie', 'Fensterplatz', 'Geburtstag'];
const GUEST_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 10, 12];
function generateId() {
return Math.random().toString(36).substring(2, 10);
}
function getTimeSlots() {
const slots: string[] = [];
for (let h = 10; h < 24; h++) {
for (const m of [0, 15, 30, 45]) {
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
return slots;
}
const TIME_SLOTS = getTimeSlots();
export default function BookingModal() {
const { tables, prefillTableId, prefillTime, prefillDate, closeBookingModal, addReservation } = useAppStore();
const today = prefillDate ?? new Date().toISOString().slice(0, 10);
const [guestCount, setGuestCount] = useState(2);
const [date, setDate] = useState(today);
const [startTime, setStartTime] = useState(prefillTime ?? '19:00');
const [endTime, setEndTime] = useState(() => {
const start = prefillTime ?? '19:00';
const [h, m] = start.split(':').map(Number);
const end = h + 1 + ':' + String(m).padStart(2, '0');
return end;
});
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [tableId, setTableId] = useState(prefillTableId ?? tables[0]?.id ?? '');
const [notes, setNotes] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const suggestedTables = tables.filter((t) => t.maxCapacity >= guestCount && t.status === 'FREE');
const toggleTag = (tag: string) =>
setSelectedTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!firstName || !tableId) return;
const reservation: Reservation = {
id: generateId(),
guest: { id: generateId(), firstName, lastName, phone: phone || undefined, email: email || undefined },
tableIds: [tableId],
date,
startTime,
endTime,
guestCount,
status: 'CONFIRMED',
notes,
tags: selectedTags,
createdAt: new Date().toISOString(),
};
addReservation(reservation);
closeBookingModal();
};
return (
<div className="fixed inset-0 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-4 z-50 overflow-y-auto">
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-lg shadow-2xl my-4">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-800 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-amber-400">Neue Reservierung</h3>
<p className="text-xs text-slate-400">Schnellbuchung / Walk-In</p>
</div>
<button
onClick={closeBookingModal}
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-slate-400 hover:text-white transition"
>
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Person Count */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Personen</label>
<div className="flex flex-wrap gap-2">
{GUEST_COUNTS.map((n) => (
<button
key={n}
type="button"
onClick={() => setGuestCount(n)}
className={`w-11 h-11 rounded-xl font-bold text-sm transition border ${
guestCount === n
? 'bg-amber-500 border-amber-400 text-slate-950 shadow-lg shadow-amber-500/20'
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/50'
}`}
>
{n}
</button>
))}
<button
type="button"
onClick={() => setGuestCount(15)}
className={`px-3 h-11 rounded-xl font-bold text-sm transition border ${
guestCount >= 13
? 'bg-amber-500 border-amber-400 text-slate-950'
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/50'
}`}
>
12+
</button>
</div>
</div>
{/* Date & Time */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3 sm:col-span-1">
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Datum</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Von</label>
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition"
>
{TIME_SLOTS.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Bis</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition"
>
{TIME_SLOTS.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
{/* Guest Info */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Gastdaten</label>
<div className="grid grid-cols-2 gap-3">
<input
required
type="text"
placeholder="Vorname *"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/>
<input
type="text"
placeholder="Nachname"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/>
<input
type="tel"
placeholder="Telefon"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/>
<input
type="email"
placeholder="E-Mail (optional)"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/>
</div>
</div>
{/* Table Selection */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Tisch zuweisen
{suggestedTables.length > 0 && (
<span className="ml-2 text-emerald-400 font-medium normal-case">
{suggestedTables.length} freie Tische verfügbar
</span>
)}
</label>
<div className="flex flex-wrap gap-2">
{tables.map((t) => {
const isSuggested = suggestedTables.some((s) => s.id === t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setTableId(t.id)}
className={`px-3 py-2 rounded-xl text-xs font-semibold border transition ${
tableId === t.id
? 'bg-amber-500 border-amber-400 text-slate-950'
: isSuggested
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/25'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{t.number} ({t.maxCapacity}P)
{isSuggested && tableId !== t.id && <span className="ml-1"></span>}
</button>
);
})}
</div>
</div>
{/* Tags */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Tags / Besonderheiten</label>
<div className="flex gap-2 flex-wrap">
{TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition ${
selectedTags.includes(tag)
? 'bg-purple-500/30 border-purple-400/60 text-purple-300'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{tag === 'Hochstuhl' ? '🪑' : tag === 'Allergie' ? '⚠️' : tag === 'Fensterplatz' ? '🪟' : '🎂'} {tag}
</button>
))}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Notizen</label>
<textarea
rows={2}
placeholder="Besondere Wünsche, Hinweise..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition resize-none placeholder-slate-600"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={closeBookingModal}
className="flex-1 py-3 rounded-2xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold transition text-sm"
>
Abbrechen
</button>
<button
type="submit"
className="flex-1 py-3 rounded-2xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold transition text-sm active:scale-95 shadow-lg shadow-amber-500/25"
>
Reservierung bestätigen
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { RestaurantTable, TableStatus } from '../types';
import { useAppStore } from '../store/useAppStore';
const STATUS_STYLES: Record<TableStatus, string> = {
FREE: 'border-emerald-500 bg-emerald-500/15 text-emerald-300',
OCCUPIED: 'border-rose-500 bg-rose-500/15 text-rose-300',
RESERVED: 'border-amber-400 bg-amber-400/15 text-amber-300',
BILL: 'border-sky-400 bg-sky-400/15 text-sky-300',
};
const STATUS_LABEL: Record<TableStatus, string> = {
FREE: 'Frei', OCCUPIED: 'Belegt', RESERVED: 'Reserviert', BILL: 'Rechnung',
};
interface Props {
tables: RestaurantTable[];
}
export default function FloorPlan({ tables }: Props) {
const { selectedTableId, selectTable, setTableStatus, openBookingModal } = useAppStore();
const handleTableClick = (t: RestaurantTable) => {
selectTable(t.id === selectedTableId ? null : t.id);
};
const selected = tables.find((t) => t.id === selectedTableId);
return (
<div className="flex gap-4 flex-col lg:flex-row h-full">
{/* Canvas */}
<div className="flex-1 relative bg-slate-950/70 rounded-2xl border border-slate-800 overflow-hidden min-h-[420px]">
{/* Grid */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: 'linear-gradient(to right, #334155 1px, transparent 1px), linear-gradient(to bottom, #334155 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
{/* Area labels */}
{['Gastraum', 'Terrasse', 'Bar'].map((area, i) => (
<div
key={area}
className="absolute text-xs font-semibold text-slate-600 uppercase tracking-widest select-none"
style={{ left: 16, top: 16 + i * 200 }}
>
{area}
</div>
))}
{tables.map((t) => (
<button
key={t.id}
onClick={() => handleTableClick(t)}
style={{ left: t.x, top: t.y }}
title={`${t.number}${STATUS_LABEL[t.status]}`}
className={`absolute w-28 h-28 border-2 transition-all duration-200 flex flex-col items-center justify-center gap-1 group
${t.shape === 'ROUND' ? 'rounded-full' : 'rounded-2xl'}
${STATUS_STYLES[t.status]}
${selectedTableId === t.id ? 'ring-4 ring-amber-400/50 scale-110 z-10' : 'hover:scale-105'}
cursor-pointer shadow-lg`}
>
<span className="font-bold text-xl">{t.number}</span>
<span className="text-xs opacity-75">👤 {t.maxCapacity}</span>
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-80">
{STATUS_LABEL[t.status]}
</span>
</button>
))}
</div>
{/* Side Panel */}
<div className="w-full lg:w-64 flex flex-col gap-3">
{/* Legend */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-4 space-y-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Legende</p>
{(Object.entries(STATUS_LABEL) as [TableStatus, string][]).map(([s, label]) => (
<div key={s} className="flex items-center gap-2 text-sm">
<span className={`w-3 h-3 rounded border ${STATUS_STYLES[s]}`} />
<span className="text-slate-300">{label}</span>
</div>
))}
</div>
{/* Selected Table Control */}
{selected && (
<div className="bg-slate-900/60 border border-amber-500/30 rounded-2xl p-4 space-y-3">
<p className="text-sm font-bold text-amber-400">Tisch {selected.number}</p>
<p className="text-xs text-slate-400">{selected.area} · max. {selected.maxCapacity} Pers.</p>
<div className="grid grid-cols-2 gap-2">
{(['FREE', 'OCCUPIED', 'RESERVED', 'BILL'] as TableStatus[]).map((s) => (
<button
key={s}
onClick={() => setTableStatus(selected.id, s)}
className={`px-2 py-1.5 rounded-lg text-xs font-semibold border transition
${selected.status === s
? STATUS_STYLES[s] + ' shadow-md'
: 'border-slate-700 text-slate-500 hover:border-slate-500'}`}
>
{STATUS_LABEL[s]}
</button>
))}
</div>
<button
onClick={() => openBookingModal(selected.id)}
className="w-full py-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-semibold text-xs transition active:scale-95"
>
+ Neue Reservierung
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { useAppStore } from '../store/useAppStore';
import { Reservation, ReservationStatus } from '../types';
const STATUS_ORDER: ReservationStatus[] = ['PENDING', 'CONFIRMED', 'ARRIVED', 'FINISHED', 'NO_SHOW', 'CANCELLED'];
const STATUS_STYLE: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-700/50 text-slate-300 border-slate-600',
CONFIRMED: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
ARRIVED: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
FINISHED: 'bg-slate-800/60 text-slate-500 border-slate-700',
NO_SHOW: 'bg-rose-500/20 text-rose-300 border-rose-500/40',
CANCELLED: 'bg-rose-900/30 text-rose-400/60 border-rose-800/40',
};
const STATUS_LABEL: Record<ReservationStatus, string> = {
PENDING: 'Ausstehend',
CONFIRMED: 'Bestätigt',
ARRIVED: 'Angekommen',
FINISHED: 'Abgeschlossen',
NO_SHOW: 'No-Show',
CANCELLED: 'Storniert',
};
const NEXT_STATUS: Partial<Record<ReservationStatus, ReservationStatus>> = {
PENDING: 'CONFIRMED',
CONFIRMED: 'ARRIVED',
ARRIVED: 'FINISHED',
};
const TAG_ICONS: Record<string, string> = {
Hochstuhl: '🪑',
Allergie: '⚠️',
Fensterplatz: '🪟',
Geburtstag: '🎂',
};
export default function ReservationList() {
const { reservations, updateReservationStatus, tables, openBookingModal } = useAppStore();
const active = reservations.filter((r) => !['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status));
const past = reservations.filter((r) => ['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status));
const getTableNames = (ids: string[]) =>
ids.map((id) => tables.find((t) => t.id === id)?.number ?? id).join(', ');
const Card = ({ r }: { r: Reservation }) => (
<div className={`p-4 rounded-2xl border transition-all ${STATUS_STYLE[r.status]}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base">{r.guest.firstName} {r.guest.lastName}</span>
<span className="text-xs px-2 py-0.5 rounded-full border font-semibold text-current border-current/30 bg-current/10">
{STATUS_LABEL[r.status]}
</span>
</div>
<div className="text-xs mt-1 text-current/70 flex flex-wrap gap-x-3 gap-y-0.5">
<span>👤 {r.guestCount} Pers.</span>
<span>🕐 {r.startTime}{r.endTime}</span>
<span>📋 Tisch {getTableNames(r.tableIds)}</span>
{r.guest.phone && <span>📞 {r.guest.phone}</span>}
</div>
{r.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{r.tags.map((tag) => (
<span key={tag} className="text-xs px-2 py-0.5 rounded-full bg-slate-800/80 text-slate-300 border border-slate-700">
{TAG_ICONS[tag] ?? '🏷️'} {tag}
</span>
))}
</div>
)}
{r.notes && <p className="text-xs mt-1.5 italic text-current/50">{r.notes}"</p>}
</div>
<div className="flex flex-col gap-2 items-end shrink-0">
{NEXT_STATUS[r.status] && (
<button
onClick={() => updateReservationStatus(r.id, NEXT_STATUS[r.status]!)}
className="text-xs px-3 py-1.5 rounded-lg bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30 font-semibold transition whitespace-nowrap"
>
→ {STATUS_LABEL[NEXT_STATUS[r.status]!]}
</button>
)}
{!['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status) && (
<button
onClick={() => updateReservationStatus(r.id, 'NO_SHOW')}
className="text-xs px-3 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-400 hover:bg-rose-500/20 font-semibold transition whitespace-nowrap"
>
No-Show
</button>
)}
{!['CANCELLED', 'FINISHED'].includes(r.status) && (
<button
onClick={() => updateReservationStatus(r.id, 'CANCELLED')}
className="text-xs px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600 text-slate-400 hover:bg-slate-700 font-semibold transition"
>
Stornieren
</button>
)}
</div>
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-200">Heute · Aktive Reservierungen ({active.length})</h3>
<button
onClick={() => openBookingModal()}
className="text-xs px-4 py-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold transition"
>
+ Neue Buchung
</button>
</div>
<div className="space-y-3">
{active.length === 0 && <p className="text-sm text-slate-500">Keine aktiven Reservierungen.</p>}
{active.map((r) => <Card key={r.id} r={r} />)}
</div>
{past.length > 0 && (
<>
<div className="border-t border-slate-800/80 pt-4">
<h3 className="text-sm font-semibold text-slate-500 mb-3">Abgeschlossen / Storniert ({past.length})</h3>
<div className="space-y-2">
{past.map((r) => <Card key={r.id} r={r} />)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useAppStore } from '../store/useAppStore';
import { Reservation, ReservationStatus } from '../types';
const HOURS = Array.from({ length: 14 }, (_, i) => `${String(i + 10).padStart(2, '0')}:00`);
const STATUS_DOT: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-400',
CONFIRMED: 'bg-amber-400',
ARRIVED: 'bg-emerald-400',
FINISHED: 'bg-slate-600',
NO_SHOW: 'bg-rose-500',
CANCELLED: 'bg-rose-700',
};
const STATUS_BAR: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-500/50 border-slate-400/30',
CONFIRMED: 'bg-amber-500/30 border-amber-400/50',
ARRIVED: 'bg-emerald-500/30 border-emerald-400/50',
FINISHED: 'bg-slate-600/30 border-slate-500/30',
NO_SHOW: 'bg-rose-500/20 border-rose-400/30',
CANCELLED: 'bg-rose-800/20 border-rose-700/30',
};
function timeToMinutes(t: string) {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
}
const TIMELINE_START = 10 * 60; // 10:00
const TIMELINE_END = 24 * 60; // 24:00
const TOTAL_MINUTES = TIMELINE_END - TIMELINE_START;
const COL_WIDTH = 72; // px per hour
export default function Timeline() {
const { tables, reservations, openBookingModal } = useAppStore();
const handleSlotClick = (tableId: string, hour: number) => {
openBookingModal(tableId, `${String(hour).padStart(2, '0')}:00`);
};
return (
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
<div style={{ minWidth: `${120 + HOURS.length * COL_WIDTH}px` }}>
{/* Header */}
<div className="flex border-b border-slate-800 sticky top-0 bg-slate-950 z-10">
<div className="w-[120px] shrink-0 p-3 text-xs font-semibold text-slate-500 uppercase tracking-wider border-r border-slate-800">
Tisch
</div>
{HOURS.map((h) => (
<div
key={h}
className="text-center text-xs text-slate-500 border-r border-slate-800/50 py-3 font-mono"
style={{ width: COL_WIDTH }}
>
{h}
</div>
))}
</div>
{/* Rows */}
{tables.map((table) => {
const tableReservations = reservations.filter((r) =>
r.tableIds.includes(table.id) && r.status !== 'CANCELLED'
);
return (
<div key={table.id} className="flex border-b border-slate-800/50 hover:bg-slate-900/30 group relative">
{/* Table Label */}
<div className="w-[120px] shrink-0 px-3 py-3 border-r border-slate-800 flex flex-col justify-center">
<span className="text-sm font-bold text-slate-200">{table.number}</span>
<span className="text-xs text-slate-500">{table.area} · {table.maxCapacity}P</span>
</div>
{/* Timeline cells */}
<div className="relative flex flex-1" style={{ height: 64 }}>
{/* Click grid */}
{HOURS.map((_, i) => (
<button
key={i}
onClick={() => handleSlotClick(table.id, 10 + i)}
className="border-r border-slate-800/30 h-full hover:bg-amber-500/10 transition shrink-0 group-hover:border-slate-700/50"
style={{ width: COL_WIDTH }}
title={`Neue Reservierung: ${table.number} um ${HOURS[i]}`}
/>
))}
{/* Reservation bars */}
{tableReservations.map((res: Reservation) => {
const startMin = timeToMinutes(res.startTime) - TIMELINE_START;
const endMin = timeToMinutes(res.endTime) - TIMELINE_START;
const leftPct = (startMin / TOTAL_MINUTES) * 100;
const widthPct = ((endMin - startMin) / TOTAL_MINUTES) * 100;
const totalWidth = HOURS.length * COL_WIDTH;
return (
<div
key={res.id}
title={`${res.guest.firstName} ${res.guest.lastName} (${res.guestCount}P) ${res.startTime}${res.endTime}`}
className={`absolute top-2 bottom-2 rounded-lg border px-2 flex items-center overflow-hidden text-xs font-semibold cursor-pointer hover:brightness-110 transition z-10 ${STATUS_BAR[res.status]}`}
style={{
left: `${(startMin / TOTAL_MINUTES) * totalWidth}px`,
width: `${((endMin - startMin) / TOTAL_MINUTES) * totalWidth - 4}px`,
}}
>
<span className={`w-2 h-2 rounded-full shrink-0 mr-1.5 ${STATUS_DOT[res.status]}`} />
<span className="truncate">{res.guest.firstName} {res.guest.lastName}</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,68 +1 @@
:root { @import "tailwindcss";
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand';
import { Reservation, ReservationStatus, RestaurantTable, TableStatus } from '../types';
const SAMPLE_TABLES: RestaurantTable[] = [
{ id: 't1', number: 'T1', seats: 4, minCapacity: 1, maxCapacity: 4, status: 'FREE', x: 60, y: 60, shape: 'RECT', area: 'Gastraum' },
{ id: 't2', number: 'T2', seats: 2, minCapacity: 1, maxCapacity: 2, status: 'OCCUPIED', x: 240, y: 60, shape: 'ROUND', area: 'Gastraum' },
{ id: 't3', number: 'T3', seats: 6, minCapacity: 2, maxCapacity: 6, status: 'RESERVED', x: 420, y: 60, shape: 'RECT', area: 'Gastraum' },
{ id: 't4', number: 'T4', seats: 4, minCapacity: 1, maxCapacity: 4, status: 'BILL', x: 60, y: 240, shape: 'ROUND', area: 'Terrasse' },
{ id: 't5', number: 'T5', seats: 8, minCapacity: 4, maxCapacity: 8, status: 'FREE', x: 240, y: 240, shape: 'RECT', area: 'Terrasse' },
{ id: 't6', number: 'T6', seats: 2, minCapacity: 1, maxCapacity: 2, status: 'FREE', x: 420, y: 240, shape: 'ROUND', area: 'Bar' },
];
const SAMPLE_RESERVATIONS: Reservation[] = [
{
id: 'r1', tableIds: ['t3'],
guest: { id: 'g1', firstName: 'Maria', lastName: 'Müller', phone: '+49 170 1234567', email: 'maria@example.com' },
date: '2026-08-19', startTime: '18:00', endTime: '19:30', guestCount: 4,
status: 'CONFIRMED', notes: 'Fensterplatz gewünscht', tags: ['Fensterplatz', 'Geburtstag'], createdAt: '2026-08-18T10:00:00Z',
},
{
id: 'r2', tableIds: ['t2'],
guest: { id: 'g2', firstName: 'Hans', lastName: 'Schmidt', phone: '+49 160 7654321' },
date: '2026-08-19', startTime: '19:00', endTime: '20:30', guestCount: 2,
status: 'ARRIVED', notes: '', tags: ['Allergie'], createdAt: '2026-08-18T11:00:00Z',
},
{
id: 'r3', tableIds: ['t5'],
guest: { id: 'g3', firstName: 'Anna', lastName: 'Weber', email: 'anna@example.com' },
date: '2026-08-19', startTime: '20:00', endTime: '22:00', guestCount: 6,
status: 'PENDING', notes: 'Hochstuhl benötigt', tags: ['Hochstuhl'], createdAt: '2026-08-18T12:00:00Z',
},
];
interface AppState {
tables: RestaurantTable[];
reservations: Reservation[];
selectedTableId: string | null;
selectedReservationId: string | null;
showBookingModal: boolean;
prefillTableId: string | null;
prefillTime: string | null;
prefillDate: string | null;
setTableStatus: (id: string, status: TableStatus) => void;
selectTable: (id: string | null) => void;
openBookingModal: (tableId?: string, time?: string, date?: string) => void;
closeBookingModal: () => void;
addReservation: (reservation: Reservation) => void;
updateReservationStatus: (id: string, status: ReservationStatus) => void;
selectReservation: (id: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
tables: SAMPLE_TABLES,
reservations: SAMPLE_RESERVATIONS,
selectedTableId: null,
selectedReservationId: null,
showBookingModal: false,
prefillTableId: null,
prefillTime: null,
prefillDate: null,
setTableStatus: (id, status) =>
set((s) => ({ tables: s.tables.map((t) => (t.id === id ? { ...t, status } : t)) })),
selectTable: (id) => set({ selectedTableId: id }),
openBookingModal: (tableId, time, date) =>
set({ showBookingModal: true, prefillTableId: tableId ?? null, prefillTime: time ?? null, prefillDate: date ?? null }),
closeBookingModal: () =>
set({ showBookingModal: false, prefillTableId: null, prefillTime: null, prefillDate: null }),
addReservation: (reservation) =>
set((s) => ({ reservations: [reservation, ...s.reservations] })),
updateReservationStatus: (id, status) =>
set((s) => ({ reservations: s.reservations.map((r) => (r.id === id ? { ...r, status } : r)) })),
selectReservation: (id) => set({ selectedReservationId: id }),
}));

View File

@@ -0,0 +1,39 @@
export type TableStatus = 'FREE' | 'OCCUPIED' | 'RESERVED' | 'BILL';
export type TableShape = 'RECT' | 'ROUND';
export type ReservationStatus = 'PENDING' | 'CONFIRMED' | 'ARRIVED' | 'FINISHED' | 'NO_SHOW' | 'CANCELLED';
export type ViewMode = 'floor' | 'timeline' | 'list';
export interface RestaurantTable {
id: string;
number: string;
seats: number;
minCapacity: number;
maxCapacity: number;
status: TableStatus;
x: number;
y: number;
shape: TableShape;
area: string;
}
export interface Guest {
id: string;
firstName: string;
lastName: string;
phone?: string;
email?: string;
}
export interface Reservation {
id: string;
guest: Guest;
tableIds: string[];
date: string;
startTime: string;
endTime: string;
guestCount: number;
status: ReservationStatus;
notes: string;
tags: string[];
createdAt: string;
}

View File

@@ -1,7 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
}) })