Compare commits

..

10 Commits

48 changed files with 6545 additions and 293 deletions

View File

@@ -1,227 +0,0 @@
# LicServer API Spezifikation (v1)
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers.
## 1. Endpunkte (Port 9981 - Activation API)
### Activation (Aktivierung)
#### `POST /v1/activate`
Aktiviert eine neue Lizenz.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"activationCode": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
```json
{
"licenseData": {
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
},
"apiKey": "string",
"secret": "string"
}
```
**Response Headers:**
- `ETag` (string): Der ETag-Wert der Lizenz.
---
#### `POST /v1/activate-existing`
Aktiviert eine bereits vorhandene Lizenz erneut.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"serialNumber": "string",
"proofOfPossessionHash": "string",
"salt": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
*Gleiche Struktur wie bei `/v1/activate`.*
---
### Licenses (Lizenzen)
#### `GET /v1/licenses/{id}`
Holt die Lizenz-Details zu einer bestimmten Lizenz-ID.
**Request Parameters:**
- `id` (path, string, required): Die UUID der Lizenz.
- `If-None-Match` (header, string, optional): ETag zur Cache-Validierung.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
**Response `304 Not Modified`:**
Falls die Lizenz nicht verändert wurde.
**Response `404 Not Found` / `403 Forbidden`:**
Standard Fehlerobjekt `ProblemDetails`.
---
## 2. Endpunkte (Port 9980 - Management API)
Alle Anfragen an die Management-API müssen authentifiziert sein.
- **Header-Format:** `X-Api-Key: <Schlüssel>`
### Partners (Partner)
#### `GET /api-v1/partners`
Gibt eine paginierte Liste aller Partner zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{id}`
Holt Partner-Details zu einer bestimmten ID.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
```
### Customers (Kunden von Partnern)
#### `GET /api-v1/partners/{partnerId}/customers`
Gibt eine paginierte Liste aller Kunden eines bestimmten Partners zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{partnerId}/customers/{customerId}`
Holt detaillierte Kundeninformationen.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string",
"partnerName": "string",
"taxNumber": "string",
"vatIdNumber": "string",
"notes": "string",
"locations": []
}
```
---
## 3. Typen & Schemas
### `LicenseData`
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
### `ProblemDetails`
```json
{
"type": "string",
"title": "string",
"status": 0,
"detail": "string",
"instance": "string"
}
```

View File

@@ -1,64 +0,0 @@
# Supabase Schema
## `companies` (Unternehmen)
- Repräsentiert die Partner-Unternehmen (Retailer).
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `street`, `zip`, `city`, `email` (Adressdaten)
## `users` (Systembenutzer)
- Erweitert die Authentifizierungsdaten aus `auth.users`.
- `id` (UUID, References `auth.users(id)`)
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
- `company_id` (UUID, References `public.companies(id)`)
## `profiles` (Benutzerprofile)
- Stammdaten der einzelnen Benutzer.
- `id` (UUID, References `auth.users(id)`)
- `first_name`, `last_name`, `email` etc.
## `end_customers` (Endkunden)
- `id` (UUID, Primary Key)
- `partner_id` (UUID, References `public.companies(id)`)
- `company_name` (TEXT)
- `first_name`, `last_name`, `street`, `zip`, `city`, `email`
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner`
- `is_anonymized` (BOOLEAN)
## `products` (Katalogprodukte)
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
- `requirements` (UUID[])
- `exclusions` (UUID[])
## `product_modules` (Zusatzmodule)
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
- `requirements` (UUID[])
- `exclusions` (UUID[])
## `orders` (Bestellungen / Anfragen)
- `id` (UUID, Primary Key)
- `user_id` (UUID, References `auth.users(id)`)
- `order_number` (TEXT)
- `order_hash` (TEXT)
- `end_customer_id` (UUID, References `end_customers(id)`)
- `total_price` (DECIMAL)
- `customer_data` (JSONB)
- `order_data` (JSONB)
- `pdf_url` (TEXT)
- `status` (TEXT)
## `categories` (Kategorien)
- Steuert das Layout und Verhalten im Wizard.
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `is_required` (BOOLEAN)
- `allow_multiselect` (BOOLEAN)
- `sort_order` (INTEGER)
- `show_in_branches` (BOOLEAN)
## `settings` (Systemeinstellungen)
- Globale Shopeinstellungen (SMTP & Lizenzserver).
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
- `host`, `port`, `secure`, `user`, `pass` (SMTP-Konfiguration)
- `licserver_base_url`, `licserver_api_key` (CASPOS Lizenzserver-Einstellungen)
- `updated_at` (TIMESTAMPTZ)

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM eclipse-temurin:21-jdk-alpine as build
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests || true
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -1,6 +1,5 @@
version: '3.8'
services:
# 1. PostgreSQL 16 Datenbank (Hybrid Multi-Tenant Storage)
postgres:
image: postgres:16-alpine

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM node:20-alpine
WORKDIR /app
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

50
frontend/README.md Normal file
View File

@@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4259
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "temp_frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.19.0",
"konva": "^10.3.1",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-konva": "^19.2.5",
"tailwindcss": "^4.3.3",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
frontend/src/App.css Normal file
View File

@@ -0,0 +1 @@
/* cleared */

127
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useAppStore } from './store/useAppStore';
import FloorPlan from './components/FloorPlan';
import Timeline from './components/Timeline';
import ReservationList from './components/ReservationList';
import ReservationModal from './components/BookingModal';
import { ViewMode } from './types';
const NAV = [
{ id: 'floor' as ViewMode, label: 'Tischplan' },
{ id: 'timeline' as ViewMode, label: 'Timeline' },
{ id: 'list' as ViewMode, label: 'Reservierungen' },
];
export default function App() {
const [view, setView] = useState<ViewMode>('floor');
const { tables, reservations, showBookingModal, openBookingModal } = useAppStore();
const freeCount = tables.filter((t) => t.status === 'FREE').length;
const activeCount = reservations.filter((r) => ['PENDING', 'CONFIRMED', 'ARRIVED'].includes(r.status)).length;
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: 'var(--color-bg)' }}>
{/* ── Header ── */}
<header style={{
position: 'sticky', top: 0, zIndex: 50,
background: 'rgba(9,9,13,0.92)',
backdropFilter: 'blur(16px)',
borderBottom: '1px solid var(--color-border)',
padding: '0 1.5rem',
height: 56,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
}}>
{/* Brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--color-accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700, color: '#fff',
letterSpacing: '-0.5px',
boxShadow: '0 0 20px var(--color-accent-glow)',
}}>PL</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--color-text)', letterSpacing: '-0.2px' }}>
Pizzeria Luigi
</div>
<div style={{ fontSize: 11, color: 'var(--color-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--color-success)', display: 'inline-block' }} />
{new Date().toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'short' })}
</div>
</div>
</div>
{/* Stats */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Chip color="success" label="Frei" value={freeCount} />
<Chip color="accent" label="Aktiv" value={activeCount} />
</div>
{/* Nav */}
<nav style={{
display: 'flex', background: 'var(--color-surface)', borderRadius: 10,
border: '1px solid var(--color-border)', padding: 3, gap: 2,
}}>
{NAV.map((n) => (
<button
key={n.id}
onClick={() => setView(n.id)}
style={{
padding: '5px 14px', borderRadius: 7, fontSize: 12, fontWeight: 600, cursor: 'pointer',
border: 'none', transition: 'all 0.15s',
background: view === n.id ? 'var(--color-accent)' : 'transparent',
color: view === n.id ? '#fff' : 'var(--color-muted)',
boxShadow: view === n.id ? '0 2px 12px var(--color-accent-glow)' : 'none',
}}
>
{n.label}
</button>
))}
</nav>
{/* CTA */}
<button
onClick={() => openBookingModal()}
style={{
background: 'var(--color-accent)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer',
padding: '7px 16px', fontSize: 12, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 6,
boxShadow: '0 2px 16px var(--color-accent-glow)',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
Walk-In
</button>
</header>
{/* ── Content ── */}
<main style={{ flex: 1, maxWidth: 1280, width: '100%', margin: '0 auto', padding: '1.5rem' }}>
{view === 'floor' && <FloorPlan tables={tables} />}
{view === 'timeline' && <Timeline />}
{view === 'list' && <ReservationList />}
</main>
{showBookingModal && <ReservationModal />}
</div>
);
}
// ─── Shared atoms ────────────────────────────────────────────────────────────
function Chip({ label, value, color }: { label: string; value: number; color: 'success' | 'accent' }) {
const c = color === 'success' ? 'var(--color-success)' : 'var(--color-accent)';
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'var(--color-surface)', borderRadius: 8,
border: '1px solid var(--color-border)', padding: '4px 10px', fontSize: 11,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: c }} />
<span style={{ color: 'var(--color-muted)' }}>{label}</span>
<span style={{ fontWeight: 700, color: c }}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,23 @@
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),
updateTablePosition: (id: string, x: number, y: number) =>
http.patch(`/tables/${id}/position`, { posX: x, posY: y }).then((r) => r.data),
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,396 @@
import { useState } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Reservation } from '../types';
// ─── Config ────────────────────────────────────────────────────────────────
const PERSON_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const TIME_SLOTS = [
'17:00', '17:30', '18:00', '18:30', '19:00', '19:30',
'20:00', '20:30', '21:00', '21:30', '22:00', '22:30',
];
const DURATION_MINUTES = 90; // Business rule: always 90 minutes
const EXTRA_TAGS = [
{ id: 'Hochstuhl', label: 'Hochstuhl' },
{ id: 'Geburtstag', label: 'Geburtstag' },
{ id: 'Fensterplatz', label: 'Fensterplatz' },
{ id: 'Hund', label: 'Hund' },
{ id: 'Terrasse', label: 'Terrasse / Außen' },
{ id: 'Allergie', label: 'Allergie' },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function generateId() {
return Math.random().toString(36).slice(2, 10);
}
function addMinutes(time: string, minutes: number): string {
const [h, m] = time.split(':').map(Number);
const total = h * 60 + m + minutes;
return `${String(Math.floor(total / 60) % 24).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
// ─── Component ──────────────────────────────────────────────────────────────
export default function ReservationModal() {
const { tables, prefillTableId, prefillTime, prefillDate, closeBookingModal, addReservation, setTableStatus } = useAppStore();
// ── State ──
const [guestCount, setGuestCount] = useState(2);
const [customCount, setCustomCount] = useState('');
const [date, setDate] = useState(prefillDate ?? today());
const [startTime, setStartTime] = useState(prefillTime ?? '19:00');
const [tags, setTags] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [tableId, setTableId] = useState<string | 'auto'>(prefillTableId ?? 'auto');
// ── Derived ──
const effectiveCount = customCount ? parseInt(customCount) : guestCount;
const eligibleTables = tables.filter((t) => t.maxCapacity >= effectiveCount && t.minCapacity <= effectiveCount && t.status === 'FREE');
const resolvedTableId = tableId === 'auto' ? (eligibleTables[0]?.id ?? tables[0]?.id) : tableId;
// ── Handlers ──
const toggleTag = (id: string) =>
setTags((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!firstName.trim()) return;
const endTime = addMinutes(startTime, DURATION_MINUTES);
const reservation: Reservation = {
id: generateId(),
guest: {
id: generateId(),
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim() || undefined,
email: email.trim() || undefined,
},
tableIds: resolvedTableId ? [resolvedTableId] : [],
date,
startTime,
endTime,
guestCount: effectiveCount,
status: 'CONFIRMED',
notes: notes.trim(),
tags,
createdAt: new Date().toISOString(),
};
addReservation(reservation);
if (resolvedTableId) setTableStatus(resolvedTableId, 'RESERVED');
closeBookingModal();
};
return (
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md flex items-center justify-center z-50 p-3 overflow-y-auto">
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl shadow-black/60 my-4">
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800">
<div>
<h2 className="text-xl font-bold text-white">Neue Reservierung</h2>
<p className="text-xs text-slate-400 mt-0.5">Schnellbuchung · Walk-In</p>
</div>
<button
onClick={closeBookingModal}
className="w-9 h-9 rounded-full bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-slate-400 hover:text-white transition text-sm"
>
</button>
</div>
<form onSubmit={handleSave}>
<div className="px-6 py-5 space-y-6">
{/* ── Section 1: Eckdaten ── */}
<section className="space-y-4">
<SectionTitle number="1" label="Eckdaten" />
{/* Person Count */}
<div>
<FieldLabel>Personenanzahl</FieldLabel>
<div className="flex flex-wrap gap-2 mt-2">
{PERSON_COUNTS.map((n) => (
<TouchButton
key={n}
active={guestCount === n && !customCount}
onClick={() => { setGuestCount(n); setCustomCount(''); }}
className="w-12 h-12 text-base"
>
{n}
</TouchButton>
))}
{/* 10+ */}
<div className={`w-16 h-12 rounded-2xl border-2 flex items-center overflow-hidden transition ${
customCount ? 'border-amber-500 bg-amber-500/10' : 'border-slate-700 bg-slate-800/60'
}`}>
<input
type="number"
min={11}
placeholder="10+"
value={customCount}
onChange={(e) => { setCustomCount(e.target.value); setGuestCount(0); }}
className="w-full h-full text-center text-sm font-bold bg-transparent text-slate-100 focus:outline-none placeholder-slate-600"
/>
</div>
</div>
{eligibleTables.length > 0 && (
<p className="text-xs text-emerald-400 mt-2">
{eligibleTables.length} freier Tisch{eligibleTables.length !== 1 ? 'e' : ''} verfügbar
</p>
)}
{eligibleTables.length === 0 && effectiveCount > 0 && (
<p className="text-xs text-rose-400 mt-2">
Kein freier Tisch für {effectiveCount} Personen
</p>
)}
</div>
{/* Date */}
<div>
<FieldLabel>Datum</FieldLabel>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="mt-2 bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition w-full sm:w-56"
/>
</div>
{/* Time Slots */}
<div>
<FieldLabel>Uhrzeit</FieldLabel>
<div className="flex flex-wrap gap-2 mt-2">
{TIME_SLOTS.map((t) => (
<TouchButton
key={t}
active={startTime === t}
onClick={() => setStartTime(t)}
className="px-4 h-11 text-sm font-mono"
>
{t}
</TouchButton>
))}
</div>
</div>
{/* 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>
<Divider />
{/* ── Section 2: Extras & Tags ── */}
<section className="space-y-4">
<SectionTitle number="2" label="Extras & Gastwünsche" />
<div className="flex flex-wrap gap-2">
{EXTRA_TAGS.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition select-none ${
tags.includes(tag.id)
? 'bg-purple-500/25 border-purple-400/70 text-purple-200 shadow-md shadow-purple-500/10'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-300'
}`}
>
<span>{tag.label}</span>
</button>
))}
</div>
<div>
<FieldLabel>Sonstige Wünsche / Notizen</FieldLabel>
<textarea
rows={2}
placeholder="Freitext für besondere Hinweise..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2 w-full bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition resize-none placeholder-slate-600"
/>
</div>
</section>
<Divider />
{/* ── Section 3: Gastdaten ── */}
<section className="space-y-4">
<SectionTitle number="3" label="Gastdaten" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<FieldLabel>Vorname <span className="text-rose-400">*</span></FieldLabel>
<TextInput
required
placeholder="z. B. Maria"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<FieldLabel>Nachname</FieldLabel>
<TextInput
placeholder="z. B. Müller"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
<div>
<FieldLabel>Telefonnummer</FieldLabel>
<TextInput
type="tel"
placeholder="+49 170 ..."
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div>
<FieldLabel>E-Mail <span className="text-slate-600">(optional)</span></FieldLabel>
<TextInput
type="email"
placeholder="gast@email.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
</section>
<Divider />
{/* ── Section 4: Tisch ── */}
<section className="space-y-3">
<SectionTitle number="4" label="Tisch-Zuweisung" />
<div className="flex flex-wrap gap-2">
{/* Auto */}
<button
type="button"
onClick={() => setTableId('auto')}
className={`px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition ${
tableId === 'auto'
? 'bg-amber-500/20 border-amber-400/70 text-amber-300'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
Automatisch
{tableId === 'auto' && eligibleTables[0] && (
<span className="ml-2 opacity-70">&#8594; {eligibleTables[0].number}</span>
)}
</button>
{/* Manual */}
{tables.map((t) => {
const isFree = t.status === 'FREE';
const fits = t.maxCapacity >= effectiveCount;
return (
<button
key={t.id}
type="button"
onClick={() => setTableId(t.id)}
className={`px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition ${
tableId === t.id
? 'bg-amber-500 border-amber-400 text-slate-950'
: fits && isFree
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/25'
: 'bg-slate-800/40 border-slate-800 text-slate-600 hover:border-slate-700'
}`}
>
{t.number}
<span className="ml-1 font-normal opacity-70">({t.maxCapacity}P)</span>
{!isFree && <span className="ml-1 text-rose-400 text-xs"></span>}
</button>
);
})}
</div>
</section>
</div>
{/* ── Footer ── */}
<div className="px-6 pb-6 flex gap-3">
<button
type="button"
onClick={closeBookingModal}
className="flex-1 py-4 rounded-2xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-sm transition"
>
Abbrechen
</button>
<button
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
</button>
</div>
</form>
</div>
</div>
);
}
// ─── Sub-components ─────────────────────────────────────────────────────────
function SectionTitle({ number, label }: { number: string; label: string }) {
return (
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 text-xs font-bold flex items-center justify-center">
{number}
</span>
<span className="text-sm font-bold text-slate-200 uppercase tracking-wider">{label}</span>
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-xs font-semibold text-slate-500 mt-0.5">{children}</label>;
}
function Divider() {
return <div className="border-t border-slate-800/80" />;
}
function TouchButton({
active, onClick, children, className = '',
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-2xl border-2 font-bold transition flex items-center justify-center select-none ${className} ${
active
? 'bg-amber-500 border-amber-400 text-slate-950 shadow-md shadow-amber-500/20'
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/40 hover:text-amber-300'
}`}
>
{children}
</button>
);
}
function TextInput({ className = '', ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={`mt-1.5 w-full bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600 ${className}`}
/>
);
}

View File

@@ -0,0 +1,243 @@
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-indigo-400 bg-indigo-400/15 text-indigo-300',
BILL: 'border-sky-400 bg-sky-400/15 text-sky-300',
};
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, 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) => {
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);
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-[480px] select-none"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Grid */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: 'linear-gradient(to right, #334155 1px, transparent 1px), linear-gradient(to bottom, #334155 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
{/* Edit-Mode bar */}
<div className="absolute top-3 left-3 z-20 flex items-center gap-2">
<button
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',
}}
>
{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 */}
<div className="w-full lg:w-64 flex flex-col gap-3">
{/* Legend */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-4 space-y-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Legende</p>
{(Object.entries(STATUS_LABEL) as [TableStatus, string][]).map(([s, label]) => (
<div key={s} className="flex items-center gap-2 text-sm">
<span className={`w-3 h-3 rounded border ${STATUS_STYLES[s]}`} />
<span className="text-slate-300">{label}</span>
</div>
))}
</div>
{/* 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">
{(['FREE', 'OCCUPIED', 'RESERVED', 'BILL'] as TableStatus[]).map((s) => (
<button
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'}`}
>
{STATUS_LABEL[s]}
</button>
))}
</div>
<button
onClick={() => openBookingModal(selected.id)}
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>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useAppStore } from '../store/useAppStore';
import { Reservation, ReservationStatus } from '../types';
const STATUS_ORDER: ReservationStatus[] = ['PENDING', 'CONFIRMED', 'ARRIVED', 'FINISHED', 'NO_SHOW', 'CANCELLED'];
const STATUS_STYLE: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-700/50 text-slate-300 border-slate-600',
CONFIRMED: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
ARRIVED: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
FINISHED: 'bg-slate-800/60 text-slate-500 border-slate-700',
NO_SHOW: 'bg-rose-500/20 text-rose-300 border-rose-500/40',
CANCELLED: 'bg-rose-900/30 text-rose-400/60 border-rose-800/40',
};
const STATUS_LABEL: Record<ReservationStatus, string> = {
PENDING: 'Ausstehend',
CONFIRMED: 'Bestätigt',
ARRIVED: 'Angekommen',
FINISHED: 'Abgeschlossen',
NO_SHOW: 'No-Show',
CANCELLED: 'Storniert',
};
const NEXT_STATUS: Partial<Record<ReservationStatus, ReservationStatus>> = {
PENDING: 'CONFIRMED',
CONFIRMED: 'ARRIVED',
ARRIVED: 'FINISHED',
};
const TAG_LABELS: Record<string, string> = {
Hochstuhl: 'Hochstuhl',
Allergie: 'Allergie',
Fensterplatz: 'Fensterplatz',
Geburtstag: 'Geburtstag',
Hund: 'Hund',
Terrasse: 'Terrasse',
};
export default function ReservationList() {
const { reservations, updateReservationStatus, tables, openBookingModal } = useAppStore();
const active = reservations.filter((r) => !['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status));
const past = reservations.filter((r) => ['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status));
const getTableNames = (ids: string[]) =>
ids.map((id) => tables.find((t) => t.id === id)?.number ?? id).join(', ');
const Card = ({ r }: { r: Reservation }) => (
<div className={`p-4 rounded-2xl border transition-all ${STATUS_STYLE[r.status]}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base">{r.guest.firstName} {r.guest.lastName}</span>
<span className="text-xs px-2 py-0.5 rounded-full border font-semibold text-current border-current/30 bg-current/10">
{STATUS_LABEL[r.status]}
</span>
</div>
<div className="text-xs mt-1 text-current/70 flex flex-wrap gap-x-3 gap-y-0.5">
<span>{r.guestCount} Pers.</span>
<span>{r.startTime}{r.endTime}</span>
<span>Tisch {getTableNames(r.tableIds)}</span>
{r.guest.phone && <span>{r.guest.phone}</span>}
</div>
{r.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{r.tags.map((tag) => (
<span key={tag} className="text-xs px-2 py-0.5 rounded-full bg-slate-800/80 text-slate-300 border border-slate-700">
{TAG_LABELS[tag] ?? tag}
</span>
))}
</div>
)}
{r.notes && <p className="text-xs mt-1.5 italic text-current/50">{r.notes}"</p>}
</div>
<div className="flex flex-col gap-2 items-end shrink-0">
{NEXT_STATUS[r.status] && (
<button
onClick={() => updateReservationStatus(r.id, NEXT_STATUS[r.status]!)}
className="text-xs px-3 py-1.5 rounded-lg bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30 font-semibold transition whitespace-nowrap"
>
→ {STATUS_LABEL[NEXT_STATUS[r.status]!]}
</button>
)}
{!['FINISHED', 'NO_SHOW', 'CANCELLED'].includes(r.status) && (
<button
onClick={() => updateReservationStatus(r.id, 'NO_SHOW')}
className="text-xs px-3 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-400 hover:bg-rose-500/20 font-semibold transition whitespace-nowrap"
>
No-Show
</button>
)}
{!['CANCELLED', 'FINISHED'].includes(r.status) && (
<button
onClick={() => updateReservationStatus(r.id, 'CANCELLED')}
className="text-xs px-3 py-1.5 rounded-lg bg-slate-700/50 border border-slate-600 text-slate-400 hover:bg-slate-700 font-semibold transition"
>
Stornieren
</button>
)}
</div>
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-200">Heute · Aktive Reservierungen ({active.length})</h3>
<button
onClick={() => openBookingModal()}
className="text-xs px-4 py-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold transition"
>
+ Neue Buchung
</button>
</div>
<div className="space-y-3">
{active.length === 0 && <p className="text-sm text-slate-500">Keine aktiven Reservierungen.</p>}
{active.map((r) => <Card key={r.id} r={r} />)}
</div>
{past.length > 0 && (
<>
<div className="border-t border-slate-800/80 pt-4">
<h3 className="text-sm font-semibold text-slate-500 mb-3">Abgeschlossen / Storniert ({past.length})</h3>
<div className="space-y-2">
{past.map((r) => <Card key={r.id} r={r} />)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useAppStore } from '../store/useAppStore';
import { Reservation, ReservationStatus } from '../types';
// 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',
CONFIRMED: 'bg-amber-400',
ARRIVED: 'bg-emerald-400',
FINISHED: 'bg-slate-600',
NO_SHOW: 'bg-rose-500',
CANCELLED: 'bg-rose-700',
};
const STATUS_BAR: Record<ReservationStatus, string> = {
PENDING: 'bg-slate-500/50 border-slate-400/30',
CONFIRMED: 'bg-amber-500/30 border-amber-400/50',
ARRIVED: 'bg-emerald-500/30 border-emerald-400/50',
FINISHED: 'bg-slate-600/30 border-slate-500/30',
NO_SHOW: 'bg-rose-500/20 border-rose-400/30',
CANCELLED: 'bg-rose-800/20 border-rose-700/30',
};
function timeToMinutes(t: string) {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
}
const TIMELINE_START = 10 * 60; // 10:00
const TIMELINE_END = 24 * 60; // 24:00
const TOTAL_MINUTES = TIMELINE_END - TIMELINE_START;
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();
const handleSlotClick = (tableId: string, hour: number) => {
openBookingModal(tableId, `${String(hour).padStart(2, '0')}:00`);
};
return (
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
<div style={{ minWidth: `${120 + HOURS.length * COL_WIDTH}px` }}>
{/* Header */}
<div className="flex border-b border-slate-800 sticky top-0 bg-slate-950 z-10">
<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>
{/* 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: HOUR_WIDTH }}
>
{h}
</div>
))}
</div>
{/* Rows */}
{tables.map((table) => {
const tableReservations = reservations.filter((r) =>
r.tableIds.includes(table.id) && r.status !== 'CANCELLED'
);
return (
<div key={table.id} className="flex border-b border-slate-800/50 hover:bg-slate-900/30 group relative">
{/* Table Label */}
<div className="w-[120px] shrink-0 px-3 py-3 border-r border-slate-800 flex flex-col justify-center">
<span className="text-sm font-bold text-slate-200">{table.number}</span>
<span className="text-xs text-slate-500">{table.area} · {table.maxCapacity}P</span>
</div>
{/* Timeline cells */}
<div className="relative flex flex-1" style={{ height: 56 }}>
{/* 15-min clickable slots */}
{SLOTS.map((slot, i) => (
<button
key={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}`}
/>
))}
{/* Reservation bars */}
{tableReservations.map((res: Reservation) => {
const startMin = timeToMinutes(res.startTime) - TIMELINE_START;
const endMin = timeToMinutes(res.endTime) - TIMELINE_START;
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-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 - 2}px`,
}}
>
<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>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}

49
frontend/src/index.css Normal file
View File

@@ -0,0 +1,49 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--color-bg: #09090d;
--color-surface: #111116;
--color-surface-2: #1a1a24;
--color-border: #1f1f2e;
--color-border-2: #2a2a3a;
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-dim: rgba(99,102,241,0.12);
--color-accent-glow: rgba(99,102,241,0.25);
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-sky: #38bdf8;
--color-text: #f4f4f8;
--color-muted: #71717a;
--color-subtle: #3f3f52;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border-2); border-radius: 3px; }
/* Selection */
::selection { background: var(--color-accent-dim); color: var(--color-text); }
/* Focus ring */
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { Reservation, ReservationStatus, RestaurantTable, TableStatus } from '../types';
const SAMPLE_TABLES: RestaurantTable[] = [
{ id: 't1', number: 'T1', seats: 4, minCapacity: 1, maxCapacity: 4, status: 'FREE', x: 60, y: 60, shape: 'RECT', area: 'Gastraum' },
{ id: 't2', number: 'T2', seats: 2, minCapacity: 1, maxCapacity: 2, status: 'OCCUPIED', x: 240, y: 60, shape: 'ROUND', area: 'Gastraum' },
{ id: 't3', number: 'T3', seats: 6, minCapacity: 2, maxCapacity: 6, status: 'RESERVED', x: 420, y: 60, shape: 'RECT', area: 'Gastraum' },
{ id: 't4', number: 'T4', seats: 4, minCapacity: 1, maxCapacity: 4, status: 'BILL', x: 60, y: 240, shape: 'ROUND', area: 'Terrasse' },
{ id: 't5', number: 'T5', seats: 8, minCapacity: 4, maxCapacity: 8, status: 'FREE', x: 240, y: 240, shape: 'RECT', area: 'Terrasse' },
{ id: 't6', number: 'T6', seats: 2, minCapacity: 1, maxCapacity: 2, status: 'FREE', x: 420, y: 240, shape: 'ROUND', area: 'Bar' },
];
const SAMPLE_RESERVATIONS: Reservation[] = [
{
id: 'r1', tableIds: ['t3'],
guest: { id: 'g1', firstName: 'Maria', lastName: 'Müller', phone: '+49 170 1234567', email: 'maria@example.com' },
date: '2026-08-19', startTime: '18:00', endTime: '19:30', guestCount: 4,
status: 'CONFIRMED', notes: 'Fensterplatz gewünscht', tags: ['Fensterplatz', 'Geburtstag'], createdAt: '2026-08-18T10:00:00Z',
},
{
id: 'r2', tableIds: ['t2'],
guest: { id: 'g2', firstName: 'Hans', lastName: 'Schmidt', phone: '+49 160 7654321' },
date: '2026-08-19', startTime: '19:00', endTime: '20:30', guestCount: 2,
status: 'ARRIVED', notes: '', tags: ['Allergie'], createdAt: '2026-08-18T11:00:00Z',
},
{
id: 'r3', tableIds: ['t5'],
guest: { id: 'g3', firstName: 'Anna', lastName: 'Weber', email: 'anna@example.com' },
date: '2026-08-19', startTime: '20:00', endTime: '22:00', guestCount: 6,
status: 'PENDING', notes: 'Hochstuhl benötigt', tags: ['Hochstuhl'], createdAt: '2026-08-18T12:00:00Z',
},
];
interface AppState {
tables: RestaurantTable[];
reservations: Reservation[];
selectedTableId: string | null;
selectedReservationId: string | null;
showBookingModal: boolean;
prefillTableId: string | null;
prefillTime: string | null;
prefillDate: string | null;
setTableStatus: (id: string, status: TableStatus) => void;
selectTable: (id: string | null) => void;
openBookingModal: (tableId?: string, time?: string, date?: string) => void;
closeBookingModal: () => void;
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) => ({
tables: SAMPLE_TABLES,
reservations: SAMPLE_RESERVATIONS,
selectedTableId: null,
selectedReservationId: null,
showBookingModal: false,
prefillTableId: null,
prefillTime: null,
prefillDate: null,
setTableStatus: (id, status) =>
set((s) => ({ tables: s.tables.map((t) => (t.id === id ? { ...t, status } : t)) })),
selectTable: (id) => set({ selectedTableId: id }),
openBookingModal: (tableId, time, date) =>
set({ showBookingModal: true, prefillTableId: tableId ?? null, prefillTime: time ?? null, prefillDate: date ?? null }),
closeBookingModal: () =>
set({ showBookingModal: false, prefillTableId: null, prefillTime: null, prefillDate: null }),
addReservation: (reservation) =>
set((s) => ({ reservations: [reservation, ...s.reservations] })),
updateReservationStatus: (id, status) =>
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] })),
}));

View File

@@ -0,0 +1,39 @@
export type TableStatus = 'FREE' | 'OCCUPIED' | 'RESERVED' | 'BILL';
export type TableShape = 'RECT' | 'ROUND';
export type ReservationStatus = 'PENDING' | 'CONFIRMED' | 'ARRIVED' | 'FINISHED' | 'NO_SHOW' | 'CANCELLED';
export type ViewMode = 'floor' | 'timeline' | 'list';
export interface RestaurantTable {
id: string;
number: string;
seats: number;
minCapacity: number;
maxCapacity: number;
status: TableStatus;
x: number;
y: number;
shape: TableShape;
area: string;
}
export interface Guest {
id: string;
firstName: string;
lastName: string;
phone?: string;
email?: string;
}
export interface Reservation {
id: string;
guest: Guest;
tableIds: string[];
date: string;
startTime: string;
endTime: string;
guestCount: number;
status: ReservationStatus;
notes: string;
tags: string[];
createdAt: string;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

9
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})

85
pom.xml Normal file
View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
<relativePath/>
</parent>
<groupId>com.caspos</groupId>
<artifactId>smartseat-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>smartseat-backend</name>
<description>SmartSeat Gastro Reservation Backend</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<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>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.7</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package com.caspos;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SmartSeatApplication {
public static void main(String[] args) {
SpringApplication.run(SmartSeatApplication.class, args);
}
}

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,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,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()
);
}
}

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()
);
}
}