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) => updateStatus: (id: string, status: string) =>
http.patch(`/reservations/${id}/status`, { status }).then((r) => r.data), 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', '20:00', '20:30', '21:00', '21:30', '22:00', '22:30',
]; ];
const DURATION_OPTIONS = [ const DURATION_MINUTES = 90; // Business rule: always 90 minutes
{ label: '1 Std.', value: 60 },
{ label: '1,5 Std.', value: 90 },
{ label: '2 Std.', value: 120 },
{ label: '3 Std.', value: 180 },
];
const EXTRA_TAGS = [ const EXTRA_TAGS = [
{ id: 'Hochstuhl', label: 'Hochstuhl' }, { id: 'Hochstuhl', label: 'Hochstuhl' },
@@ -50,7 +45,6 @@ export default function ReservationModal() {
const [customCount, setCustomCount] = useState(''); const [customCount, setCustomCount] = useState('');
const [date, setDate] = useState(prefillDate ?? today()); const [date, setDate] = useState(prefillDate ?? today());
const [startTime, setStartTime] = useState(prefillTime ?? '19:00'); const [startTime, setStartTime] = useState(prefillTime ?? '19:00');
const [duration, setDuration] = useState(90);
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
@@ -72,7 +66,7 @@ export default function ReservationModal() {
e.preventDefault(); e.preventDefault();
if (!firstName.trim()) return; if (!firstName.trim()) return;
const endTime = addMinutes(startTime, duration); const endTime = addMinutes(startTime, DURATION_MINUTES);
const reservation: Reservation = { const reservation: Reservation = {
id: generateId(), id: generateId(),
guest: { guest: {
@@ -191,24 +185,9 @@ export default function ReservationModal() {
</div> </div>
</div> </div>
{/* Duration */} {/* 90-min Rule Info */}
<div> <div className="mt-2 px-3 py-2 rounded-xl bg-indigo-500/10 border border-indigo-500/20 text-xs text-indigo-300">
<FieldLabel>Aufenthaltsdauer</FieldLabel> Die Tischreservierung gilt für <strong>1,5 Stunden</strong>. Ende: <span className="font-mono">{addMinutes(startTime, DURATION_MINUTES)}</span>
<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>
</div> </div>
</section> </section>
@@ -309,9 +288,9 @@ export default function ReservationModal() {
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500' : 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`} }`}
> >
Automatisch Automatisch
{tableId === 'auto' && eligibleTables[0] && ( {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> </button>
@@ -355,7 +334,7 @@ export default function ReservationModal() {
type="submit" 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" 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> </button>
</div> </div>
</form> </form>

View File

@@ -1,10 +1,12 @@
import { useState } from 'react';
import { RestaurantTable, TableStatus } from '../types'; import { RestaurantTable, TableStatus } from '../types';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { api } from '../api/client';
const STATUS_STYLES: Record<TableStatus, string> = { const STATUS_STYLES: Record<TableStatus, string> = {
FREE: 'border-emerald-500 bg-emerald-500/15 text-emerald-300', FREE: 'border-emerald-500 bg-emerald-500/15 text-emerald-300',
OCCUPIED: 'border-rose-500 bg-rose-500/15 text-rose-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', 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', FREE: 'Frei', OCCUPIED: 'Belegt', RESERVED: 'Reserviert', BILL: 'Rechnung',
}; };
let _nextId = 100;
interface Props { interface Props {
tables: RestaurantTable[]; tables: RestaurantTable[];
} }
export default function FloorPlan({ tables }: Props) { 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) => { 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); const selected = tables.find((t) => t.id === selectedTableId);
@@ -28,7 +88,12 @@ export default function FloorPlan({ tables }: Props) {
return ( return (
<div className="flex gap-4 flex-col lg:flex-row h-full"> <div className="flex gap-4 flex-col lg:flex-row h-full">
{/* Canvas */} {/* 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 */} {/* Grid */}
<div <div
className="absolute inset-0 opacity-20" className="absolute inset-0 opacity-20"
@@ -38,36 +103,93 @@ export default function FloorPlan({ tables }: Props) {
}} }}
/> />
{/* Area labels */} {/* Edit-Mode bar */}
{['Gastraum', 'Terrasse', 'Bar'].map((area, i) => ( <div className="absolute top-3 left-3 z-20 flex items-center gap-2">
<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 <button
key={t.id} onClick={() => { setEditMode((v) => !v); selectTable(null); }}
onClick={() => handleTableClick(t)} style={{
style={{ left: t.x, top: t.y }} padding: '5px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600,
title={`${t.number}${STATUS_LABEL[t.status]}`} border: '1px solid',
className={`absolute w-28 h-28 border-2 transition-all duration-200 flex flex-col items-center justify-center gap-1 group borderColor: editMode ? 'var(--color-accent)' : 'var(--color-border-2)',
${t.shape === 'ROUND' ? 'rounded-full' : 'rounded-2xl'} background: editMode ? 'var(--color-accent-dim)' : 'var(--color-surface)',
${STATUS_STYLES[t.status]} color: editMode ? 'var(--color-accent-hover)' : 'var(--color-muted)',
${selectedTableId === t.id ? 'ring-4 ring-amber-400/50 scale-110 z-10' : 'hover:scale-105'} cursor: 'pointer', transition: 'all 0.15s',
cursor-pointer shadow-lg`} }}
> >
<span className="font-bold text-xl">{t.number}</span> {editMode ? 'Edit-Modus aktiv' : 'Edit-Modus'}
<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>
</button> </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> </div>
{/* Side Panel */} {/* Side Panel */}
@@ -83,10 +205,10 @@ export default function FloorPlan({ tables }: Props) {
))} ))}
</div> </div>
{/* Selected Table Control */} {/* Selected Table Detail Panel (normal mode only) */}
{selected && ( {!editMode && selected && (
<div className="bg-slate-900/60 border border-amber-500/30 rounded-2xl p-4 space-y-3"> <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-amber-400">Tisch {selected.number}</p> <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> <p className="text-xs text-slate-400">{selected.area} · max. {selected.maxCapacity} Pers.</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -95,9 +217,7 @@ export default function FloorPlan({ tables }: Props) {
key={s} key={s}
onClick={() => setTableStatus(selected.id, s)} onClick={() => setTableStatus(selected.id, s)}
className={`px-2 py-1.5 rounded-lg text-xs font-semibold border transition className={`px-2 py-1.5 rounded-lg text-xs font-semibold border transition
${selected.status === s ${selected.status === s ? STATUS_STYLES[s] + ' shadow-md' : 'border-slate-700 text-slate-500 hover:border-slate-500'}`}
? STATUS_STYLES[s] + ' shadow-md'
: 'border-slate-700 text-slate-500 hover:border-slate-500'}`}
> >
{STATUS_LABEL[s]} {STATUS_LABEL[s]}
</button> </button>
@@ -106,7 +226,12 @@ export default function FloorPlan({ tables }: Props) {
<button <button
onClick={() => openBookingModal(selected.id)} 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 + Neue Reservierung
</button> </button>

View File

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

View File

@@ -30,6 +30,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>

View File

@@ -0,0 +1,31 @@
package com.caspos.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
// Public reservation endpoints — no auth required
.requestMatchers("/api/v1/public/**").permitAll()
// Internal endpoints — authenticated (placeholder, extend with JWT later)
.requestMatchers("/api/v1/**").authenticated()
.anyRequest().denyAll()
)
// Disable default form login for REST API
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable);
return http.build();
}
}

View File

@@ -0,0 +1,29 @@
package com.caspos.controller;
import com.caspos.dto.CreateReservationRequest;
import com.caspos.dto.ReservationDto;
import com.caspos.service.PublicReservationService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/public/reservations")
@CrossOrigin(origins = "*")
public class PublicReservationController {
private final PublicReservationService publicReservationService;
public PublicReservationController(PublicReservationService publicReservationService) {
this.publicReservationService = publicReservationService;
}
/**
* POST /api/v1/public/reservations
* No auth required. endTime is always set to startTime + 90 min server-side.
*/
@PostMapping
public ResponseEntity<ReservationDto> createPublic(@RequestBody CreateReservationRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(publicReservationService.createPublic(request));
}
}

View File

@@ -0,0 +1,85 @@
package com.caspos.service;
import com.caspos.domain.Guest;
import com.caspos.domain.Reservation;
import com.caspos.domain.RestaurantTable;
import com.caspos.dto.CreateReservationRequest;
import com.caspos.dto.ReservationDto;
import com.caspos.repository.GuestRepository;
import com.caspos.repository.ReservationRepository;
import com.caspos.repository.TableRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Public reservation service.
* Business rule: endTime is ALWAYS set server-side to startTime + 90 minutes.
* Client-supplied endTime is silently ignored.
*/
@Service
@Transactional
public class PublicReservationService {
private static final int RESERVATION_DURATION_MINUTES = 90;
private final ReservationRepository reservationRepository;
private final GuestRepository guestRepository;
private final TableRepository tableRepository;
public PublicReservationService(
ReservationRepository reservationRepository,
GuestRepository guestRepository,
TableRepository tableRepository
) {
this.reservationRepository = reservationRepository;
this.guestRepository = guestRepository;
this.tableRepository = tableRepository;
}
public ReservationDto createPublic(CreateReservationRequest req) {
// ── 1. Create guest ──
Guest guest = new Guest();
guest.setFirstName(req.firstName());
guest.setLastName(req.lastName());
guest.setPhone(req.phone());
guest.setEmail(req.email());
guest = guestRepository.save(guest);
// ── 2. Resolve tables ──
List<RestaurantTable> tables = req.tableIds() == null
? List.of()
: tableRepository.findAllById(req.tableIds());
// ── 3. Enforce 90-min rule — ignore client endTime ──
OffsetDateTime startTime = req.startTime();
OffsetDateTime endTime = startTime.plusMinutes(RESERVATION_DURATION_MINUTES);
// ── 4. Persist ──
Reservation reservation = new Reservation();
reservation.setGuest(guest);
reservation.setTables(tables);
reservation.setStartTime(startTime);
reservation.setEndTime(endTime); // always 90 min from start
reservation.setGuestCount(req.guestCount());
reservation.setSpecialRequests(req.specialRequests());
reservation.setStatus(Reservation.Status.PENDING); // Public → starts as PENDING
Reservation saved = reservationRepository.save(reservation);
// ── 5. Map to DTO ──
var guestDto = new ReservationDto.GuestDto(
guest.getId(), guest.getFirstName(), guest.getLastName(),
guest.getPhone(), guest.getEmail()
);
return new ReservationDto(
saved.getId(), guestDto,
saved.getTables().stream().map(RestaurantTable::getId).toList(),
saved.getStartTime(), saved.getEndTime(),
saved.getGuestCount(), saved.getStatus().name(),
saved.getSpecialRequests(), saved.getCreatedAt().toString()
);
}
}