feat(api): add REST controllers, services, entities and 15-min timeline grid

This commit is contained in:
DanielS
2026-08-19 00:44:33 +02:00
parent 52b0728510
commit 22469f557c
16 changed files with 619 additions and 17 deletions

View File

@@ -0,0 +1,20 @@
import axios from 'axios';
import { Reservation } from '../types';
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1';
const http = axios.create({ baseURL: BASE });
export const api = {
getTables: () =>
http.get('/tables').then((r) => r.data),
getReservations: (date?: string) =>
http.get('/reservations', { params: { date } }).then((r) => r.data),
createReservation: (payload: Partial<Reservation>) =>
http.post('/reservations', payload).then((r) => r.data),
updateStatus: (id: string, status: string) =>
http.patch(`/reservations/${id}/status`, { status }).then((r) => r.data),
};

View File

@@ -1,7 +1,14 @@
import { useAppStore } from '../store/useAppStore';
import { Reservation, ReservationStatus } from '../types';
const HOURS = Array.from({ length: 14 }, (_, i) => `${String(i + 10).padStart(2, '0')}:00`);
// 15-min slots from 10:00 to 24:00
const SLOTS: string[] = [];
for (let h = 10; h < 24; h++) {
for (const m of [0, 15, 30, 45]) {
SLOTS.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
const HOUR_SLOTS = SLOTS.filter((s) => s.endsWith(':00'));
const STATUS_DOT: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-400',
@@ -29,7 +36,8 @@ function timeToMinutes(t: string) {
const TIMELINE_START = 10 * 60; // 10:00
const TIMELINE_END = 24 * 60; // 24:00
const TOTAL_MINUTES = TIMELINE_END - TIMELINE_START;
const COL_WIDTH = 72; // px per hour
const SLOT_WIDTH = 18; // px per 15-min slot
const HOUR_WIDTH = SLOT_WIDTH * 4; // 72px per hour
export default function Timeline() {
const { tables, reservations, openBookingModal } = useAppStore();
@@ -46,11 +54,12 @@ export default function Timeline() {
<div className="w-[120px] shrink-0 p-3 text-xs font-semibold text-slate-500 uppercase tracking-wider border-r border-slate-800">
Tisch
</div>
{HOURS.map((h) => (
{/* Hour labels */}
{HOUR_SLOTS.map((h) => (
<div
key={h}
className="text-center text-xs text-slate-500 border-r border-slate-800/50 py-3 font-mono"
style={{ width: COL_WIDTH }}
style={{ width: HOUR_WIDTH }}
>
{h}
</div>
@@ -72,15 +81,16 @@ export default function Timeline() {
</div>
{/* Timeline cells */}
<div className="relative flex flex-1" style={{ height: 64 }}>
{/* Click grid */}
{HOURS.map((_, i) => (
<div className="relative flex flex-1" style={{ height: 56 }}>
{/* 15-min clickable slots */}
{SLOTS.map((slot, i) => (
<button
key={i}
onClick={() => handleSlotClick(table.id, 10 + i)}
className="border-r border-slate-800/30 h-full hover:bg-amber-500/10 transition shrink-0 group-hover:border-slate-700/50"
style={{ width: COL_WIDTH }}
title={`Neue Reservierung: ${table.number} um ${HOURS[i]}`}
onClick={() => handleSlotClick(table.id, parseInt(slot.split(':')[0]))}
className={`border-r h-full hover:bg-indigo-500/10 transition shrink-0
${slot.endsWith(':00') ? 'border-slate-700/60' : 'border-slate-800/30'}`}
style={{ width: SLOT_WIDTH }}
title={`${table.number} · ${slot}`}
/>
))}
@@ -88,21 +98,19 @@ export default function Timeline() {
{tableReservations.map((res: Reservation) => {
const startMin = timeToMinutes(res.startTime) - TIMELINE_START;
const endMin = timeToMinutes(res.endTime) - TIMELINE_START;
const leftPct = (startMin / TOTAL_MINUTES) * 100;
const widthPct = ((endMin - startMin) / TOTAL_MINUTES) * 100;
const totalWidth = HOURS.length * COL_WIDTH;
const totalWidth = SLOTS.length * SLOT_WIDTH;
return (
<div
key={res.id}
title={`${res.guest.firstName} ${res.guest.lastName} (${res.guestCount}P) ${res.startTime}${res.endTime}`}
className={`absolute top-2 bottom-2 rounded-lg border px-2 flex items-center overflow-hidden text-xs font-semibold cursor-pointer hover:brightness-110 transition z-10 ${STATUS_BAR[res.status]}`}
className={`absolute top-1.5 bottom-1.5 rounded-md border px-2 flex items-center overflow-hidden text-xs font-semibold cursor-pointer hover:brightness-110 transition z-10 ${STATUS_BAR[res.status]}`}
style={{
left: `${(startMin / TOTAL_MINUTES) * totalWidth}px`,
width: `${((endMin - startMin) / TOTAL_MINUTES) * totalWidth - 4}px`,
width: `${((endMin - startMin) / TOTAL_MINUTES) * totalWidth - 2}px`,
}}
>
<span className={`w-2 h-2 rounded-full shrink-0 mr-1.5 ${STATUS_DOT[res.status]}`} />
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mr-1.5 ${STATUS_DOT[res.status]}`} />
<span className="truncate">{res.guest.firstName} {res.guest.lastName}</span>
</div>
);

View File

@@ -0,0 +1,62 @@
package com.caspos.controller;
import com.caspos.dto.CreateReservationRequest;
import com.caspos.dto.ReservationDto;
import com.caspos.service.ReservationService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/reservations")
@CrossOrigin(origins = {"http://localhost:5173", "http://localhost:3000"})
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(ReservationService reservationService) {
this.reservationService = reservationService;
}
/**
* GET /api/v1/reservations?date=YYYY-MM-DD
*/
@GetMapping
public ResponseEntity<List<ReservationDto>> getByDate(
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
) {
LocalDate target = date != null ? date : LocalDate.now();
return ResponseEntity.ok(reservationService.getByDate(target));
}
/**
* POST /api/v1/reservations
*/
@PostMapping
public ResponseEntity<ReservationDto> create(@RequestBody CreateReservationRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(reservationService.create(request));
}
/**
* PATCH /api/v1/reservations/{id}/status
* Body: { "status": "CONFIRMED" }
*/
@PatchMapping("/{id}/status")
public ResponseEntity<ReservationDto> updateStatus(
@PathVariable UUID id,
@RequestBody Map<String, String> body
) {
String status = body.get("status");
if (status == null || status.isBlank()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(reservationService.updateStatus(id, status));
}
}

View File

@@ -0,0 +1,29 @@
package com.caspos.controller;
import com.caspos.dto.TableDto;
import com.caspos.service.TableService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tables")
@CrossOrigin(origins = {"http://localhost:5173", "http://localhost:3000"})
public class TableController {
private final TableService tableService;
public TableController(TableService tableService) {
this.tableService = tableService;
}
/**
* GET /api/v1/tables
* Returns all tables ordered by area and table number.
*/
@GetMapping
public ResponseEntity<List<TableDto>> getAllTables() {
return ResponseEntity.ok(tableService.getAllTables());
}
}

View File

@@ -0,0 +1,31 @@
package com.caspos.domain;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "dining_areas")
public class DiningArea {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, length = 100)
private String name;
@Column(name = "is_active")
private boolean active = true;
@Column(name = "display_order")
private int displayOrder = 0;
public UUID getId() { return id; }
public String getName() { return name; }
public boolean isActive() { return active; }
public int getDisplayOrder() { return displayOrder; }
public void setId(UUID id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setActive(boolean active) { this.active = active; }
public void setDisplayOrder(int displayOrder) { this.displayOrder = displayOrder; }
}

View File

@@ -0,0 +1,53 @@
package com.caspos.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "guests")
public class Guest {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "first_name", length = 100)
private String firstName;
@Column(name = "last_name", length = 100)
private String lastName;
@Column(length = 50)
private String phone;
@Column(length = 255)
private String email;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "no_show_count")
private int noShowCount = 0;
@Column(name = "created_at")
private Instant createdAt = Instant.now();
public UUID getId() { return id; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getPhone() { return phone; }
public String getEmail() { return email; }
public String getNotes() { return notes; }
public int getNoShowCount() { return noShowCount; }
public Instant getCreatedAt() { return createdAt; }
public void setId(UUID id) { this.id = id; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public void setPhone(String phone) { this.phone = phone; }
public void setEmail(String email) { this.email = email; }
public void setNotes(String notes) { this.notes = notes; }
public void setNoShowCount(int noShowCount) { this.noShowCount = noShowCount; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,72 @@
package com.caspos.domain;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "reservations")
public class Reservation {
public enum Status {
PENDING, CONFIRMED, ARRIVED, FINISHED, NO_SHOW, CANCELLED
}
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "guest_id")
private Guest guest;
@ManyToMany
@JoinTable(
name = "reservation_table_assignments",
joinColumns = @JoinColumn(name = "reservation_id"),
inverseJoinColumns = @JoinColumn(name = "table_id")
)
private List<RestaurantTable> tables = new ArrayList<>();
@Column(name = "start_time", nullable = false)
private OffsetDateTime startTime;
@Column(name = "end_time", nullable = false)
private OffsetDateTime endTime;
@Column(name = "guest_count", nullable = false)
private int guestCount;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private Status status = Status.CONFIRMED;
@Column(name = "special_requests", columnDefinition = "TEXT")
private String specialRequests;
@Column(name = "created_at")
private Instant createdAt = Instant.now();
public UUID getId() { return id; }
public Guest getGuest() { return guest; }
public List<RestaurantTable> getTables() { return tables; }
public OffsetDateTime getStartTime() { return startTime; }
public OffsetDateTime getEndTime() { return endTime; }
public int getGuestCount() { return guestCount; }
public Status getStatus() { return status; }
public String getSpecialRequests() { return specialRequests; }
public Instant getCreatedAt() { return createdAt; }
public void setId(UUID id) { this.id = id; }
public void setGuest(Guest guest) { this.guest = guest; }
public void setTables(List<RestaurantTable> tables) { this.tables = tables; }
public void setStartTime(OffsetDateTime startTime) { this.startTime = startTime; }
public void setEndTime(OffsetDateTime endTime) { this.endTime = endTime; }
public void setGuestCount(int guestCount) { this.guestCount = guestCount; }
public void setStatus(Status status) { this.status = status; }
public void setSpecialRequests(String specialRequests) { this.specialRequests = specialRequests; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,68 @@
package com.caspos.domain;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "restaurant_tables")
public class RestaurantTable {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "dining_area_id", nullable = false)
private DiningArea diningArea;
@Column(name = "table_number", nullable = false, length = 20)
private String tableNumber;
@Column(name = "min_capacity", nullable = false)
private int minCapacity = 1;
@Column(name = "max_capacity", nullable = false)
private int maxCapacity;
@Column(name = "is_combinable")
private boolean combinable = true;
@Column(name = "pos_x")
private Double posX;
@Column(name = "pos_y")
private Double posY;
@Column(name = "width")
private Double width;
@Column(name = "height")
private Double height;
@Column(name = "shape", length = 20)
private String shape;
public UUID getId() { return id; }
public DiningArea getDiningArea() { return diningArea; }
public String getTableNumber() { return tableNumber; }
public int getMinCapacity() { return minCapacity; }
public int getMaxCapacity() { return maxCapacity; }
public boolean isCombinable() { return combinable; }
public Double getPosX() { return posX; }
public Double getPosY() { return posY; }
public Double getWidth() { return width; }
public Double getHeight() { return height; }
public String getShape() { return shape; }
public void setId(UUID id) { this.id = id; }
public void setDiningArea(DiningArea diningArea) { this.diningArea = diningArea; }
public void setTableNumber(String tableNumber) { this.tableNumber = tableNumber; }
public void setMinCapacity(int minCapacity) { this.minCapacity = minCapacity; }
public void setMaxCapacity(int maxCapacity) { this.maxCapacity = maxCapacity; }
public void setCombinable(boolean combinable) { this.combinable = combinable; }
public void setPosX(Double posX) { this.posX = posX; }
public void setPosY(Double posY) { this.posY = posY; }
public void setWidth(Double width) { this.width = width; }
public void setHeight(Double height) { this.height = height; }
public void setShape(String shape) { this.shape = shape; }
}

View File

@@ -0,0 +1,18 @@
package com.caspos.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public record CreateReservationRequest(
String firstName,
String lastName,
String phone,
String email,
List<UUID> tableIds,
OffsetDateTime startTime,
OffsetDateTime endTime,
int guestCount,
String specialRequests,
List<String> tags
) {}

View File

@@ -0,0 +1,25 @@
package com.caspos.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public record ReservationDto(
UUID id,
GuestDto guest,
List<UUID> tableIds,
OffsetDateTime startTime,
OffsetDateTime endTime,
int guestCount,
String status,
String specialRequests,
String createdAt
) {
public record GuestDto(
UUID id,
String firstName,
String lastName,
String phone,
String email
) {}
}

View File

@@ -0,0 +1,17 @@
package com.caspos.dto;
import java.util.UUID;
public record TableDto(
UUID id,
String tableNumber,
String area,
int minCapacity,
int maxCapacity,
boolean combinable,
Double posX,
Double posY,
Double width,
Double height,
String shape
) {}

View File

@@ -0,0 +1,11 @@
package com.caspos.repository;
import com.caspos.domain.Guest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface GuestRepository extends JpaRepository<Guest, UUID> {
}

View File

@@ -0,0 +1,30 @@
package com.caspos.repository;
import com.caspos.domain.Reservation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Repository
public interface ReservationRepository extends JpaRepository<Reservation, UUID> {
@Query("""
SELECT r FROM Reservation r
WHERE CAST(r.startTime AS date) = :date
ORDER BY r.startTime ASC
""")
List<Reservation> findByDate(@Param("date") LocalDate date);
@Query("""
SELECT r FROM Reservation r
WHERE r.startTime < :end AND r.endTime > :start
AND r.status NOT IN ('CANCELLED', 'NO_SHOW')
""")
List<Reservation> findOverlapping(@Param("start") OffsetDateTime start, @Param("end") OffsetDateTime end);
}

View File

@@ -0,0 +1,14 @@
package com.caspos.repository;
import com.caspos.domain.RestaurantTable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface TableRepository extends JpaRepository<RestaurantTable, UUID> {
List<RestaurantTable> findByDiningAreaId(UUID diningAreaId);
List<RestaurantTable> findAllByOrderByDiningAreaDisplayOrderAscTableNumberAsc();
}

View File

@@ -0,0 +1,101 @@
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.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
public class ReservationService {
private final ReservationRepository reservationRepository;
private final GuestRepository guestRepository;
private final TableRepository tableRepository;
public ReservationService(
ReservationRepository reservationRepository,
GuestRepository guestRepository,
TableRepository tableRepository
) {
this.reservationRepository = reservationRepository;
this.guestRepository = guestRepository;
this.tableRepository = tableRepository;
}
@Transactional(readOnly = true)
public List<ReservationDto> getByDate(LocalDate date) {
return reservationRepository.findByDate(date).stream().map(this::toDto).toList();
}
public ReservationDto create(CreateReservationRequest req) {
Guest guest = new Guest();
guest.setFirstName(req.firstName());
guest.setLastName(req.lastName());
guest.setPhone(req.phone());
guest.setEmail(req.email());
guest = guestRepository.save(guest);
List<RestaurantTable> tables = req.tableIds() == null ? List.of()
: tableRepository.findAllById(req.tableIds());
Reservation reservation = new Reservation();
reservation.setGuest(guest);
reservation.setTables(tables);
reservation.setStartTime(req.startTime());
reservation.setEndTime(req.endTime());
reservation.setGuestCount(req.guestCount());
reservation.setSpecialRequests(req.specialRequests());
reservation.setStatus(Reservation.Status.CONFIRMED);
return toDto(reservationRepository.save(reservation));
}
public ReservationDto updateStatus(UUID id, String rawStatus) {
Reservation reservation = reservationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Reservation not found: " + id));
Reservation.Status newStatus;
try {
newStatus = Reservation.Status.valueOf(rawStatus.toUpperCase());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid status: " + rawStatus);
}
reservation.setStatus(newStatus);
return toDto(reservationRepository.save(reservation));
}
private ReservationDto toDto(Reservation r) {
var guestDto = r.getGuest() == null ? null : new ReservationDto.GuestDto(
r.getGuest().getId(),
r.getGuest().getFirstName(),
r.getGuest().getLastName(),
r.getGuest().getPhone(),
r.getGuest().getEmail()
);
return new ReservationDto(
r.getId(),
guestDto,
r.getTables().stream().map(RestaurantTable::getId).toList(),
r.getStartTime(),
r.getEndTime(),
r.getGuestCount(),
r.getStatus().name(),
r.getSpecialRequests(),
r.getCreatedAt().toString()
);
}
}

View File

@@ -0,0 +1,43 @@
package com.caspos.service;
import com.caspos.domain.RestaurantTable;
import com.caspos.dto.TableDto;
import com.caspos.repository.TableRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class TableService {
private final TableRepository tableRepository;
public TableService(TableRepository tableRepository) {
this.tableRepository = tableRepository;
}
public List<TableDto> getAllTables() {
return tableRepository.findAllByOrderByDiningAreaDisplayOrderAscTableNumberAsc()
.stream()
.map(this::toDto)
.toList();
}
private TableDto toDto(RestaurantTable t) {
return new TableDto(
t.getId(),
t.getTableNumber(),
t.getDiningArea() != null ? t.getDiningArea().getName() : null,
t.getMinCapacity(),
t.getMaxCapacity(),
t.isCombinable(),
t.getPosX(),
t.getPosY(),
t.getWidth(),
t.getHeight(),
t.getShape()
);
}
}