feat(frontend): add full reservation workflow, timeline gantt, and booking modal
This commit is contained in:
1054
frontend/package-lock.json
generated
1054
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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="flex items-center gap-3">
|
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||||
<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">
|
{/* Brand */}
|
||||||
PL
|
<div className="flex items-center gap-3">
|
||||||
|
<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
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<p className="font-bold text-base leading-tight">Pizzeria Luigi</p>
|
||||||
|
<p className="text-xs text-slate-500 flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Live · {new Date().toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold tracking-tight">Pizzeria Luigi</h1>
|
{/* Stats */}
|
||||||
<p className="text-xs text-slate-400 flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-4 text-xs">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-900 rounded-xl border border-slate-800">
|
||||||
Tenant: pizzeria-luigi (Schema: tenant_pizzeria_luigi)
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
</p>
|
<span className="text-slate-400">Frei:</span>
|
||||||
|
<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">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span className="text-slate-400">Aktiv:</span>
|
||||||
|
<span className="font-bold text-amber-400">{activeCount}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Switcher */}
|
{/* Nav */}
|
||||||
<div className="flex bg-slate-800/80 p-1 rounded-xl border border-slate-700">
|
<nav className="flex bg-slate-900/80 p-1 rounded-xl border border-slate-800 gap-1">
|
||||||
<button
|
{NAV_ITEMS.map((item) => (
|
||||||
onClick={() => setView('floor')}
|
<button
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 ${
|
key={item.id}
|
||||||
view === 'floor' ? 'bg-amber-500 text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
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
|
||||||
<LayoutGrid className="w-4 h-4" /> 2D Tischplan
|
? 'bg-amber-500 text-slate-950 shadow'
|
||||||
</button>
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
<button
|
}`}
|
||||||
onClick={() => setView('timeline')}
|
>
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 ${
|
<span>{item.icon}</span>
|
||||||
view === 'timeline' ? 'bg-amber-500 text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
<span className="hidden sm:inline">{item.label}</span>
|
||||||
}`}
|
</button>
|
||||||
>
|
))}
|
||||||
<Clock className="w-4 h-4" /> Timeline (Gantt)
|
</nav>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* CTA */}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowQuickBook(true)}
|
onClick={() => openBookingModal()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-5 h-5" /> Schnellbuchung / Walk-In
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
278
frontend/src/components/BookingModal.tsx
Normal file
278
frontend/src/components/BookingModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/components/FloorPlan.tsx
Normal file
118
frontend/src/components/FloorPlan.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
frontend/src/components/ReservationList.tsx
Normal file
131
frontend/src/components/ReservationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/components/Timeline.tsx
Normal file
117
frontend/src/components/Timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
81
frontend/src/store/useAppStore.ts
Normal file
81
frontend/src/store/useAppStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
39
frontend/src/types/index.ts
Normal file
39
frontend/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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()],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user