feat: public api 90min rule, security config, floorplan drag-and-drop edit mode

This commit is contained in:
DanielS
2026-08-19 00:48:23 +02:00
parent 22469f557c
commit e942f3bee7
8 changed files with 332 additions and 68 deletions

View File

@@ -17,4 +17,7 @@ export const api = {
updateStatus: (id: string, status: string) =>
http.patch(`/reservations/${id}/status`, { status }).then((r) => r.data),
updateTablePosition: (id: string, x: number, y: number) =>
http.patch(`/tables/${id}/position`, { posX: x, posY: y }).then((r) => r.data),
};

View File

@@ -10,12 +10,7 @@ const TIME_SLOTS = [
'20:00', '20:30', '21:00', '21:30', '22:00', '22:30',
];
const DURATION_OPTIONS = [
{ label: '1 Std.', value: 60 },
{ label: '1,5 Std.', value: 90 },
{ label: '2 Std.', value: 120 },
{ label: '3 Std.', value: 180 },
];
const DURATION_MINUTES = 90; // Business rule: always 90 minutes
const EXTRA_TAGS = [
{ id: 'Hochstuhl', label: 'Hochstuhl' },
@@ -50,7 +45,6 @@ export default function ReservationModal() {
const [customCount, setCustomCount] = useState('');
const [date, setDate] = useState(prefillDate ?? today());
const [startTime, setStartTime] = useState(prefillTime ?? '19:00');
const [duration, setDuration] = useState(90);
const [tags, setTags] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [firstName, setFirstName] = useState('');
@@ -72,7 +66,7 @@ export default function ReservationModal() {
e.preventDefault();
if (!firstName.trim()) return;
const endTime = addMinutes(startTime, duration);
const endTime = addMinutes(startTime, DURATION_MINUTES);
const reservation: Reservation = {
id: generateId(),
guest: {
@@ -191,24 +185,9 @@ export default function ReservationModal() {
</div>
</div>
{/* Duration */}
<div>
<FieldLabel>Aufenthaltsdauer</FieldLabel>
<div className="flex flex-wrap gap-2 mt-2">
{DURATION_OPTIONS.map((d) => (
<TouchButton
key={d.value}
active={duration === d.value}
onClick={() => setDuration(d.value)}
className="px-4 h-11 text-sm"
>
{d.label}
</TouchButton>
))}
</div>
<p className="text-xs text-slate-500 mt-1.5">
Ende: <span className="text-slate-300 font-mono">{addMinutes(startTime, duration)}</span>
</p>
{/* 90-min Rule Info */}
<div className="mt-2 px-3 py-2 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-xs text-indigo-300">
Die Tischreservierung gilt für <strong>1,5 Stunden</strong>. Ende: <span className="font-mono">{addMinutes(startTime, DURATION_MINUTES)}</span>
</div>
</section>
@@ -309,9 +288,9 @@ export default function ReservationModal() {
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
Automatisch
Automatisch
{tableId === 'auto' && eligibleTables[0] && (
<span className="ml-2 opacity-70"> {eligibleTables[0].number}</span>
<span className="ml-2 opacity-70">&#8594; {eligibleTables[0].number}</span>
)}
</button>
@@ -355,7 +334,7 @@ export default function ReservationModal() {
type="submit"
className="flex-[2] py-4 rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-slate-950 font-bold text-sm transition active:scale-[0.98] shadow-xl shadow-amber-500/20"
>
Reservierung bestätigen
Reservierung bestätigen
</button>
</div>
</form>

View File

@@ -1,10 +1,12 @@
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-amber-400 bg-amber-400/15 text-amber-300',
RESERVED: 'border-indigo-400 bg-indigo-400/15 text-indigo-300',
BILL: 'border-sky-400 bg-sky-400/15 text-sky-300',
};
@@ -12,15 +14,73 @@ 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 } = useAppStore();
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) => {
selectTable(t.id === selectedTableId ? null : t.id);
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);
@@ -28,7 +88,12 @@ export default function FloorPlan({ tables }: Props) {
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]">
<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"
@@ -38,36 +103,93 @@ export default function FloorPlan({ tables }: Props) {
}}
/>
{/* 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) => (
{/* Edit-Mode bar */}
<div className="absolute top-3 left-3 z-20 flex items-center gap-2">
<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`}
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',
}}
>
<span className="font-bold text-xl">{t.number}</span>
<span className="text-xs opacity-60">{t.maxCapacity} P.</span>
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-80">
{STATUS_LABEL[t.status]}
</span>
{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 */}
@@ -83,10 +205,10 @@ export default function FloorPlan({ tables }: Props) {
))}
</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>
{/* 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">
@@ -95,9 +217,7 @@ export default function FloorPlan({ tables }: Props) {
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'}`}
${selected.status === s ? STATUS_STYLES[s] + ' shadow-md' : 'border-slate-700 text-slate-500 hover:border-slate-500'}`}
>
{STATUS_LABEL[s]}
</button>
@@ -106,7 +226,12 @@ export default function FloorPlan({ tables }: Props) {
<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"
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>

View File

@@ -48,6 +48,8 @@ interface AppState {
addReservation: (reservation: Reservation) => void;
updateReservationStatus: (id: string, status: ReservationStatus) => void;
selectReservation: (id: string | null) => void;
updateTablePosition: (id: string, x: number, y: number) => void;
addTable: (table: RestaurantTable) => void;
}
export const useAppStore = create<AppState>((set) => ({
@@ -78,4 +80,10 @@ export const useAppStore = create<AppState>((set) => ({
set((s) => ({ reservations: s.reservations.map((r) => (r.id === id ? { ...r, status } : r)) })),
selectReservation: (id) => set({ selectedReservationId: id }),
updateTablePosition: (id, x, y) =>
set((s) => ({ tables: s.tables.map((t) => (t.id === id ? { ...t, x, y } : t)) })),
addTable: (table) =>
set((s) => ({ tables: [...s.tables, table] })),
}));