From e942f3bee71cfd0d31df87e391b7f669037c0302 Mon Sep 17 00:00:00 2001 From: DanielS Date: Wed, 19 Aug 2026 00:48:23 +0200 Subject: [PATCH] feat: public api 90min rule, security config, floorplan drag-and-drop edit mode --- frontend/src/api/client.ts | 3 + frontend/src/components/BookingModal.tsx | 37 +--- frontend/src/components/FloorPlan.tsx | 203 ++++++++++++++---- frontend/src/store/useAppStore.ts | 8 + pom.xml | 4 + .../com/caspos/config/SecurityConfig.java | 31 +++ .../PublicReservationController.java | 29 +++ .../service/PublicReservationService.java | 85 ++++++++ 8 files changed, 332 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/caspos/config/SecurityConfig.java create mode 100644 src/main/java/com/caspos/controller/PublicReservationController.java create mode 100644 src/main/java/com/caspos/service/PublicReservationService.java diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 60e3617..57df072 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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), }; diff --git a/frontend/src/components/BookingModal.tsx b/frontend/src/components/BookingModal.tsx index ea0f6b1..e473c04 100644 --- a/frontend/src/components/BookingModal.tsx +++ b/frontend/src/components/BookingModal.tsx @@ -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([]); 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() { - {/* Duration */} -
- Aufenthaltsdauer -
- {DURATION_OPTIONS.map((d) => ( - setDuration(d.value)} - className="px-4 h-11 text-sm" - > - {d.label} - - ))} -
-

- Ende: {addMinutes(startTime, duration)} -

+ {/* 90-min Rule Info */} +
+ Die Tischreservierung gilt für 1,5 Stunden. Ende: {addMinutes(startTime, DURATION_MINUTES)}
@@ -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] && ( - → {eligibleTables[0].number} + → {eligibleTables[0].number} )} @@ -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
diff --git a/frontend/src/components/FloorPlan.tsx b/frontend/src/components/FloorPlan.tsx index 4d8f323..52df043 100644 --- a/frontend/src/components/FloorPlan.tsx +++ b/frontend/src/components/FloorPlan.tsx @@ -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 = { 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 = { 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>({}); + + 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 (
{/* Canvas */} -
+
{/* Grid */}
- {/* Area labels */} - {['Gastraum', 'Terrasse', 'Bar'].map((area, i) => ( -
- {area} -
- ))} - - {tables.map((t) => ( + {/* Edit-Mode bar */} +
- ))} + + {editMode && ( + + )} +
+ + {/* Edit mode hint */} + {editMode && ( +
+ + Tische verschieben via Drag & Drop + +
+ )} + + {/* Tables */} + {tables.map((t) => { + const pos = getPos(t); + const isSelected = selectedTableId === t.id; + const isDraggingThis = dragging?.id === t.id; + return ( +
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', + }} + > +
+ {t.number} + {t.maxCapacity} P. + + {STATUS_LABEL[t.status]} + + {editMode && ( + + {Math.round(pos.x)}, {Math.round(pos.y)} + + )} +
+
+ ); + })}
{/* Side Panel */} @@ -83,10 +205,10 @@ export default function FloorPlan({ tables }: Props) { ))}
- {/* Selected Table Control */} - {selected && ( -
-

Tisch {selected.number}

+ {/* Selected Table Detail Panel (normal mode only) */} + {!editMode && selected && ( +
+

Tisch {selected.number}

{selected.area} · max. {selected.maxCapacity} Pers.

@@ -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]} @@ -106,7 +226,12 @@ export default function FloorPlan({ tables }: Props) { diff --git a/frontend/src/store/useAppStore.ts b/frontend/src/store/useAppStore.ts index adfc858..0a8b803 100644 --- a/frontend/src/store/useAppStore.ts +++ b/frontend/src/store/useAppStore.ts @@ -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((set) => ({ @@ -78,4 +80,10 @@ export const useAppStore = create((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] })), })); diff --git a/pom.xml b/pom.xml index aa44d75..a5d25db 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-security + org.postgresql postgresql diff --git a/src/main/java/com/caspos/config/SecurityConfig.java b/src/main/java/com/caspos/config/SecurityConfig.java new file mode 100644 index 0000000..56a5ad9 --- /dev/null +++ b/src/main/java/com/caspos/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/caspos/controller/PublicReservationController.java b/src/main/java/com/caspos/controller/PublicReservationController.java new file mode 100644 index 0000000..acff55d --- /dev/null +++ b/src/main/java/com/caspos/controller/PublicReservationController.java @@ -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 createPublic(@RequestBody CreateReservationRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(publicReservationService.createPublic(request)); + } +} diff --git a/src/main/java/com/caspos/service/PublicReservationService.java b/src/main/java/com/caspos/service/PublicReservationService.java new file mode 100644 index 0000000..84510be --- /dev/null +++ b/src/main/java/com/caspos/service/PublicReservationService.java @@ -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 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() + ); + } +}