feat: public api 90min rule, security config, floorplan drag-and-drop edit mode
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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">→ {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] })),
|
||||
}));
|
||||
|
||||
4
pom.xml
4
pom.xml
@@ -30,6 +30,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
||||
31
src/main/java/com/caspos/config/SecurityConfig.java
Normal file
31
src/main/java/com/caspos/config/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user