feat(frontend): add full reservation workflow, timeline gantt, and booking modal
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user