244 lines
9.2 KiB
TypeScript
244 lines
9.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { RestaurantTable, TableStatus } from '../types';
|
|
import { useAppStore } from '../store/useAppStore';
|
|
import { api } from '../api/client';
|
|
|
|
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-indigo-400 bg-indigo-400/15 text-indigo-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',
|
|
};
|
|
|
|
let _nextId = 100;
|
|
|
|
interface Props {
|
|
tables: RestaurantTable[];
|
|
}
|
|
|
|
export default function FloorPlan({ tables }: Props) {
|
|
const { selectedTableId, selectTable, setTableStatus, openBookingModal, updateTablePosition, addTable } = useAppStore();
|
|
const [editMode, setEditMode] = useState(false);
|
|
|
|
// Drag state
|
|
const [dragging, setDragging] = useState<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
|
|
|
|
const getPos = (t: RestaurantTable) => positions[t.id] ?? { x: t.x, y: t.y };
|
|
|
|
const handleMouseDown = (e: React.MouseEvent, t: RestaurantTable) => {
|
|
if (!editMode) return;
|
|
e.preventDefault();
|
|
const pos = getPos(t);
|
|
setDragging({ id: t.id, startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y });
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!dragging) return;
|
|
const dx = e.clientX - dragging.startX;
|
|
const dy = e.clientY - dragging.startY;
|
|
setPositions((prev) => ({
|
|
...prev,
|
|
[dragging.id]: {
|
|
x: Math.max(0, dragging.origX + dx),
|
|
y: Math.max(0, dragging.origY + dy),
|
|
},
|
|
}));
|
|
};
|
|
|
|
const handleMouseUp = async () => {
|
|
if (!dragging) return;
|
|
const pos = positions[dragging.id];
|
|
if (pos) {
|
|
updateTablePosition(dragging.id, pos.x, pos.y);
|
|
// Fire-and-forget PATCH to backend
|
|
try {
|
|
await api.updateTablePosition(dragging.id, pos.x, pos.y);
|
|
} catch { /* backend might be offline */ }
|
|
}
|
|
setDragging(null);
|
|
};
|
|
|
|
const handleTableClick = (t: RestaurantTable) => {
|
|
if (editMode) return; // no selection in edit mode
|
|
if (selectedTableId === t.id) selectTable(null);
|
|
else selectTable(t.id);
|
|
};
|
|
|
|
const handleAddTable = () => {
|
|
const newTable: RestaurantTable = {
|
|
id: `new_${_nextId++}`,
|
|
number: `T${_nextId}`,
|
|
seats: 4, minCapacity: 1, maxCapacity: 4,
|
|
status: 'FREE',
|
|
x: 80 + Math.random() * 200,
|
|
y: 80 + Math.random() * 200,
|
|
shape: 'RECT',
|
|
area: 'Gastraum',
|
|
};
|
|
addTable(newTable);
|
|
};
|
|
|
|
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-[480px] select-none"
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{/* 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',
|
|
}}
|
|
/>
|
|
|
|
{/* Edit-Mode bar */}
|
|
<div className="absolute top-3 left-3 z-20 flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setEditMode((v) => !v); selectTable(null); }}
|
|
style={{
|
|
padding: '5px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
|
border: '1px solid',
|
|
borderColor: editMode ? 'var(--color-accent)' : 'var(--color-border-2)',
|
|
background: editMode ? 'var(--color-accent-dim)' : 'var(--color-surface)',
|
|
color: editMode ? 'var(--color-accent-hover)' : 'var(--color-muted)',
|
|
cursor: 'pointer', transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
{editMode ? 'Edit-Modus aktiv' : 'Edit-Modus'}
|
|
</button>
|
|
|
|
{editMode && (
|
|
<button
|
|
onClick={handleAddTable}
|
|
style={{
|
|
padding: '5px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
|
border: '1px solid var(--color-accent)',
|
|
background: 'var(--color-accent)',
|
|
color: '#fff', cursor: 'pointer', transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
+ Neuer Tisch
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit mode hint */}
|
|
{editMode && (
|
|
<div className="absolute bottom-3 left-0 right-0 flex justify-center z-10">
|
|
<span style={{
|
|
fontSize: 11, color: 'var(--color-muted)', background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)', borderRadius: 6, padding: '3px 10px',
|
|
}}>
|
|
Tische verschieben via Drag & Drop
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tables */}
|
|
{tables.map((t) => {
|
|
const pos = getPos(t);
|
|
const isSelected = selectedTableId === t.id;
|
|
const isDraggingThis = dragging?.id === t.id;
|
|
return (
|
|
<div
|
|
key={t.id}
|
|
onMouseDown={(e) => handleMouseDown(e, t)}
|
|
onClick={() => handleTableClick(t)}
|
|
style={{
|
|
position: 'absolute',
|
|
left: pos.x,
|
|
top: pos.y,
|
|
width: 112,
|
|
height: 112,
|
|
cursor: editMode ? (isDraggingThis ? 'grabbing' : 'grab') : 'pointer',
|
|
zIndex: isDraggingThis ? 20 : isSelected ? 10 : 1,
|
|
transition: isDraggingThis ? 'none' : 'transform 0.15s, box-shadow 0.15s',
|
|
transform: isSelected ? 'scale(1.07)' : isDraggingThis ? 'scale(1.05)' : 'scale(1)',
|
|
boxShadow: isDraggingThis ? '0 12px 32px rgba(0,0,0,0.6)' : isSelected ? '0 0 0 3px rgba(99,102,241,0.4)' : 'none',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<div
|
|
className={`w-full h-full border-2 flex flex-col items-center justify-center gap-1
|
|
${t.shape === 'ROUND' ? 'rounded-full' : 'rounded-2xl'}
|
|
${STATUS_STYLES[t.status]}
|
|
backdrop-blur-sm`}
|
|
>
|
|
<span style={{ fontWeight: 700, fontSize: 18 }}>{t.number}</span>
|
|
<span style={{ fontSize: 11, opacity: 0.65 }}>{t.maxCapacity} P.</span>
|
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', opacity: 0.85 }}>
|
|
{STATUS_LABEL[t.status]}
|
|
</span>
|
|
{editMode && (
|
|
<span style={{ fontSize: 9, opacity: 0.4, marginTop: 2 }}>
|
|
{Math.round(pos.x)}, {Math.round(pos.y)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</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 Detail Panel (normal mode only) */}
|
|
{!editMode && selected && (
|
|
<div className="bg-slate-900/60 border border-indigo-500/30 rounded-2xl p-4 space-y-3">
|
|
<p className="text-sm font-bold text-indigo-300">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)}
|
|
style={{
|
|
width: '100%', padding: '8px 0', borderRadius: 10, border: 'none',
|
|
background: 'var(--color-accent)', color: '#fff',
|
|
fontWeight: 600, fontSize: 12, cursor: 'pointer',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
>
|
|
+ Neue Reservierung
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|