v2.0: 3-Raum-System - Hauptraum, Saal A, Saal B mit 18 Tischen, Raum-Buchungen, API-Doku

This commit is contained in:
Peter
2026-05-16 12:15:46 +00:00
commit 1ae070f82f
35 changed files with 10640 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
# Reservierungssystem - 3-Raum-Implementierung
**Datum:** 16. Mai 2026
**Version:** 2.0 (3-Raum-System)
**Server:** VM251 (192.168.0.251)
**URL:** http://192.168.0.251
---
## Übersicht
Reservierungssystem mit Raum- und Tischverwaltung für Restaurant/Event-Betrieb.
**Features:**
- 3 Buchbare Räume mit individuellen Tischplänen
- Visuelle Tisch-Auswahl im Grundriss
- Raum-Buchung für Veranstaltungen
- Automatische Verfügbarkeitsprüfung
---
## Architektur
### Tech-Stack
| Komponente | Technologie |
|-----------|-------------|
| Backend | Python 3.12 + Flask |
| Datenbank | SQLite (reservations.db) |
| Frontend | Vanilla JS + HTML/CSS |
| Webserver | Gunicorn (dev) |
| Container | Docker |
### Netzwerk
- Port 80: Web-UI (reservierung-frontend)
- Port 8081: API-Direktzugriff (reservation-system)
---
## Datenbank-Schema
### Räume (`rooms`)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER PK | Raum-ID |
| name | TEXT | Raumname |
| capacity | INTEGER | Maximale Kapazität |
| color | TEXT | Farbe für UI (#hex) |
### Bereiche (`areas`)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER PK | Bereich-ID |
| room_id | INTEGER FK | Zugehöriger Raum |
| name | TEXT | Bereichsname |
| available_from | TIME | Öffnungszeit |
| available_to | TIME | Schließzeit |
### Tische (`tables`)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER PK | Tisch-ID |
| area_id | INTEGER FK | Zugehöriger Bereich |
| name | TEXT | Tischbezeichnung |
| x, y | INTEGER | Position im Grundriss |
| width, height | INTEGER | Größe (Pixel) |
| shape | TEXT | Form: rect/circle/oval |
| seats | INTEGER | Sitzplätze |
| is_combinable | BOOLEAN | Kombinierbar |
| is_active | BOOLEAN | Aktiv/Inaktiv |
### Raum-Buchungen (`room_bookings`)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER PK | Buchungs-ID |
| room_id | INTEGER FK | Gebuchter Raum |
| date | DATE | Datum |
| time_from | TIME | Startzeit |
| time_to | TIME | Endzeit |
| event_name | TEXT | Veranstaltung |
| status | TEXT | pending/confirmed/cancelled |
---
## Konfigurierte Räume
### Raum 1: Hauptraum
- **Kapazität:** 80 Plätze
- **Farbe:** #3b82f6 (Blau)
- **Tische:** 8
- T1-T3: 4er/6er Tische (rect)
- T4: 6er Rund (circle)
- T5-T6: 4er Tische (rect)
- T7: 8er Rund (circle)
- T8: 10er Großtisch (rect)
### Raum 2: Saal A
- **Kapazität:** 40 Plätze
- **Farbe:** #10b981 (Grün)
- **Tische:** 5
- A1-A3: 4er Tische (rect)
- A4-A5: 6er Tische (rect)
### Raum 3: Saal B
- **Kapazität:** 30 Plätze
- **Farbe:** #f59e0b (Orange)
- **Tische:** 5
- B1-B2: 4er Tische (rect)
- B3: 6er Tisch (rect)
- B4-B5: 4er Rund (circle)
---
## API-Endpunkte
### Räume
```
GET /api/rooms # Alle Räume mit Tischen
GET /api/rooms-with-bookings # Räume + aktuelle Buchungen
```
### Tische
```
GET /api/tables/<area_id> # Tische eines Bereichs
POST /api/tables/<area_id> # Tisch erstellen
PUT /api/tables/<table_id> # Tisch aktualisieren
DELETE /api/tables/<table_id> # Tisch löschen
```
### Reservierungen
```
GET /api/reservations # Alle Reservierungen
POST /api/reservations # Reservierung erstellen
PUT /api/reservations/<id> # Reservierung aktualisieren
DELETE /api/reservations/<id> # Reservierung stornieren
POST /api/reservations/check-availability # Verfügbarkeit prüfen
```
### Raum-Buchungen (Events)
```
GET /api/room-bookings # Alle Raum-Buchungen
POST /api/room-bookings # Raum buchen
PUT /api/room-bookings/<id> # Buchung aktualisieren
DELETE /api/room-bookings/<id> # Buchung stornieren
GET /api/room-availability # Raum-Verfügbarkeit
```
### Verfügbarkeit
```
GET /api/availability?date=YYYY-MM-DD&time_from=HH:MM&time_to=HH:MM
```
---
## Deployment
### Docker-Container
```bash
# Backend starten
docker run -d --name reservation-system \
-p 80:8080 \
-v reservation-data:/data \
--restart unless-stopped \
reservierung-system:fixed
```
### Datenbank-Backup
```bash
# Backup erstellen
docker exec reservation-system \
cp /data/reservations.db /data/reservations.db.backup-$(date +%Y%m%d)
# Backup herunterladen
docker cp reservation-system:/data/reservations.db.backup-YYYYMMDD ./
```
---
## Änderungen (Changelog)
### v2.0 (16.05.2026)
- [x] 3-Raum-System implementiert
- [x] Räume: Hauptraum, Saal A, Saal B
- [x] 18 Tische mit Positionen und Sitzplätzen
- [x] Raum-Buchung für Veranstaltungen
- [x] Visuelle Tisch-Auswahl im Grundriss
- [x] Verfügbarkeitsprüfung automatisiert
- [x] API erweitert: /api/rooms, /api/room-bookings
### v1.0 (vorher)
- Einfaches Tisch-Reservierungssystem
- Keine Raum-Zuordnung
- Keine Event-Buchungen
---
## Zugriff
**SSH:**
```bash
ssh root@192.168.0.251
# Passwort: qwertzuiOP1!
```
**Web-UI:** http://192.168.0.251
**API-Test:**
```bash
curl http://192.168.0.251/api/rooms
curl http://192.168.0.251/api/health
```
---
## Bekannte Probleme
- [ ] Frontend-Design könnte professioneller sein
- [ ] Keine Benutzer-Authentifizierung (Admin/Mitarbeiter)
- [ ] Keine E-Mail-Benachrichtigungen implementiert
- [ ] Keine Öffnungszeiten pro Raum konfigurierbar
---
## Nächste Schritte
1. **Frontend-Design verbessern** (professionelles UI)
2. **Benutzer-Rollen** einführen (Admin, Mitarbeiter, Gast)
3. **E-Mail-Versand** aktivieren
4. **Öffnungszeiten pro Raum** konfigurierbar machen
5. **Statistiken/Dashboard** erweitern
---
## Kontakt
- **Entwicklung:** Peter (KI-Assistent)
- **Server:** X2 (192.168.0.55) / VM251 (192.168.0.251)
- **Git:** Committen auf VM251, Push zu Gitea pending
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask flask-cors requests
COPY app/ ./
RUN mkdir -p /data
EXPOSE 8080
ENV PORT=8080
ENV DATA_DIR=/data
ENV OLLAMA_URL=http://192.168.0.150:11434
VOLUME ["/data"]
CMD ["python", "main.py"]
+67
View File
@@ -0,0 +1,67 @@
# 🍽️ Reservierungssystem mit KI-Unterstützung
Intelligentes Reservierungs- und Tischmanagement für Restaurants.
## Features
- **KI-gestützte E-Mail-Verarbeitung** (Ollama 7b)
- **Automatische Terminänderungen** über Buchungsnummer
- **Tischplan** mit Drag & Drop
- **Gäste-Adressbuch** mit Historie
- **Dashboard** mit Klärung erforderlich
## Schneller Start
```bash
docker-compose up -d
```
- **URL:** http://192.168.0.250:8081
- **Datenbank:** SQLite (/data/reservations.db)
## KI-Integration
Das System nutzt Ollama für:
- E-Mail-Parsing und Intent-Erkennung
- Buchungsnummer-Extraktion
- Automatische Reservierungs-Verarbeitung
**Ollama-Endpoint:** http://192.168.0.150:11434
## Buchungsnummer-Format
```
RES-YYYY-MM-DD-XXX
Beispiel: RES-2025-05-12-001
```
## API-Endpunkte
| Endpunkt | Beschreibung |
|----------|--------------|
| GET /api/dashboard | Dashboard-Daten |
| GET /api/reservations | Alle Reservierungen |
| POST /api/reservations | Neue Reservierung |
| GET /api/guests | Gäste-Adressbuch |
| GET /api/emails | E-Mails (zur Klärung) |
| GET /api/availability | Freie Tische prüfen |
## Workflows
### Neue Reservierung per E-Mail
```
1. E-Mail wird empfangen
2. Ollama extrahiert: Name, Datum, Zeit, Personen
3. System erstellt Reservierung
4. Bestätigungsmail mit Buchungsnummer wird versendet
```
### Terminänderung
```
1. Gast schreibt: "Ich möchte RES-2025-05-12-001 verschieben"
2. Ollama erkennt Buchungsnummer + Intent
3. System findet Reservierung
4. Änderung wird durchgeführt
5. Historie wird protokolliert
6. Bestätigung wird versendet
```
+1
View File
@@ -0,0 +1 @@
# App module
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Datenbank-Modelle für Reservierungssystem"""
import sqlite3
import json
from datetime import datetime, timedelta
from contextlib import contextmanager
DB_PATH = "/data/reservations.db"
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Datenbank initialisieren"""
with get_db() as db:
db.executescript('''
-- Gäste-Adressbuch
CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT UNIQUE,
preferred_table_id INTEGER,
notes TEXT,
visit_count INTEGER DEFAULT 0,
last_visit DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Räume
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER,
color TEXT DEFAULT '#3498db'
);
-- Bereiche innerhalb von Räumen
CREATE TABLE IF NOT EXISTS areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
available_from TIME DEFAULT '10:00',
available_to TIME DEFAULT '23:00',
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
-- Tische
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
area_id INTEGER NOT NULL,
name TEXT NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
shape TEXT DEFAULT 'rect' CHECK(shape IN ('rect', 'circle', 'oval')),
seats INTEGER DEFAULT 4,
is_combinable BOOLEAN DEFAULT 1,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE CASCADE
);
-- Tisch-Kombinationen (Zusammenlegungen)
CREATE TABLE IF NOT EXISTS table_combinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_table_id INTEGER NOT NULL,
child_table_ids TEXT NOT NULL, -- JSON-Array
total_seats INTEGER,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE
);
-- Reservierungen
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_number TEXT UNIQUE NOT NULL, -- RES-20250512-001
guest_id INTEGER,
table_ids TEXT NOT NULL, -- JSON-Array
date DATE NOT NULL,
time_from TIME NOT NULL,
time_to TIME,
guests INTEGER NOT NULL,
occasion TEXT,
notes TEXT,
source TEXT DEFAULT 'email' CHECK(source IN ('email', 'phone', 'web', 'walk-in')),
phone_caller_name TEXT, -- Für Telefon-Buchungen
status TEXT DEFAULT 'confirmed' CHECK(status IN ('confirmed', 'pending', 'cancelled', 'completed')),
email_thread_id TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE SET NULL
);
-- Änderungshistorie
CREATE TABLE IF NOT EXISTS reservation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_id INTEGER NOT NULL,
booking_number TEXT NOT NULL,
changed_field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reason TEXT,
FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
);
-- E-Mails
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE NOT NULL,
thread_id TEXT,
subject TEXT,
body TEXT,
sender TEXT,
sender_email TEXT,
parsed_json TEXT, -- JSON mit extrahierten Daten
confidence REAL,
action_type TEXT CHECK(action_type IN ('new', 'modification', 'cancellation', 'unknown')),
status TEXT DEFAULT 'new' CHECK(status IN ('new', 'auto_processed', 'needs_review', 'confirmed', 'failed')),
linked_reservation_id INTEGER,
booking_number_found TEXT, -- Extrahierte Buchungsnummer
auto_reply_sent BOOLEAN DEFAULT 0,
auto_reply_status TEXT,
received_at DATETIME,
processed_at DATETIME,
FOREIGN KEY (linked_reservation_id) REFERENCES reservations(id) ON DELETE SET NULL
);
-- Indizes
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_booking ON reservations(booking_number);
CREATE INDEX IF NOT EXISTS idx_reservations_guest ON reservations(guest_id);
CREATE INDEX IF NOT EXISTS idx_reservations_status ON reservations(status);
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id);
CREATE INDEX IF NOT EXISTS idx_emails_status ON emails(status);
CREATE INDEX IF NOT EXISTS idx_emails_booking ON emails(booking_number_found);
CREATE INDEX IF NOT EXISTS idx_history_booking ON reservation_history(booking_number);
-- Trigger für updated_at
CREATE TRIGGER IF NOT EXISTS update_reservations_timestamp
AFTER UPDATE ON reservations
BEGIN
UPDATE reservations SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
''')
def generate_booking_number(date=None):
"""Generiere Buchungsnummer RES-YYYY-MM-DD-XXX"""
if date is None:
date = datetime.now()
date_str = date.strftime('%Y-%m-%d')
with get_db() as db:
# Zähle bestehende Reservierungen an diesem Tag
count = db.execute(
"SELECT COUNT(*) FROM reservations WHERE date = ?",
(date_str,)
).fetchone()[0]
return f"RES-{date_str}-{count + 1:03d}"
def log_change(db, reservation_id, booking_number, field, old_val, new_val, reason=None):
"""Änderung in Historie speichern"""
db.execute('''
INSERT INTO reservation_history
(reservation_id, booking_number, changed_field, old_value, new_value, reason)
VALUES (?, ?, ?, ?, ?, ?)
''', (reservation_id, booking_number, field, str(old_val), str(new_val), reason))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,637 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reservierungssystem</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
}
.header {
background: #16213e;
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #2d3a5c;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: #00d4aa;
}
.nav {
display: flex;
gap: 10px;
}
.nav-btn {
background: transparent;
border: 1px solid #00d4aa;
color: #00d4aa;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
background: #00d4aa;
color: #000;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 30px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
padding: 25px;
}
.stat-card h3 {
color: #888;
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 10px;
text-transform: uppercase;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: #00d4aa;
}
.alert-card {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
cursor: pointer;
transition: transform 0.2s;
}
.alert-card:hover {
transform: translateY(-3px);
}
.alert-card h3 {
color: rgba(255,255,255,0.9);
}
.alert-card .stat-value {
color: #fff;
}
.section {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
margin-bottom: 20px;
}
.section-header {
padding: 20px 25px;
border-bottom: 1px solid #2d3a5c;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h2 {
font-size: 1.2rem;
}
.btn {
background: #00d4aa;
color: #000;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
.btn:hover {
background: #00b894;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #2d3a5c;
}
th {
color: #888;
font-weight: 500;
font-size: 0.85rem;
text-transform: uppercase;
}
tr:hover {
background: rgba(0,212,170,0.05);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-confirmed {
background: rgba(0,212,170,0.2);
color: #00d4aa;
}
.badge-pending {
background: rgba(241,196,15,0.2);
color: #f1c40f;
}
.badge-cancelled {
background: rgba(231,76,60,0.2);
color: #e74c3c;
}
.booking-number {
font-family: monospace;
background: rgba(0,212,170,0.1);
padding: 4px 8px;
border-radius: 4px;
color: #00d4aa;
}
.hidden {
display: none !important;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
padding: 30px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #888;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #2d3a5c;
background: #1a1a2e;
color: #fff;
border-radius: 6px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 25px;
}
.btn-secondary {
background: #333;
color: #fff;
}
.btn-secondary:hover {
background: #444;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 15px;
}
.nav {
flex-wrap: wrap;
justify-content: center;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🍽️ Reservierungssystem</div>
<div class="nav">
<button class="nav-btn active" onclick="showView('dashboard')">Dashboard</button>
<button class="nav-btn" onclick="showView('reservations')">Reservierungen</button>
<button class="nav-btn" onclick="showView('floorplan')">Tischplan</button>
<button class="nav-btn" onclick="showView('guests')">Gäste</button>
<button class="nav-btn" onclick="showView('emails')">E-Mails</button>
</div>
</div>
<div class="container">
<!-- Dashboard View -->
<div id="view-dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<h3>Heute Reserviert</h3>
<div class="stat-value" id="stat-today">0</div>
</div>
<div class="stat-card">
<h3>Gäste Heute</h3>
<div class="stat-value" id="stat-guests">0</div>
</div>
<div class="stat-card">
<h3>Freie Tische</h3>
<div class="stat-value" id="stat-available">0</div>
</div>
<div class="stat-card alert-card" onclick="showView('emails')" id="alert-card">
<h3>⚠️ Klärung Erforderlich</h3>
<div class="stat-value" id="stat-pending">0</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>Heutige Reservierungen</h2>
<button class="btn" onclick="openNewReservation()">+ Neue Reservierung</button>
</div>
<div id="today-reservations">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Zeit</th>
<th>Name</th>
<th>Personen</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody id="today-table-body">
<!-- Dynamisch gefüllt -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Weitere Views (hidden by default) -->
<div id="view-reservations" class="hidden">
<div class="section">
<div class="section-header">
<h2>Alle Reservierungen</h2>
<div>
<input type="date" id="filter-date" onchange="loadReservations()">
<button class="btn" onclick="openNewReservation()">+ Neu</button>
</div>
</div>
<div id="all-reservations">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Zeit</th>
<th>Name</th>
<th>Pers.</th>
<th>Status</th>
</tr>
</thead>
<tbody id="reservations-table-body">
</tbody>
</table>
</div>
</div>
</div>
<div id="view-floorplan" class="hidden">
<div class="section">
<div class="section-header">
<h2>Tischplan</h2>
<select id="floorplan-date" onchange="loadFloorplan()">
<option>Heute</option>
</select>
</div>
<div id="floorplan-container" style="min-height: 500px; position: relative;"
003e
<!-- Dynamisch rendern -->
</div>
</div>
</div>
<div id="view-guests" class="hidden">
<div class="section">
<div class="section-header">
<h2>Gäste-Adressbuch</h2>
<div>
<input type="text" placeholder="Suchen..." id="guest-search" onkeyup="searchGuests()">
<button class="btn" onclick="openNewGuest()">+ Neuer Gast</button>
</div>
</div>
<div id="guests-list"></div>
</div>
</div>
<div id="view-emails" class="hidden">
<div class="section">
<div class="section-header">
<h2>E-Mail-Verarbeitung</h2>
<div class="nav">
<button class="nav-btn active" onclick="filterEmails('needs_review')">Klärung</button>
<button class="nav-btn" onclick="filterEmails('auto_processed')">Automatisch</button>
<button class="nav-btn" onclick="filterEmails('confirmed')">Bestätigt</button>
</div>
</div>
<div id="emails-list"></div>
</div>
</div>
</div>
<!-- Modal: Neue Reservierung -->
<div id="modal-reservation" class="modal-overlay hidden">
<div class="modal">
<h2>Neue Reservierung</h2>
<form id="reservation-form">
<div class="form-row">
<div class="form-group">
<label>Datum *</label>
<input type="date" id="res-date" required>
</div>
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="res-time" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input type="text" id="res-name" placeholder="Name suchen..." onkeyup="searchGuestForReservation()" required>
</div>
<div class="form-group">
<label>Personen *</label>
<input type="number" id="res-guests" min="1" max="50" value="2" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Telefon</label>
<input type="tel" id="res-phone">
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" id="res-email">
</div>
</div>
<div class="form-group">
<label>Quelle</label>
<select id="res-source">
<option value="phone">Telefon</option>
<option value="email">E-Mail</option>
<option value="web">Web</option>
<option value="walk-in">Walk-in</option>
</select>
</div>
<div class="form-group" id="phone-caller-group">
<label>Anrufer (bei Telefon-Buchung)</label>
<input type="text" id="res-caller">
</div>
<div class="form-group">
<label>Notizen</label>
<textarea id="res-notes" rows="3"></textarea>
</div>
</form>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Abbrechen</button>
<button class="btn" onclick="saveReservation()">Speichern</button>
</div>
</div>
</div>
<script>
// Global state
let currentView = 'dashboard';
// Init
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
document.getElementById('res-date').valueAsDate = new Date();
// Source change handler
document.getElementById('res-source').addEventListener('change', (e) => {
const callerGroup = document.getElementById('phone-caller-group');
callerGroup.style.display = e.target.value === 'phone' ? 'block' : 'none';
});
});
// View switching
function showView(view) {
// Hide all views
document.querySelectorAll('[id^="view-"]').forEach(el => {
el.classList.add('hidden');
});
// Show selected view
document.getElementById(`view-${view}`).classList.remove('hidden');
// Update nav buttons
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// Load data for view
if (view === 'dashboard') loadDashboard();
if (view === 'reservations') loadReservations();
if (view === 'floorplan') loadFloorplan();
if (view === 'guests') loadGuests();
if (view === 'emails') loadEmails();
currentView = view;
}
// API calls
async function api(endpoint, options = {}) {
const res = await fetch(`/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
return res.json();
}
// Dashboard
async function loadDashboard() {
const data = await api('/dashboard');
document.getElementById('stat-today').textContent = data.today_count;
document.getElementById('stat-guests').textContent = data.guests_today;
document.getElementById('stat-available').textContent = data.total_tables - data.today_count;
document.getElementById('stat-pending').textContent = data.pending_emails;
// Today's reservations
const tbody = document.getElementById('today-table-body');
tbody.innerHTML = data.today_reservations.map(r => `
<tr>
<td><span class="booking-number">${r.booking_number}</span></td>
<td>${r.time_from}</td>
<td>${r.guest_name || r.phone || 'Unbekannt'}</td>
<td>${r.guests}</td>
<td><span class="badge badge-${r.status}">${r.status}</span></td>
<td><button class="btn btn-secondary" onclick="viewReservation(${r.id})">Details</button></td>
</tr>
`).join('');
}
// Reservations
async function loadReservations() {
const date = document.getElementById('filter-date').value;
const params = date ? `?date=${date}` : '';
const data = await api(`/reservations${params}`);
const tbody = document.getElementById('reservations-table-body');
tbody.innerHTML = data.map(r => `
<tr>
<td><span class="booking-number">${r.booking_number}</span></td>
<td>${r.date}</td>
<td>${r.time_from}</td>
<td>${r.guest_name || 'Unbekannt'}</td>
<td>${r.guests}</td>
<td><span class="badge badge-${r.status}">${r.status}</span></td>
</tr>
`).join('');
}
// Modal functions
function openNewReservation() {
document.getElementById('modal-reservation').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal-reservation').classList.add('hidden');
document.getElementById('reservation-form').reset();
}
async function saveReservation() {
const data = {
date: document.getElementById('res-date').value,
time_from: document.getElementById('res-time').value,
guests: parseInt(document.getElementById('res-guests').value),
name: document.getElementById('res-name').value,
phone: document.getElementById('res-phone').value,
email: document.getElementById('res-email').value,
source: document.getElementById('res-source').value,
phone_caller_name: document.getElementById('res-caller').value,
notes: document.getElementById('res-notes').value
};
const result = await api('/reservations', {
method: 'POST',
body: JSON.stringify(data)
});
if (result.booking_number) {
alert(`Reservierung erstellt: ${result.booking_number}`);
closeModal();
loadDashboard();
} else {
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'));
}
}
// Placeholder functions
function loadFloorplan() { console.log('Floorplan loading...'); }
function loadGuests() { console.log('Guests loading...'); }
function loadEmails() { console.log('Emails loading...'); }
function filterEmails(status) { console.log('Filter:', status); }
function searchGuests() { console.log('Search guests...'); }
function openNewGuest() { console.log('New guest...'); }
function searchGuestForReservation() { console.log('Search guest...'); }
function viewReservation(id) { console.log('View:', id); }
</script>
</body>
</html>
@@ -0,0 +1 @@
# Utils module
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Ollama Integration für KI-gestütztes E-Mail-Parsing"""
import json
import requests
import re
from datetime import datetime
OLLAMA_URL = "http://192.168.0.150:11434/api/generate"
DEFAULT_MODEL = "gemma4:latest"
def query_ollama(prompt, model=DEFAULT_MODEL, temperature=0.1):
"""Ollama API aufrufen"""
try:
response = requests.post(
OLLAMA_URL,
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": 500
}
},
timeout=30
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def extract_json_from_response(text):
"""JSON aus Ollama-Antwort extrahieren"""
# Versuche direkt als JSON zu parsen
try:
return json.loads(text)
except:
pass
# Suche nach JSON-Block
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
return json.loads(json_match.group())
except:
pass
return {"error": "Kein gültiges JSON gefunden", "raw": text}
def parse_email_with_ollama(email_subject, email_body, sender_name):
"""E-Mail mit Ollama parsen"""
prompt = f"""Analysiere diese Restaurant-Reservierungs-E-Mail.
WICHTIG: Suche nach einer Buchungsnummer im Format RES-YYYY-MM-DD-XXX.
Wenn eine Buchungsnummer erwähnt wird, ist es eine Änderung oder Stornierung.
Wenn keine Buchungsnummer vorhanden ist, ist es eine neue Reservierung.
Absender: {sender_name}
Betreff: {email_subject}
E-Mail-Inhalt:
---
{email_body}
---
Extrahiere folgende Informationen und antworte NUR mit JSON:
{{
"booking_number": "RES-2025-05-12-001" oder null,
"intent": "new" | "modification" | "cancellation",
"name": "Name des Gastes",
"phone": "Telefonnummer",
"email": "E-Mail-Adresse",
"date": "YYYY-MM-DD",
"time": "HH:MM",
"guests": 4,
"occasion": "Geburtstag/Dinner/etc. oder null",
"notes": "Weitere Informationen",
"confidence": 0.95,
"needs_review": false,
"review_reason": null
}}
Beispiele für Änderungen:
- "Ich möchte meine Reservierung RES-2025-05-12-001 verschieben"
- "Bitte stornieren Sie meinen Tisch"
- "Wir sind jetzt 6 statt 4 Personen"
Antworte nur mit dem JSON-Objekt, keine Erklärungen."""
result = query_ollama(prompt)
if "error" in result:
return {
"error": result["error"],
"needs_review": True,
"review_reason": "Ollama-Fehler"
}
response_text = result.get("response", "")
parsed = extract_json_from_response(response_text)
# Validiere und ergänze
if "confidence" not in parsed:
parsed["confidence"] = 0.5
if "needs_review" not in parsed:
parsed["needs_review"] = parsed.get("confidence", 0) < 0.7
return parsed
def generate_confirmation_email(reservation, is_new=True):
"""Bestätigungsmail-Text generieren"""
if is_new:
template = f"""Hallo {reservation.get('name', 'Gast')},
vielen Dank für Ihre Reservierung bei uns!
━━━━━━━━━━━━━━━━━━━━━━━
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
━━━━━━━━━━━━━━━━━━━━━━━
📅 Datum: {reservation.get('date', 'unbekannt')}
⏰ Uhrzeit: {reservation.get('time_from', 'unbekannt')}
👥 Personen: {reservation.get('guests', 'unbekannt')}
🍽️ Tisch: {reservation.get('table_name', 'wird zugewiesen')}
Bei Änderungen antworten Sie einfach auf diese E-Mail
und nennen Sie Ihre Buchungsnummer: {reservation['booking_number']}
Wir freuen uns auf Ihren Besuch!
Mit freundlichen Grüßen
Ihr Restaurant-Team"""
else:
template = f"""Hallo {reservation.get('name', 'Gast')},
Ihre Reservierung wurde aktualisiert.
━━━━━━━━━━━━━━━━━━━━━━━
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
━━━━━━━━━━━━━━━━━━━━━━━
📅 Neuer Termin: {reservation.get('date', 'unbekannt')} um {reservation.get('time_from', 'unbekannt')}
👥 Personen: {reservation.get('guests', 'unbekannt')}
Alle anderen Details bleiben bestehen.
Bei weiteren Änderungen nennen Sie bitte immer Ihre Buchungsnummer: {reservation['booking_number']}
Mit freundlichen Grüßen
Ihr Restaurant-Team"""
return template
def suggest_table_for_group(available_tables, guests, date, time, ollama_model=DEFAULT_MODEL):
"""Ollama schlägt optimalen Tisch vor"""
tables_info = "\n".join([
f"- {t['name']}: {t['seats']} Plätze, Bereich: {t.get('area_name', 'unbekannt')}"
for t in available_tables
])
prompt = f"""Schlage den besten Tisch für diese Reservierung vor:
Gruppe: {guests} Personen
Datum: {date}
Uhrzeit: {time}
Verfügbare Tische:
{tables_info}
Berücksichtige:
- Passende Größe für {guests} Personen
- Atmosphäre (Fenster bevorzugt)
- Nicht zu groß oder zu klein
Antworte mit JSON:
{{
"suggested_table_id": 5,
"reasoning": "Tisch 5 hat 6 Plätze, ist am Fenster und ideal für {guests} Personen",
"alternatives": [3, 7]
}}"""
result = query_ollama(prompt, model=ollama_model)
if "error" in result:
return None
return extract_json_from_response(result.get("response", ""))
+1
View File
@@ -0,0 +1 @@
# App module
Binary file not shown.
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Datenbank-Modelle für Reservierungssystem"""
import sqlite3
import json
from datetime import datetime, timedelta
from contextlib import contextmanager
DB_PATH = "/data/reservations.db"
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Datenbank initialisieren"""
with get_db() as db:
db.executescript('''
-- Gäste-Adressbuch
CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT UNIQUE,
preferred_table_id INTEGER,
notes TEXT,
visit_count INTEGER DEFAULT 0,
last_visit DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Räume
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER,
color TEXT DEFAULT '#3498db'
);
-- Bereiche innerhalb von Räumen
CREATE TABLE IF NOT EXISTS areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
available_from TIME DEFAULT '10:00',
available_to TIME DEFAULT '23:00',
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
-- Tische
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
area_id INTEGER NOT NULL,
name TEXT NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
shape TEXT DEFAULT 'rect' CHECK(shape IN ('rect', 'circle', 'oval')),
seats INTEGER DEFAULT 4,
is_combinable BOOLEAN DEFAULT 1,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE CASCADE
);
-- Tisch-Kombinationen (Zusammenlegungen)
CREATE TABLE IF NOT EXISTS table_combinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_table_id INTEGER NOT NULL,
child_table_ids TEXT NOT NULL, -- JSON-Array
total_seats INTEGER,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE
);
-- Reservierungen
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_number TEXT UNIQUE NOT NULL, -- RES-20250512-001
guest_id INTEGER,
table_ids TEXT NOT NULL, -- JSON-Array
date DATE NOT NULL,
time_from TIME NOT NULL,
time_to TIME,
guests INTEGER NOT NULL,
occasion TEXT,
notes TEXT,
source TEXT DEFAULT 'email' CHECK(source IN ('email', 'phone', 'web', 'walk-in')),
phone_caller_name TEXT, -- Für Telefon-Buchungen
status TEXT DEFAULT 'confirmed' CHECK(status IN ('confirmed', 'pending', 'cancelled', 'completed')),
email_thread_id TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE SET NULL
);
-- Änderungshistorie
CREATE TABLE IF NOT EXISTS reservation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_id INTEGER NOT NULL,
booking_number TEXT NOT NULL,
changed_field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reason TEXT,
FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
);
-- E-Mails
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE NOT NULL,
thread_id TEXT,
subject TEXT,
body TEXT,
sender TEXT,
sender_email TEXT,
parsed_json TEXT, -- JSON mit extrahierten Daten
confidence REAL,
action_type TEXT CHECK(action_type IN ('new', 'modification', 'cancellation', 'unknown')),
status TEXT DEFAULT 'new' CHECK(status IN ('new', 'auto_processed', 'needs_review', 'confirmed', 'failed')),
linked_reservation_id INTEGER,
booking_number_found TEXT, -- Extrahierte Buchungsnummer
auto_reply_sent BOOLEAN DEFAULT 0,
auto_reply_status TEXT,
received_at DATETIME,
processed_at DATETIME,
FOREIGN KEY (linked_reservation_id) REFERENCES reservations(id) ON DELETE SET NULL
);
-- Indizes
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_booking ON reservations(booking_number);
CREATE INDEX IF NOT EXISTS idx_reservations_guest ON reservations(guest_id);
CREATE INDEX IF NOT EXISTS idx_reservations_status ON reservations(status);
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id);
CREATE INDEX IF NOT EXISTS idx_emails_status ON emails(status);
CREATE INDEX IF NOT EXISTS idx_emails_booking ON emails(booking_number_found);
CREATE INDEX IF NOT EXISTS idx_history_booking ON reservation_history(booking_number);
-- Trigger für updated_at
CREATE TRIGGER IF NOT EXISTS update_reservations_timestamp
AFTER UPDATE ON reservations
BEGIN
UPDATE reservations SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
''')
def generate_booking_number(date=None):
"""Generiere Buchungsnummer RES-YYYY-MM-DD-XXX"""
if date is None:
date = datetime.now()
date_str = date.strftime('%Y-%m-%d')
with get_db() as db:
# Zähle bestehende Reservierungen an diesem Tag
count = db.execute(
"SELECT COUNT(*) FROM reservations WHERE date = ?",
(date_str,)
).fetchone()[0]
return f"RES-{date_str}-{count + 1:03d}"
def log_change(db, reservation_id, booking_number, field, old_val, new_val, reason=None):
"""Änderung in Historie speichern"""
db.execute('''
INSERT INTO reservation_history
(reservation_id, booking_number, changed_field, old_value, new_value, reason)
VALUES (?, ?, ?, ?, ?, ?)
''', (reservation_id, booking_number, field, str(old_val), str(new_val), reason))
+1128
View File
File diff suppressed because it is too large Load Diff
+637
View File
@@ -0,0 +1,637 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reservierungssystem</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
}
.header {
background: #16213e;
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #2d3a5c;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: #00d4aa;
}
.nav {
display: flex;
gap: 10px;
}
.nav-btn {
background: transparent;
border: 1px solid #00d4aa;
color: #00d4aa;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
background: #00d4aa;
color: #000;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 30px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
padding: 25px;
}
.stat-card h3 {
color: #888;
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 10px;
text-transform: uppercase;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: #00d4aa;
}
.alert-card {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
cursor: pointer;
transition: transform 0.2s;
}
.alert-card:hover {
transform: translateY(-3px);
}
.alert-card h3 {
color: rgba(255,255,255,0.9);
}
.alert-card .stat-value {
color: #fff;
}
.section {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
margin-bottom: 20px;
}
.section-header {
padding: 20px 25px;
border-bottom: 1px solid #2d3a5c;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h2 {
font-size: 1.2rem;
}
.btn {
background: #00d4aa;
color: #000;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
.btn:hover {
background: #00b894;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #2d3a5c;
}
th {
color: #888;
font-weight: 500;
font-size: 0.85rem;
text-transform: uppercase;
}
tr:hover {
background: rgba(0,212,170,0.05);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.badge-confirmed {
background: rgba(0,212,170,0.2);
color: #00d4aa;
}
.badge-pending {
background: rgba(241,196,15,0.2);
color: #f1c40f;
}
.badge-cancelled {
background: rgba(231,76,60,0.2);
color: #e74c3c;
}
.booking-number {
font-family: monospace;
background: rgba(0,212,170,0.1);
padding: 4px 8px;
border-radius: 4px;
color: #00d4aa;
}
.hidden {
display: none !important;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #16213e;
border: 1px solid #2d3a5c;
border-radius: 12px;
padding: 30px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #888;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid #2d3a5c;
background: #1a1a2e;
color: #fff;
border-radius: 6px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 25px;
}
.btn-secondary {
background: #333;
color: #fff;
}
.btn-secondary:hover {
background: #444;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 15px;
}
.nav {
flex-wrap: wrap;
justify-content: center;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="header">
<div class="logo">🍽️ Reservierungssystem</div>
<div class="nav">
<button class="nav-btn active" onclick="showView('dashboard')">Dashboard</button>
<button class="nav-btn" onclick="showView('reservations')">Reservierungen</button>
<button class="nav-btn" onclick="showView('floorplan')">Tischplan</button>
<button class="nav-btn" onclick="showView('guests')">Gäste</button>
<button class="nav-btn" onclick="showView('emails')">E-Mails</button>
</div>
</div>
<div class="container">
<!-- Dashboard View -->
<div id="view-dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<h3>Heute Reserviert</h3>
<div class="stat-value" id="stat-today">0</div>
</div>
<div class="stat-card">
<h3>Gäste Heute</h3>
<div class="stat-value" id="stat-guests">0</div>
</div>
<div class="stat-card">
<h3>Freie Tische</h3>
<div class="stat-value" id="stat-available">0</div>
</div>
<div class="stat-card alert-card" onclick="showView('emails')" id="alert-card">
<h3>⚠️ Klärung Erforderlich</h3>
<div class="stat-value" id="stat-pending">0</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>Heutige Reservierungen</h2>
<button class="btn" onclick="openNewReservation()">+ Neue Reservierung</button>
</div>
<div id="today-reservations">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Zeit</th>
<th>Name</th>
<th>Personen</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody id="today-table-body">
<!-- Dynamisch gefüllt -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Weitere Views (hidden by default) -->
<div id="view-reservations" class="hidden">
<div class="section">
<div class="section-header">
<h2>Alle Reservierungen</h2>
<div>
<input type="date" id="filter-date" onchange="loadReservations()">
<button class="btn" onclick="openNewReservation()">+ Neu</button>
</div>
</div>
<div id="all-reservations">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Zeit</th>
<th>Name</th>
<th>Pers.</th>
<th>Status</th>
</tr>
</thead>
<tbody id="reservations-table-body">
</tbody>
</table>
</div>
</div>
</div>
<div id="view-floorplan" class="hidden">
<div class="section">
<div class="section-header">
<h2>Tischplan</h2>
<select id="floorplan-date" onchange="loadFloorplan()">
<option>Heute</option>
</select>
</div>
<div id="floorplan-container" style="min-height: 500px; position: relative;"
003e
<!-- Dynamisch rendern -->
</div>
</div>
</div>
<div id="view-guests" class="hidden">
<div class="section">
<div class="section-header">
<h2>Gäste-Adressbuch</h2>
<div>
<input type="text" placeholder="Suchen..." id="guest-search" onkeyup="searchGuests()">
<button class="btn" onclick="openNewGuest()">+ Neuer Gast</button>
</div>
</div>
<div id="guests-list"></div>
</div>
</div>
<div id="view-emails" class="hidden">
<div class="section">
<div class="section-header">
<h2>E-Mail-Verarbeitung</h2>
<div class="nav">
<button class="nav-btn active" onclick="filterEmails('needs_review')">Klärung</button>
<button class="nav-btn" onclick="filterEmails('auto_processed')">Automatisch</button>
<button class="nav-btn" onclick="filterEmails('confirmed')">Bestätigt</button>
</div>
</div>
<div id="emails-list"></div>
</div>
</div>
</div>
<!-- Modal: Neue Reservierung -->
<div id="modal-reservation" class="modal-overlay hidden">
<div class="modal">
<h2>Neue Reservierung</h2>
<form id="reservation-form">
<div class="form-row">
<div class="form-group">
<label>Datum *</label>
<input type="date" id="res-date" required>
</div>
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="res-time" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input type="text" id="res-name" placeholder="Name suchen..." onkeyup="searchGuestForReservation()" required>
</div>
<div class="form-group">
<label>Personen *</label>
<input type="number" id="res-guests" min="1" max="50" value="2" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Telefon</label>
<input type="tel" id="res-phone">
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" id="res-email">
</div>
</div>
<div class="form-group">
<label>Quelle</label>
<select id="res-source">
<option value="phone">Telefon</option>
<option value="email">E-Mail</option>
<option value="web">Web</option>
<option value="walk-in">Walk-in</option>
</select>
</div>
<div class="form-group" id="phone-caller-group">
<label>Anrufer (bei Telefon-Buchung)</label>
<input type="text" id="res-caller">
</div>
<div class="form-group">
<label>Notizen</label>
<textarea id="res-notes" rows="3"></textarea>
</div>
</form>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Abbrechen</button>
<button class="btn" onclick="saveReservation()">Speichern</button>
</div>
</div>
</div>
<script>
// Global state
let currentView = 'dashboard';
// Init
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
document.getElementById('res-date').valueAsDate = new Date();
// Source change handler
document.getElementById('res-source').addEventListener('change', (e) => {
const callerGroup = document.getElementById('phone-caller-group');
callerGroup.style.display = e.target.value === 'phone' ? 'block' : 'none';
});
});
// View switching
function showView(view) {
// Hide all views
document.querySelectorAll('[id^="view-"]').forEach(el => {
el.classList.add('hidden');
});
// Show selected view
document.getElementById(`view-${view}`).classList.remove('hidden');
// Update nav buttons
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// Load data for view
if (view === 'dashboard') loadDashboard();
if (view === 'reservations') loadReservations();
if (view === 'floorplan') loadFloorplan();
if (view === 'guests') loadGuests();
if (view === 'emails') loadEmails();
currentView = view;
}
// API calls
async function api(endpoint, options = {}) {
const res = await fetch(`/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
return res.json();
}
// Dashboard
async function loadDashboard() {
const data = await api('/dashboard');
document.getElementById('stat-today').textContent = data.today_count;
document.getElementById('stat-guests').textContent = data.guests_today;
document.getElementById('stat-available').textContent = data.total_tables - data.today_count;
document.getElementById('stat-pending').textContent = data.pending_emails;
// Today's reservations
const tbody = document.getElementById('today-table-body');
tbody.innerHTML = data.today_reservations.map(r => `
<tr>
<td><span class="booking-number">${r.booking_number}</span></td>
<td>${r.time_from}</td>
<td>${r.guest_name || r.phone || 'Unbekannt'}</td>
<td>${r.guests}</td>
<td><span class="badge badge-${r.status}">${r.status}</span></td>
<td><button class="btn btn-secondary" onclick="viewReservation(${r.id})">Details</button></td>
</tr>
`).join('');
}
// Reservations
async function loadReservations() {
const date = document.getElementById('filter-date').value;
const params = date ? `?date=${date}` : '';
const data = await api(`/reservations${params}`);
const tbody = document.getElementById('reservations-table-body');
tbody.innerHTML = data.map(r => `
<tr>
<td><span class="booking-number">${r.booking_number}</span></td>
<td>${r.date}</td>
<td>${r.time_from}</td>
<td>${r.guest_name || 'Unbekannt'}</td>
<td>${r.guests}</td>
<td><span class="badge badge-${r.status}">${r.status}</span></td>
</tr>
`).join('');
}
// Modal functions
function openNewReservation() {
document.getElementById('modal-reservation').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal-reservation').classList.add('hidden');
document.getElementById('reservation-form').reset();
}
async function saveReservation() {
const data = {
date: document.getElementById('res-date').value,
time_from: document.getElementById('res-time').value,
guests: parseInt(document.getElementById('res-guests').value),
name: document.getElementById('res-name').value,
phone: document.getElementById('res-phone').value,
email: document.getElementById('res-email').value,
source: document.getElementById('res-source').value,
phone_caller_name: document.getElementById('res-caller').value,
notes: document.getElementById('res-notes').value
};
const result = await api('/reservations', {
method: 'POST',
body: JSON.stringify(data)
});
if (result.booking_number) {
alert(`Reservierung erstellt: ${result.booking_number}`);
closeModal();
loadDashboard();
} else {
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'));
}
}
// Placeholder functions
function loadFloorplan() { console.log('Floorplan loading...'); }
function loadGuests() { console.log('Guests loading...'); }
function loadEmails() { console.log('Emails loading...'); }
function filterEmails(status) { console.log('Filter:', status); }
function searchGuests() { console.log('Search guests...'); }
function openNewGuest() { console.log('New guest...'); }
function searchGuestForReservation() { console.log('Search guest...'); }
function viewReservation(id) { console.log('View:', id); }
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# Utils module
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Ollama Integration für KI-gestütztes E-Mail-Parsing"""
import json
import requests
import re
from datetime import datetime
OLLAMA_URL = "http://192.168.0.150:11434/api/generate"
DEFAULT_MODEL = "gemma4:latest"
def query_ollama(prompt, model=DEFAULT_MODEL, temperature=0.1):
"""Ollama API aufrufen"""
try:
response = requests.post(
OLLAMA_URL,
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": 500
}
},
timeout=30
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def extract_json_from_response(text):
"""JSON aus Ollama-Antwort extrahieren"""
# Versuche direkt als JSON zu parsen
try:
return json.loads(text)
except:
pass
# Suche nach JSON-Block
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
return json.loads(json_match.group())
except:
pass
return {"error": "Kein gültiges JSON gefunden", "raw": text}
def parse_email_with_ollama(email_subject, email_body, sender_name):
"""E-Mail mit Ollama parsen"""
prompt = f"""Analysiere diese Restaurant-Reservierungs-E-Mail.
WICHTIG: Suche nach einer Buchungsnummer im Format RES-YYYY-MM-DD-XXX.
Wenn eine Buchungsnummer erwähnt wird, ist es eine Änderung oder Stornierung.
Wenn keine Buchungsnummer vorhanden ist, ist es eine neue Reservierung.
Absender: {sender_name}
Betreff: {email_subject}
E-Mail-Inhalt:
---
{email_body}
---
Extrahiere folgende Informationen und antworte NUR mit JSON:
{{
"booking_number": "RES-2025-05-12-001" oder null,
"intent": "new" | "modification" | "cancellation",
"name": "Name des Gastes",
"phone": "Telefonnummer",
"email": "E-Mail-Adresse",
"date": "YYYY-MM-DD",
"time": "HH:MM",
"guests": 4,
"occasion": "Geburtstag/Dinner/etc. oder null",
"notes": "Weitere Informationen",
"confidence": 0.95,
"needs_review": false,
"review_reason": null
}}
Beispiele für Änderungen:
- "Ich möchte meine Reservierung RES-2025-05-12-001 verschieben"
- "Bitte stornieren Sie meinen Tisch"
- "Wir sind jetzt 6 statt 4 Personen"
Antworte nur mit dem JSON-Objekt, keine Erklärungen."""
result = query_ollama(prompt)
if "error" in result:
return {
"error": result["error"],
"needs_review": True,
"review_reason": "Ollama-Fehler"
}
response_text = result.get("response", "")
parsed = extract_json_from_response(response_text)
# Validiere und ergänze
if "confidence" not in parsed:
parsed["confidence"] = 0.5
if "needs_review" not in parsed:
parsed["needs_review"] = parsed.get("confidence", 0) < 0.7
return parsed
def generate_confirmation_email(reservation, is_new=True):
"""Bestätigungsmail-Text generieren"""
if is_new:
template = f"""Hallo {reservation.get('name', 'Gast')},
vielen Dank für Ihre Reservierung bei uns!
━━━━━━━━━━━━━━━━━━━━━━━
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
━━━━━━━━━━━━━━━━━━━━━━━
📅 Datum: {reservation.get('date', 'unbekannt')}
⏰ Uhrzeit: {reservation.get('time_from', 'unbekannt')}
👥 Personen: {reservation.get('guests', 'unbekannt')}
🍽️ Tisch: {reservation.get('table_name', 'wird zugewiesen')}
Bei Änderungen antworten Sie einfach auf diese E-Mail
und nennen Sie Ihre Buchungsnummer: {reservation['booking_number']}
Wir freuen uns auf Ihren Besuch!
Mit freundlichen Grüßen
Ihr Restaurant-Team"""
else:
template = f"""Hallo {reservation.get('name', 'Gast')},
Ihre Reservierung wurde aktualisiert.
━━━━━━━━━━━━━━━━━━━━━━━
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
━━━━━━━━━━━━━━━━━━━━━━━
📅 Neuer Termin: {reservation.get('date', 'unbekannt')} um {reservation.get('time_from', 'unbekannt')}
👥 Personen: {reservation.get('guests', 'unbekannt')}
Alle anderen Details bleiben bestehen.
Bei weiteren Änderungen nennen Sie bitte immer Ihre Buchungsnummer: {reservation['booking_number']}
Mit freundlichen Grüßen
Ihr Restaurant-Team"""
return template
def suggest_table_for_group(available_tables, guests, date, time, ollama_model=DEFAULT_MODEL):
"""Ollama schlägt optimalen Tisch vor"""
tables_info = "\n".join([
f"- {t['name']}: {t['seats']} Plätze, Bereich: {t.get('area_name', 'unbekannt')}"
for t in available_tables
])
prompt = f"""Schlage den besten Tisch für diese Reservierung vor:
Gruppe: {guests} Personen
Datum: {date}
Uhrzeit: {time}
Verfügbare Tische:
{tables_info}
Berücksichtige:
- Passende Größe für {guests} Personen
- Atmosphäre (Fenster bevorzugt)
- Nicht zu groß oder zu klein
Antworte mit JSON:
{{
"suggested_table_id": 5,
"reasoning": "Tisch 5 hat 6 Plätze, ist am Fenster und ideal für {guests} Personen",
"alternatives": [3, 7]
}}"""
result = query_ollama(prompt, model=ollama_model)
if "error" in result:
return None
return extract_json_from_response(result.get("response", ""))
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"log"
"net/http"
"os"
"reservation-system/backend/internal/api"
"reservation-system/backend/internal/auth"
"reservation-system/backend/internal/db"
"reservation-system/backend/internal/email"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Initialize JWT
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "your-secret-key-change-in-production"
}
auth.InitJWTSecret(jwtSecret)
// Initialize database
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "/data/reservations.db"
}
database, err := db.New(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create default admin user if none exists
createDefaultUser(database)
// Start email processor
emailProcessor := email.NewEmailProcessor(database)
emailProcessor.Start()
defer emailProcessor.Stop()
// Setup API handlers
handler := api.NewHandler(database)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Static files
fs := http.FileServer(http.Dir("/app/frontend"))
mux.Handle("/", fs)
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
if err := http.ListenAndServe(":"+port, corsMiddleware(mux)); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func createDefaultUser(database *db.DB) {
adminUser, _ := database.GetUserByUsername("admin")
if adminUser == nil {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
user := &db.models.User{
Username: "admin",
Password: string(hashedPassword),
IsAdmin: true,
}
if err := database.CreateUser(user); err != nil {
log.Printf("Failed to create default user: %v", err)
} else {
log.Println("Created default admin user (admin/admin)")
}
}
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
+773
View File
@@ -0,0 +1,773 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"reservation-system/backend/internal/auth"
"reservation-system/backend/internal/db"
"reservation-system/backend/internal/models"
"golang.org/x/crypto/bcrypt"
)
type Handler struct {
db *db.DB
}
func NewHandler(database *db.DB) *Handler {
return &Handler{db: database}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Auth
mux.HandleFunc("/api/auth/login", h.handleLogin)
mux.HandleFunc("/api/auth/refresh", h.handleRefresh)
// Rooms (protected)
mux.HandleFunc("/api/rooms", h.authMiddleware(h.handleRooms))
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomDetail))
// Tables (protected)
mux.HandleFunc("/api/tables", h.authMiddleware(h.handleTables))
mux.HandleFunc("/api/tables/", h.authMiddleware(h.handleTableDetail))
mux.HandleFunc("/api/tables/merge", h.authMiddleware(h.handleMergeTables))
// NEW: Room tables endpoint
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomTables))
// Reservations (protected)
mux.HandleFunc("/api/reservations", h.authMiddleware(h.handleReservations))
mux.HandleFunc("/api/reservations/", h.authMiddleware(h.handleReservationDetail))
mux.HandleFunc("/api/reservations/date/", h.authMiddleware(h.handleReservationsByDate))
// NEW: Room Bookings
mux.HandleFunc("/api/room-bookings", h.authMiddleware(h.handleRoomBookings))
mux.HandleFunc("/api/room-bookings/", h.authMiddleware(h.handleRoomBookingDetail))
// Availability
mux.HandleFunc("/api/availability", h.authMiddleware(h.handleAvailability))
mux.HandleFunc("/api/availability/room", h.authMiddleware(h.handleRoomAvailability))
// Email config
mux.HandleFunc("/api/email-config", h.authMiddleware(h.handleEmailConfig))
// Dashboard stats
mux.HandleFunc("/api/dashboard", h.authMiddleware(h.handleDashboard))
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := h.db.GetUserByUsername(req.Username)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if user == nil || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := auth.GenerateToken(user.Username, user.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": token,
"user": user.Username,
})
}
func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenString)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
newToken, err := auth.GenerateToken(claims.Username, claims.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": newToken,
})
}
func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := auth.ValidateToken(tokenString); err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// Rooms Handler
func (h *Handler) handleRooms(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rooms)
case http.MethodPost:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if room.Name == "" {
http.Error(w, "Name required", http.StatusBadRequest)
return
}
if err := h.db.CreateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(room)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomDetail(w http.ResponseWriter, r *http.Request) {
// Check if it's /api/rooms/:id/tables
path := r.URL.Path
if strings.HasSuffix(path, "/tables") {
h.handleRoomTables(w, r)
return
}
idStr := strings.TrimPrefix(path, "/api/rooms/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
room, err := h.db.GetRoom(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodPut:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
room.ID = id
if err := h.db.UpdateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodDelete:
if err := h.db.DeleteRoom(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// NEW: Room Tables Handler - GET /api/rooms/:id/tables
func (h *Handler) handleRoomTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := r.URL.Path
idStr := strings.TrimSuffix(strings.TrimPrefix(path, "/api/rooms/"), "/tables")
roomID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid room ID", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
tables, err := h.db.GetTablesByRoom(roomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
} else {
// POST - create table for room
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.RoomID = roomID
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
}
}
// Tables Handler
func (h *Handler) handleTables(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
var tables []models.Table
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
tables, err = h.db.GetTablesByRoom(rid)
} else {
tables, err = h.db.GetAllTables()
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
case http.MethodPost:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleTableDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tables/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
table, err := h.db.GetTable(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodPut:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.ID = id
if err := h.db.UpdateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodDelete:
if err := h.db.DeleteTable(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Reservations Handler
func (h *Handler) handleReservations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
case http.MethodPost:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check availability
available, err := h.db.CheckAvailability(res.TableID, res.Date, res.TimeFrom, res.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Table not available at this time", http.StatusConflict)
return
}
// Check table capacity
table, err := h.db.GetTable(res.TableID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table != nil && res.Guests > table.MaxGuests {
http.Error(w, "Guest count exceeds table capacity", http.StatusBadRequest)
return
}
if res.Source == "" {
res.Source = "manual"
}
if err := h.db.CreateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(res)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/reservations/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
res, err := h.db.GetReservation(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if res == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodPut:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
res.ID = id
if err := h.db.UpdateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodDelete:
if err := h.db.DeleteReservation(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationsByDate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := strings.TrimPrefix(r.URL.Path, "/api/reservations/date/")
if date == "" {
http.Error(w, "Date required", http.StatusBadRequest)
return
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
}
// NEW: Room Bookings Handlers
func (h *Handler) handleRoomBookings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
date := r.URL.Query().Get("date")
var bookings []models.RoomBooking
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
bookings, err = h.db.GetRoomBookingsByRoom(rid)
} else if date != "" {
bookings, err = h.db.GetRoomBookingsByDate(date)
} else {
// Get all bookings for today
bookings, err = h.db.GetRoomBookingsByDate(time.Now().Format("2006-01-02"))
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(bookings)
case http.MethodPost:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check room availability
available, err := h.db.CheckRoomAvailability(rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Room not available at this time", http.StatusConflict)
return
}
// Check room capacity
room, err := h.db.GetRoom(rb.RoomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room != nil && rb.Guests > room.Capacity {
http.Error(w, "Guest count exceeds room capacity", http.StatusBadRequest)
return
}
if rb.Status == "" {
rb.Status = "confirmed"
}
if err := h.db.CreateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(rb)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomBookingDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/room-bookings/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
rb, err := h.db.GetRoomBooking(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rb == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodPut:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
rb.ID = id
if err := h.db.UpdateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodDelete:
if err := h.db.DeleteRoomBooking(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Availability Handler
func (h *Handler) handleAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
tables, err := h.db.GetAllTables()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, table := range tables {
reservations, _ := h.db.GetReservationsByTable(table.ID, date)
availability = append(availability, map[string]interface{}{
"table_id": table.ID,
"table_name": table.RoomName + " - Tisch " + strconv.Itoa(table.ID),
"max_guests": table.MaxGuests,
"reservations": reservations,
})
}
json.NewEncoder(w).Encode(availability)
}
// NEW: Room Availability Handler
func (h *Handler) handleRoomAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, room := range rooms {
bookings, _ := h.db.GetRoomBookingsByRoom(room.ID)
availability = append(availability, map[string]interface{}{
"room_id": room.ID,
"room_name": room.Name,
"capacity": room.Capacity,
"bookings": bookings,
})
}
json.NewEncoder(w).Encode(availability)
}
func (h *Handler) handleMergeTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ParentTableID int `json:"parent_table_id"`
ChildTableID int `json:"child_table_id"`
MergedName string `json:"merged_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
parentTable, err := h.db.GetTable(req.ParentTableID)
if err != nil || parentTable == nil {
http.Error(w, "Parent table not found", http.StatusBadRequest)
return
}
childTable, err := h.db.GetTable(req.ChildTableID)
if err != nil || childTable == nil {
http.Error(w, "Child table not found", http.StatusBadRequest)
return
}
if parentTable.RoomID != childTable.RoomID {
http.Error(w, "Tables must be in the same room", http.StatusBadRequest)
return
}
merge := models.TableMerge{
ParentTableID: req.ParentTableID,
ChildTableID: req.ChildTableID,
MergedName: req.MergedName,
Active: true,
}
if err := h.db.CreateTableMerge(&merge); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(merge)
}
func (h *Handler) handleEmailConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := h.db.GetEmailConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if cfg != nil {
cfg.Password = "***"
}
json.NewEncoder(w).Encode(cfg)
case http.MethodPost, http.MethodPut:
var cfg models.EmailConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := h.db.SaveEmailConfig(&cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(cfg)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
today := time.Now().Format("2006-01-02")
totalGuests, _ := h.db.GetTotalGuestsForDate(today)
reservations, _ := h.db.GetReservationsByDate(today)
roomBookings, _ := h.db.GetRoomBookingsByDate(today)
dashboard := map[string]interface{}{
"today": today,
"total_guests": totalGuests,
"reservation_count": len(reservations),
"room_booking_count": len(roomBookings),
"reservations": reservations,
"room_bookings": roomBookings,
}
json.NewEncoder(w).Encode(dashboard)
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret []byte
func InitJWTSecret(secret string) {
jwtSecret = []byte(secret)
}
type Claims struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
func GenerateToken(username string, isAdmin bool) (string, error) {
claims := Claims{
Username: username,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
+616
View File
@@ -0,0 +1,616 @@
package db
import (
"database/sql"
"fmt"
"reservation-system/backend/internal/models"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.Migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER NOT NULL DEFAULT 0,
color TEXT DEFAULT '#3b82f6',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
max_guests INTEGER NOT NULL,
shape TEXT DEFAULT 'rect',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source TEXT DEFAULT 'manual',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS room_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
event_type TEXT,
notes TEXT,
status TEXT DEFAULT 'confirmed',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS table_merges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_table_id INTEGER NOT NULL,
child_table_id INTEGER NOT NULL,
merged_name TEXT,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE,
FOREIGN KEY (child_table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS email_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
port INTEGER DEFAULT 993,
user TEXT NOT NULL,
password TEXT NOT NULL,
ssl BOOLEAN DEFAULT 1,
folder TEXT DEFAULT 'INBOX'
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_table ON reservations(table_id);
CREATE INDEX IF NOT EXISTS idx_tables_room ON tables(room_id);
CREATE INDEX IF NOT EXISTS idx_reservations_time ON reservations(time_from, time_to);
CREATE INDEX IF NOT EXISTS idx_room_bookings_date ON room_bookings(date);
CREATE INDEX IF NOT EXISTS idx_room_bookings_room ON room_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_room_bookings_time ON room_bookings(time_from, time_to);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
// Room Methods
func (db *DB) CreateRoom(room *models.Room) error {
result, err := db.conn.Exec(
"INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)",
room.Name, room.Capacity, room.Color,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
room.ID = int(id)
room.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRooms() ([]models.Room, error) {
rows, err := db.conn.Query("SELECT id, name, capacity, color, created_at FROM rooms ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var rooms []models.Room
for rows.Next() {
var r models.Room
if err := rows.Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt); err != nil {
return nil, err
}
rooms = append(rooms, r)
}
return rooms, nil
}
func (db *DB) GetRoom(id int) (*models.Room, error) {
var r models.Room
err := db.conn.QueryRow(
"SELECT id, name, capacity, color, created_at FROM rooms WHERE id = ?",
id,
).Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateRoom(room *models.Room) error {
_, err := db.conn.Exec(
"UPDATE rooms SET name = ?, capacity = ?, color = ? WHERE id = ?",
room.Name, room.Capacity, room.Color, room.ID,
)
return err
}
func (db *DB) DeleteRoom(id int) error {
_, err := db.conn.Exec("DELETE FROM rooms WHERE id = ?", id)
return err
}
// Table Methods
func (db *DB) CreateTable(table *models.Table) error {
result, err := db.conn.Exec(
"INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
table.ID = int(id)
table.CreatedAt = time.Now()
return nil
}
func (db *DB) GetTablesByRoom(roomID int) ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.room_id = ? AND t.status = 'active'
ORDER BY t.id`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetAllTables() ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.status = 'active'
ORDER BY t.room_id, t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetTable(id int) (*models.Table, error) {
var t models.Table
err := db.conn.QueryRow(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.id = ?`, id).Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &t, err
}
func (db *DB) UpdateTable(table *models.Table) error {
_, err := db.conn.Exec(
"UPDATE tables SET room_id = ?, x = ?, y = ?, width = ?, height = ?, max_guests = ?, shape = ? WHERE id = ?",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.ID,
)
return err
}
func (db *DB) DeleteTable(id int) error {
_, err := db.conn.Exec("UPDATE tables SET status = 'deleted' WHERE id = ?", id)
return err
}
// Reservation Methods
func (db *DB) CreateReservation(r *models.Reservation) error {
result, err := db.conn.Exec(
"INSERT INTO reservations (table_id, date, time_from, time_to, guests, name, phone, email, source, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Source, r.Notes,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
r.ID = int(id)
r.CreatedAt = time.Now()
return nil
}
func (db *DB) GetReservationsByDate(date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT r.id, r.table_id, r.date, r.time_from, r.time_to, r.guests, r.name, r.phone, r.email, r.source, r.notes, r.created_at, r.table_id as table_name
FROM reservations r
WHERE r.date = ?
ORDER BY r.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt, &res.TableName); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservationsByTable(tableID int, date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations
WHERE table_id = ? AND date = ?
ORDER BY time_from`, tableID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservation(id int) (*models.Reservation, error) {
var r models.Reservation
err := db.conn.QueryRow(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations WHERE id = ?`, id).Scan(&r.ID, &r.TableID, &r.Date, &r.TimeFrom, &r.TimeTo, &r.Guests, &r.Name, &r.Phone, &r.Email, &r.Source, &r.Notes, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateReservation(r *models.Reservation) error {
_, err := db.conn.Exec(
"UPDATE reservations SET table_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, notes = ? WHERE id = ?",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Notes, r.ID,
)
return err
}
func (db *DB) DeleteReservation(id int) error {
_, err := db.conn.Exec("DELETE FROM reservations WHERE id = ?", id)
return err
}
// Room Booking Methods (NEW)
func (db *DB) CreateRoomBooking(rb *models.RoomBooking) error {
result, err := db.conn.Exec(
"INSERT INTO room_bookings (room_id, date, time_from, time_to, guests, name, phone, email, event_type, notes, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
rb.ID = int(id)
rb.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRoomBookingsByRoom(roomID int) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.room_id = ?
ORDER BY rb.date DESC, rb.time_from`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBookingsByDate(date string) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.date = ?
ORDER BY rb.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBooking(id int) (*models.RoomBooking, error) {
var rb models.RoomBooking
err := db.conn.QueryRow(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.id = ?`, id).Scan(&rb.ID, &rb.RoomID, &rb.Date, &rb.TimeFrom, &rb.TimeTo, &rb.Guests, &rb.Name, &rb.Phone, &rb.Email, &rb.EventType, &rb.Notes, &rb.Status, &rb.CreatedAt, &rb.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &rb, err
}
func (db *DB) UpdateRoomBooking(rb *models.RoomBooking) error {
_, err := db.conn.Exec(
"UPDATE room_bookings SET room_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, event_type = ?, notes = ?, status = ? WHERE id = ?",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status, rb.ID,
)
return err
}
func (db *DB) DeleteRoomBooking(id int) error {
_, err := db.conn.Exec("DELETE FROM room_bookings WHERE id = ?", id)
return err
}
// Availability Checks
func (db *DB) CheckAvailability(tableID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM reservations
WHERE table_id = ? AND date = ?
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, tableID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) CheckRoomAvailability(roomID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM room_bookings
WHERE room_id = ? AND date = ? AND status = 'confirmed'
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, roomID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) GetTableMerges(parentTableID int) ([]models.TableMerge, error) {
rows, err := db.conn.Query(`
SELECT id, parent_table_id, child_table_id, merged_name, active, created_at
FROM table_merges
WHERE parent_table_id = ? AND active = 1`, parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var merges []models.TableMerge
for rows.Next() {
var m models.TableMerge
if err := rows.Scan(&m.ID, &m.ParentTableID, &m.ChildTableID, &m.MergedName, &m.Active, &m.CreatedAt); err != nil {
return nil, err
}
merges = append(merges, m)
}
return merges, nil
}
func (db *DB) UnmergeTables(mergeID int) error {
var childID int
err := db.conn.QueryRow("SELECT child_table_id FROM table_merges WHERE id = ?", mergeID).Scan(&childID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE table_merges SET active = 0 WHERE id = ?", mergeID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE tables SET status = 'active' WHERE id = ?", childID)
return err
}
func (db *DB) GetEmailConfig() (*models.EmailConfig, error) {
var cfg models.EmailConfig
err := db.conn.QueryRow("SELECT id, host, port, user, password, ssl, folder FROM email_config LIMIT 1").Scan(
&cfg.ID, &cfg.Host, &cfg.Port, &cfg.Username, &cfg.Password, &cfg.SSL, &cfg.Folder,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &cfg, err
}
func (db *DB) SaveEmailConfig(cfg *models.EmailConfig) error {
if cfg.ID > 0 {
_, err := db.conn.Exec(
"UPDATE email_config SET host = ?, port = ?, user = ?, password = ?, ssl = ?, folder = ? WHERE id = ?",
cfg.Host, cfg.Port, &cfg.Username, cfg.Password, cfg.SSL, cfg.Folder, cfg.ID,
)
return err
}
result, err := db.conn.Exec(
"INSERT INTO email_config (host, port, user, password, ssl, folder) VALUES (?, ?, ?, ?, ?, ?)",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.SSL, cfg.Folder,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
cfg.ID = int(id)
return nil
}
func (db *DB) GetUserByUsername(username string) (*models.User, error) {
var u models.User
err := db.conn.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username).Scan(
&u.ID, &u.Username, &u.Password, &u.IsAdmin,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) CreateUser(user *models.User) error {
result, err := db.conn.Exec(
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
user.Username, user.Password, user.IsAdmin,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
user.ID = int(id)
return nil
}
func (db *DB) GetMergedTablesFor(parentTableID int) ([]int, error) {
rows, err := db.conn.Query("SELECT child_table_id FROM table_merges WHERE parent_table_id = ? AND active = 1", parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func (db *DB) GetTotalGuestsForDate(date string) (int, error) {
var total int
err := db.conn.QueryRow("SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ?", date).Scan(&total)
return total, err
}
func (db *DB) CreateTableMerge(merge *models.TableMerge) error {
result, err := db.conn.Exec(
"INSERT INTO table_merges (parent_table_id, child_table_id, merged_name, active) VALUES (?, ?, ?, ?)",
merge.ParentTableID, merge.ChildTableID, merge.MergedName, merge.Active,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
merge.ID = int(id)
merge.CreatedAt = time.Now()
// Mark child table as merged
_, err = db.conn.Exec("UPDATE tables SET status = 'merged' WHERE id = ?", merge.ChildTableID)
return err
}
+616
View File
@@ -0,0 +1,616 @@
package db
import (
"database/sql"
"fmt"
"reservation-system/backend/internal/models"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.Migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER NOT NULL DEFAULT 0,
color TEXT DEFAULT '#3b82f6',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
max_guests INTEGER NOT NULL,
shape TEXT DEFAULT 'rect',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source TEXT DEFAULT 'manual',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS room_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
event_type TEXT,
notes TEXT,
status TEXT DEFAULT 'confirmed',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS table_merges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_table_id INTEGER NOT NULL,
child_table_id INTEGER NOT NULL,
merged_name TEXT,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE,
FOREIGN KEY (child_table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS email_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
port INTEGER DEFAULT 993,
user TEXT NOT NULL,
password TEXT NOT NULL,
ssl BOOLEAN DEFAULT 1,
folder TEXT DEFAULT 'INBOX'
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_table ON reservations(table_id);
CREATE INDEX IF NOT EXISTS idx_tables_room ON tables(room_id);
CREATE INDEX IF NOT EXISTS idx_reservations_time ON reservations(time_from, time_to);
CREATE INDEX IF NOT EXISTS idx_room_bookings_date ON room_bookings(date);
CREATE INDEX IF NOT EXISTS idx_room_bookings_room ON room_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_room_bookings_time ON room_bookings(time_from, time_to);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
// Room Methods
func (db *DB) CreateRoom(room *models.Room) error {
result, err := db.conn.Exec(
"INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)",
room.Name, room.Capacity, room.Color,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
room.ID = int(id)
room.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRooms() ([]models.Room, error) {
rows, err := db.conn.Query("SELECT id, name, capacity, color, created_at FROM rooms ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var rooms []models.Room
for rows.Next() {
var r models.Room
if err := rows.Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt); err != nil {
return nil, err
}
rooms = append(rooms, r)
}
return rooms, nil
}
func (db *DB) GetRoom(id int) (*models.Room, error) {
var r models.Room
err := db.conn.QueryRow(
"SELECT id, name, capacity, color, created_at FROM rooms WHERE id = ?",
id,
).Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateRoom(room *models.Room) error {
_, err := db.conn.Exec(
"UPDATE rooms SET name = ?, capacity = ?, color = ? WHERE id = ?",
room.Name, room.Capacity, room.Color, room.ID,
)
return err
}
func (db *DB) DeleteRoom(id int) error {
_, err := db.conn.Exec("DELETE FROM rooms WHERE id = ?", id)
return err
}
// Table Methods
func (db *DB) CreateTable(table *models.Table) error {
result, err := db.conn.Exec(
"INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
table.ID = int(id)
table.CreatedAt = time.Now()
return nil
}
func (db *DB) GetTablesByRoom(roomID int) ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.room_id = ? AND t.status = 'active'
ORDER BY t.id`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetAllTables() ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.status = 'active'
ORDER BY t.room_id, t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetTable(id int) (*models.Table, error) {
var t models.Table
err := db.conn.QueryRow(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.id = ?`, id).Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &t, err
}
func (db *DB) UpdateTable(table *models.Table) error {
_, err := db.conn.Exec(
"UPDATE tables SET room_id = ?, x = ?, y = ?, width = ?, height = ?, max_guests = ?, shape = ? WHERE id = ?",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.ID,
)
return err
}
func (db *DB) DeleteTable(id int) error {
_, err := db.conn.Exec("UPDATE tables SET status = 'deleted' WHERE id = ?", id)
return err
}
// Reservation Methods
func (db *DB) CreateReservation(r *models.Reservation) error {
result, err := db.conn.Exec(
"INSERT INTO reservations (table_id, date, time_from, time_to, guests, name, phone, email, source, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Source, r.Notes,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
r.ID = int(id)
r.CreatedAt = time.Now()
return nil
}
func (db *DB) GetReservationsByDate(date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT r.id, r.table_id, r.date, r.time_from, r.time_to, r.guests, r.name, r.phone, r.email, r.source, r.notes, r.created_at, r.table_id as table_name
FROM reservations r
WHERE r.date = ?
ORDER BY r.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt, &res.TableName); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservationsByTable(tableID int, date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations
WHERE table_id = ? AND date = ?
ORDER BY time_from`, tableID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservation(id int) (*models.Reservation, error) {
var r models.Reservation
err := db.conn.QueryRow(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations WHERE id = ?`, id).Scan(&r.ID, &r.TableID, &r.Date, &r.TimeFrom, &r.TimeTo, &r.Guests, &r.Name, &r.Phone, &r.Email, &r.Source, &r.Notes, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateReservation(r *models.Reservation) error {
_, err := db.conn.Exec(
"UPDATE reservations SET table_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, notes = ? WHERE id = ?",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Notes, r.ID,
)
return err
}
func (db *DB) DeleteReservation(id int) error {
_, err := db.conn.Exec("DELETE FROM reservations WHERE id = ?", id)
return err
}
// Room Booking Methods (NEW)
func (db *DB) CreateRoomBooking(rb *models.RoomBooking) error {
result, err := db.conn.Exec(
"INSERT INTO room_bookings (room_id, date, time_from, time_to, guests, name, phone, email, event_type, notes, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
rb.ID = int(id)
rb.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRoomBookingsByRoom(roomID int) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.room_id = ?
ORDER BY rb.date DESC, rb.time_from`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBookingsByDate(date string) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.date = ?
ORDER BY rb.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBooking(id int) (*models.RoomBooking, error) {
var rb models.RoomBooking
err := db.conn.QueryRow(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.id = ?`, id).Scan(&rb.ID, &rb.RoomID, &rb.Date, &rb.TimeFrom, &rb.TimeTo, &rb.Guests, &rb.Name, &rb.Phone, &rb.Email, &rb.EventType, &rb.Notes, &rb.Status, &rb.CreatedAt, &rb.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &rb, err
}
func (db *DB) UpdateRoomBooking(rb *models.RoomBooking) error {
_, err := db.conn.Exec(
"UPDATE room_bookings SET room_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, event_type = ?, notes = ?, status = ? WHERE id = ?",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status, rb.ID,
)
return err
}
func (db *DB) DeleteRoomBooking(id int) error {
_, err := db.conn.Exec("DELETE FROM room_bookings WHERE id = ?", id)
return err
}
// Availability Checks
func (db *DB) CheckAvailability(tableID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM reservations
WHERE table_id = ? AND date = ?
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, tableID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) CheckRoomAvailability(roomID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM room_bookings
WHERE room_id = ? AND date = ? AND status = 'confirmed'
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, roomID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) GetTableMerges(parentTableID int) ([]models.TableMerge, error) {
rows, err := db.conn.Query(`
SELECT id, parent_table_id, child_table_id, merged_name, active, created_at
FROM table_merges
WHERE parent_table_id = ? AND active = 1`, parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var merges []models.TableMerge
for rows.Next() {
var m models.TableMerge
if err := rows.Scan(&m.ID, &m.ParentTableID, &m.ChildTableID, &m.MergedName, &m.Active, &m.CreatedAt); err != nil {
return nil, err
}
merges = append(merges, m)
}
return merges, nil
}
func (db *DB) UnmergeTables(mergeID int) error {
var childID int
err := db.conn.QueryRow("SELECT child_table_id FROM table_merges WHERE id = ?", mergeID).Scan(&childID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE table_merges SET active = 0 WHERE id = ?", mergeID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE tables SET status = 'active' WHERE id = ?", childID)
return err
}
func (db *DB) GetEmailConfig() (*models.EmailConfig, error) {
var cfg models.EmailConfig
err := db.conn.QueryRow("SELECT id, host, port, user, password, ssl, folder FROM email_config LIMIT 1").Scan(
&cfg.ID, &cfg.Host, &cfg.Port, &cfg.Username, &cfg.Password, &cfg.SSL, &cfg.Folder,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &cfg, err
}
func (db *DB) SaveEmailConfig(cfg *models.EmailConfig) error {
if cfg.ID > 0 {
_, err := db.conn.Exec(
"UPDATE email_config SET host = ?, port = ?, user = ?, password = ?, ssl = ?, folder = ? WHERE id = ?",
cfg.Host, cfg.Port, &cfg.Username, cfg.Password, cfg.SSL, cfg.Folder, cfg.ID,
)
return err
}
result, err := db.conn.Exec(
"INSERT INTO email_config (host, port, user, password, ssl, folder) VALUES (?, ?, ?, ?, ?, ?)",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.SSL, cfg.Folder,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
cfg.ID = int(id)
return nil
}
func (db *DB) GetUserByUsername(username string) (*models.User, error) {
var u models.User
err := db.conn.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username).Scan(
&u.ID, &u.Username, &u.Password, &u.IsAdmin,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) CreateUser(user *models.User) error {
result, err := db.conn.Exec(
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
user.Username, user.Password, user.IsAdmin,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
user.ID = int(id)
return nil
}
func (db *DB) GetMergedTablesFor(parentTableID int) ([]int, error) {
rows, err := db.conn.Query("SELECT child_table_id FROM table_merges WHERE parent_table_id = ? AND active = 1", parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func (db *DB) GetTotalGuestsForDate(date string) (int, error) {
var total int
err := db.conn.QueryRow("SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ?", date).Scan(&total)
return total, err
}
func (db *DB) CreateTableMerge(merge *models.TableMerge) error {
result, err := db.conn.Exec(
"INSERT INTO table_merges (parent_table_id, child_table_id, merged_name, active) VALUES (?, ?, ?, ?)",
merge.ParentTableID, merge.ChildTableID, merge.MergedName, merge.Active,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
merge.ID = int(id)
merge.CreatedAt = time.Now()
// Mark child table as merged
_, err = db.conn.Exec("UPDATE tables SET status = 'merged' WHERE id = ?", merge.ChildTableID)
return err
}
+290
View File
@@ -0,0 +1,290 @@
package email
import (
"crypto/tls"
"fmt"
"io"
"log"
"regexp"
"strings"
"time"
"reservation-system/backend/internal/db"
"reservation-system/backend/internal/models"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
)
type EmailProcessor struct {
db *db.DB
running bool
stop chan bool
}
func NewEmailProcessor(database *db.DB) *EmailProcessor {
return &EmailProcessor{
db: database,
stop: make(chan bool),
}
}
func (ep *EmailProcessor) Start() {
if ep.running {
return
}
ep.running = true
go ep.pollLoop()
}
func (ep *EmailProcessor) Stop() {
ep.running = false
close(ep.stop)
}
func (ep *EmailProcessor) pollLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
// Run immediately on start
ep.processEmails()
for {
select {
case <-ticker.C:
if !ep.running {
return
}
ep.processEmails()
case <-ep.stop:
return
}
}
}
func (ep *EmailProcessor) processEmails() error {
cfg, err := ep.db.GetEmailConfig()
if err != nil {
log.Printf("Email config error: %v", err)
return err
}
if cfg == nil {
log.Println("No email config found")
return nil
}
// Connect to IMAP server
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var c *client.Client
if cfg.SSL {
c, err = client.DialTLS(addr, &tls.Config{InsecureSkipVerify: true})
} else {
c, err = client.Dial(addr)
}
if err != nil {
log.Printf("IMAP connect error: %v", err)
return err
}
defer c.Logout()
// Login
if err := c.Login(cfg.Username, cfg.Password); err != nil {
log.Printf("IMAP login error: %v", err)
return err
}
// Select inbox
mbox, err := c.Select(cfg.Folder, false)
if err != nil {
log.Printf("IMAP select error: %v", err)
return err
}
if mbox.Messages == 0 {
return nil
}
// Fetch unread messages
criteria := imap.NewSearchCriteria()
criteria.WithoutFlags = []string{imap.SeenFlag}
uids, err := c.UidSearch(criteria)
if err != nil {
log.Printf("IMAP search error: %v", err)
return err
}
if len(uids) == 0 {
return nil
}
for _, uid := range uids {
ep.processMessage(c, uid)
}
return nil
}
func (ep *EmailProcessor) processMessage(c *client.Client, uid uint32) {
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
section := &imap.BodySectionName{}
items := []imap.FetchItem{section.FetchItem()}
messages := make(chan *imap.Message, 1)
go func() {
if err := c.UidFetch(seqSet, items, messages); err != nil {
log.Printf("Fetch error: %v", err)
}
}()
msg := <-messages
if msg == nil {
return
}
r := msg.GetBody(section)
if r == nil {
return
}
body, err := io.ReadAll(r)
if err != nil {
log.Printf("Read error: %v", err)
return
}
// Parse reservation from email
reservation := ep.parseReservation(string(body))
if reservation != nil {
// Find available table
tables, _ := ep.db.GetAllTables()
for _, table := range tables {
available, _ := ep.db.CheckAvailability(table.ID, reservation.Date, reservation.TimeFrom, reservation.TimeTo)
if available && table.MaxGuests >= reservation.Guests {
reservation.TableID = table.ID
reservation.Source = "email"
if err := ep.db.CreateReservation(reservation); err != nil {
log.Printf("Create reservation error: %v", err)
}
break
}
}
}
// Mark as seen
seqSet2 := new(imap.SeqSet)
seqSet2.AddNum(uid)
c.UidStore(seqSet2, imap.FormatFlagsAdd, []string{imap.SeenFlag})
}
func (ep *EmailProcessor) parseReservation(body string) *models.Reservation {
res := &models.Reservation{}
parsed := false
// Try to extract date
datePatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(?:am|den)\s+(\d{1,2})[./](\d{1,2})[./](\d{2,4})`),
regexp.MustCompile(`(?i)(\d{1,2})[./](\d{1,2})[./](\d{2,4})`),
regexp.MustCompile(`(?i)(\d{4})-(\d{2})-(\d{2})`),
}
for _, pattern := range datePatterns {
if matches := pattern.FindStringSubmatch(body); matches != nil {
if len(matches) >= 4 {
if len(matches[3]) == 2 {
res.Date = fmt.Sprintf("20%s-%s-%s", matches[3], matches[2], matches[1])
} else {
res.Date = fmt.Sprintf("%s-%s-%s", matches[3], matches[2], matches[1])
}
parsed = true
break
}
}
}
// Try to extract time
timePatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(\d{1,2})[:.](\d{2})\s*[Uu]hr`),
regexp.MustCompile(`(?i)(\d{1,2})[:.](\d{2})`),
regexp.MustCompile(`(?i)(\d{1,2})\s*[Uu]hr`),
}
for _, pattern := range timePatterns {
if matches := pattern.FindStringSubmatch(body); matches != nil {
if len(matches) >= 2 {
res.TimeFrom = fmt.Sprintf("%02s:00", matches[1])
res.TimeTo = fmt.Sprintf("%02d:00", parseInt(matches[1])+2)
parsed = true
break
}
}
}
// Try to extract guest count
guestPatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(\d+)\s*(?:Personen|Gäste|Leute)`),
regexp.MustCompile(`(?i)für\s+(\d+)\s*Personen`),
regexp.MustCompile(`(?i)(\d+)\s*Personen`),
}
for _, pattern := range guestPatterns {
if matches := pattern.FindStringSubmatch(body); matches != nil {
res.Guests = parseInt(matches[1])
parsed = true
break
}
}
// Try to extract name
namePatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)Name\s*[:\-]?\s*([^\n\r]+)`),
regexp.MustCompile(`(?i)(?:von|Name)\s+([A-Z][a-z]+\s+[A-Z][a-z]+)`),
}
for _, pattern := range namePatterns {
if matches := pattern.FindStringSubmatch(body); matches != nil {
res.Name = strings.TrimSpace(matches[1])
parsed = true
break
}
}
// Try to extract phone
phonePattern := regexp.MustCompile(`(?i)Tel(?:efon)?[:\s]*([\d\s\-\+\(\)]{7,})`)
if matches := phonePattern.FindStringSubmatch(body); matches != nil {
res.Phone = strings.TrimSpace(matches[1])
}
// Try to extract email
emailPattern := regexp.MustCompile(`[\w\.-]+@[\w\.-]+\.\w+`)
if matches := emailPattern.FindStringSubmatch(body); matches != nil {
res.Email = matches[0]
}
// Try to extract notes
notePatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)Notiz(?:en)?\s*[:\-]?\s*([^\n\r]+)`),
regexp.MustCompile(`(?i)Bemerkung(?:en)?\s*[:\-]?\s*([^\n\r]+)`),
}
for _, pattern := range notePatterns {
if matches := pattern.FindStringSubmatch(body); matches != nil {
res.Notes = strings.TrimSpace(matches[1])
break
}
}
if parsed && res.Name != "" {
return res
}
return nil
}
func parseInt(s string) int {
var n int
fmt.Sscanf(s, "%d", &n)
return n
}
+773
View File
@@ -0,0 +1,773 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"reservation-system/backend/internal/auth"
"reservation-system/backend/internal/db"
"reservation-system/backend/internal/models"
"golang.org/x/crypto/bcrypt"
)
type Handler struct {
db *db.DB
}
func NewHandler(database *db.DB) *Handler {
return &Handler{db: database}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Auth
mux.HandleFunc("/api/auth/login", h.handleLogin)
mux.HandleFunc("/api/auth/refresh", h.handleRefresh)
// Rooms (protected)
mux.HandleFunc("/api/rooms", h.authMiddleware(h.handleRooms))
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomDetail))
// Tables (protected)
mux.HandleFunc("/api/tables", h.authMiddleware(h.handleTables))
mux.HandleFunc("/api/tables/", h.authMiddleware(h.handleTableDetail))
mux.HandleFunc("/api/tables/merge", h.authMiddleware(h.handleMergeTables))
// NEW: Room tables endpoint
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomTables))
// Reservations (protected)
mux.HandleFunc("/api/reservations", h.authMiddleware(h.handleReservations))
mux.HandleFunc("/api/reservations/", h.authMiddleware(h.handleReservationDetail))
mux.HandleFunc("/api/reservations/date/", h.authMiddleware(h.handleReservationsByDate))
// NEW: Room Bookings
mux.HandleFunc("/api/room-bookings", h.authMiddleware(h.handleRoomBookings))
mux.HandleFunc("/api/room-bookings/", h.authMiddleware(h.handleRoomBookingDetail))
// Availability
mux.HandleFunc("/api/availability", h.authMiddleware(h.handleAvailability))
mux.HandleFunc("/api/availability/room", h.authMiddleware(h.handleRoomAvailability))
// Email config
mux.HandleFunc("/api/email-config", h.authMiddleware(h.handleEmailConfig))
// Dashboard stats
mux.HandleFunc("/api/dashboard", h.authMiddleware(h.handleDashboard))
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := h.db.GetUserByUsername(req.Username)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if user == nil || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := auth.GenerateToken(user.Username, user.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": token,
"user": user.Username,
})
}
func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenString)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
newToken, err := auth.GenerateToken(claims.Username, claims.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": newToken,
})
}
func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := auth.ValidateToken(tokenString); err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// Rooms Handler
func (h *Handler) handleRooms(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rooms)
case http.MethodPost:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if room.Name == "" {
http.Error(w, "Name required", http.StatusBadRequest)
return
}
if err := h.db.CreateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(room)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomDetail(w http.ResponseWriter, r *http.Request) {
// Check if it's /api/rooms/:id/tables
path := r.URL.Path
if strings.HasSuffix(path, "/tables") {
h.handleRoomTables(w, r)
return
}
idStr := strings.TrimPrefix(path, "/api/rooms/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
room, err := h.db.GetRoom(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodPut:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
room.ID = id
if err := h.db.UpdateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodDelete:
if err := h.db.DeleteRoom(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// NEW: Room Tables Handler - GET /api/rooms/:id/tables
func (h *Handler) handleRoomTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := r.URL.Path
idStr := strings.TrimSuffix(strings.TrimPrefix(path, "/api/rooms/"), "/tables")
roomID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid room ID", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
tables, err := h.db.GetTablesByRoom(roomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
} else {
// POST - create table for room
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.RoomID = roomID
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
}
}
// Tables Handler
func (h *Handler) handleTables(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
var tables []models.Table
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
tables, err = h.db.GetTablesByRoom(rid)
} else {
tables, err = h.db.GetAllTables()
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
case http.MethodPost:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleTableDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tables/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
table, err := h.db.GetTable(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodPut:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.ID = id
if err := h.db.UpdateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodDelete:
if err := h.db.DeleteTable(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Reservations Handler
func (h *Handler) handleReservations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
case http.MethodPost:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check availability
available, err := h.db.CheckAvailability(res.TableID, res.Date, res.TimeFrom, res.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Table not available at this time", http.StatusConflict)
return
}
// Check table capacity
table, err := h.db.GetTable(res.TableID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table != nil && res.Guests > table.MaxGuests {
http.Error(w, "Guest count exceeds table capacity", http.StatusBadRequest)
return
}
if res.Source == "" {
res.Source = "manual"
}
if err := h.db.CreateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(res)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/reservations/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
res, err := h.db.GetReservation(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if res == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodPut:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
res.ID = id
if err := h.db.UpdateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodDelete:
if err := h.db.DeleteReservation(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationsByDate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := strings.TrimPrefix(r.URL.Path, "/api/reservations/date/")
if date == "" {
http.Error(w, "Date required", http.StatusBadRequest)
return
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
}
// NEW: Room Bookings Handlers
func (h *Handler) handleRoomBookings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
date := r.URL.Query().Get("date")
var bookings []models.RoomBooking
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
bookings, err = h.db.GetRoomBookingsByRoom(rid)
} else if date != "" {
bookings, err = h.db.GetRoomBookingsByDate(date)
} else {
// Get all bookings for today
bookings, err = h.db.GetRoomBookingsByDate(time.Now().Format("2006-01-02"))
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(bookings)
case http.MethodPost:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check room availability
available, err := h.db.CheckRoomAvailability(rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Room not available at this time", http.StatusConflict)
return
}
// Check room capacity
room, err := h.db.GetRoom(rb.RoomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room != nil && rb.Guests > room.Capacity {
http.Error(w, "Guest count exceeds room capacity", http.StatusBadRequest)
return
}
if rb.Status == "" {
rb.Status = "confirmed"
}
if err := h.db.CreateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(rb)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomBookingDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/room-bookings/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
rb, err := h.db.GetRoomBooking(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rb == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodPut:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
rb.ID = id
if err := h.db.UpdateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodDelete:
if err := h.db.DeleteRoomBooking(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Availability Handler
func (h *Handler) handleAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
tables, err := h.db.GetAllTables()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, table := range tables {
reservations, _ := h.db.GetReservationsByTable(table.ID, date)
availability = append(availability, map[string]interface{}{
"table_id": table.ID,
"table_name": table.RoomName + " - Tisch " + strconv.Itoa(table.ID),
"max_guests": table.MaxGuests,
"reservations": reservations,
})
}
json.NewEncoder(w).Encode(availability)
}
// NEW: Room Availability Handler
func (h *Handler) handleRoomAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, room := range rooms {
bookings, _ := h.db.GetRoomBookingsByRoom(room.ID)
availability = append(availability, map[string]interface{}{
"room_id": room.ID,
"room_name": room.Name,
"capacity": room.Capacity,
"bookings": bookings,
})
}
json.NewEncoder(w).Encode(availability)
}
func (h *Handler) handleMergeTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ParentTableID int `json:"parent_table_id"`
ChildTableID int `json:"child_table_id"`
MergedName string `json:"merged_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
parentTable, err := h.db.GetTable(req.ParentTableID)
if err != nil || parentTable == nil {
http.Error(w, "Parent table not found", http.StatusBadRequest)
return
}
childTable, err := h.db.GetTable(req.ChildTableID)
if err != nil || childTable == nil {
http.Error(w, "Child table not found", http.StatusBadRequest)
return
}
if parentTable.RoomID != childTable.RoomID {
http.Error(w, "Tables must be in the same room", http.StatusBadRequest)
return
}
merge := models.TableMerge{
ParentTableID: req.ParentTableID,
ChildTableID: req.ChildTableID,
MergedName: req.MergedName,
Active: true,
}
if err := h.db.CreateTableMerge(&merge); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(merge)
}
func (h *Handler) handleEmailConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := h.db.GetEmailConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if cfg != nil {
cfg.Password = "***"
}
json.NewEncoder(w).Encode(cfg)
case http.MethodPost, http.MethodPut:
var cfg models.EmailConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := h.db.SaveEmailConfig(&cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(cfg)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
today := time.Now().Format("2006-01-02")
totalGuests, _ := h.db.GetTotalGuestsForDate(today)
reservations, _ := h.db.GetReservationsByDate(today)
roomBookings, _ := h.db.GetRoomBookingsByDate(today)
dashboard := map[string]interface{}{
"today": today,
"total_guests": totalGuests,
"reservation_count": len(reservations),
"room_booking_count": len(roomBookings),
"reservations": reservations,
"room_bookings": roomBookings,
}
json.NewEncoder(w).Encode(dashboard)
}
+104
View File
@@ -0,0 +1,104 @@
package models
import (
"time"
)
type Room struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Capacity int `json:"capacity" db:"capacity"`
Color string `json:"color" db:"color"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type Table struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
X int `json:"x" db:"x"`
Y int `json:"y" db:"y"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
MaxGuests int `json:"max_guests" db:"max_guests"`
Shape string `json:"shape" db:"shape"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type Reservation struct {
ID int `json:"id" db:"id"`
TableID int `json:"table_id" db:"table_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
Source string `json:"source" db:"source"`
Notes string `json:"notes" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
TableName string `json:"table_name,omitempty" db:"-"`
}
// NEW: RoomBooking for entire room reservations
type RoomBooking struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
EventType string `json:"event_type" db:"event_type"`
Notes string `json:"notes" db:"notes"`
Status string `json:"status" db:"status"` // confirmed, cancelled, pending
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type TableMerge struct {
ID int `json:"id" db:"id"`
ParentTableID int `json:"parent_table_id" db:"parent_table_id"`
ChildTableID int `json:"child_table_id" db:"child_table_id"`
MergedName string `json:"merged_name" db:"merged_name"`
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type EmailConfig struct {
ID int `json:"id" db:"id"`
Host string `json:"host" db:"host"`
Port int `json:"port" db:"port"`
Username string `json:"user" db:"user"`
Password string `json:"password" db:"password"`
SSL bool `json:"ssl" db:"ssl"`
Folder string `json:"folder" db:"folder"`
}
type User struct {
ID int `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"`
IsAdmin bool `json:"is_admin" db:"is_admin"`
}
type Availability struct {
TableID int `json:"table_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
// NEW: RoomAvailability for checking room availability
type RoomAvailability struct {
RoomID int `json:"room_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
+104
View File
@@ -0,0 +1,104 @@
package models
import (
"time"
)
type Room struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Capacity int `json:"capacity" db:"capacity"`
Color string `json:"color" db:"color"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type Table struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
X int `json:"x" db:"x"`
Y int `json:"y" db:"y"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
MaxGuests int `json:"max_guests" db:"max_guests"`
Shape string `json:"shape" db:"shape"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type Reservation struct {
ID int `json:"id" db:"id"`
TableID int `json:"table_id" db:"table_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
Source string `json:"source" db:"source"`
Notes string `json:"notes" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
TableName string `json:"table_name,omitempty" db:"-"`
}
// NEW: RoomBooking for entire room reservations
type RoomBooking struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
EventType string `json:"event_type" db:"event_type"`
Notes string `json:"notes" db:"notes"`
Status string `json:"status" db:"status"` // confirmed, cancelled, pending
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type TableMerge struct {
ID int `json:"id" db:"id"`
ParentTableID int `json:"parent_table_id" db:"parent_table_id"`
ChildTableID int `json:"child_table_id" db:"child_table_id"`
MergedName string `json:"merged_name" db:"merged_name"`
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type EmailConfig struct {
ID int `json:"id" db:"id"`
Host string `json:"host" db:"host"`
Port int `json:"port" db:"port"`
Username string `json:"user" db:"user"`
Password string `json:"password" db:"password"`
SSL bool `json:"ssl" db:"ssl"`
Folder string `json:"folder" db:"folder"`
}
type User struct {
ID int `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"`
IsAdmin bool `json:"is_admin" db:"is_admin"`
}
type Availability struct {
TableID int `json:"table_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
// NEW: RoomAvailability for checking room availability
type RoomAvailability struct {
RoomID int `json:"room_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
+616
View File
@@ -0,0 +1,616 @@
package db
import (
"database/sql"
"fmt"
"reservation-system/backend/internal/models"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.Migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER NOT NULL DEFAULT 0,
color TEXT DEFAULT '#3b82f6',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
max_guests INTEGER NOT NULL,
shape TEXT DEFAULT 'rect',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source TEXT DEFAULT 'manual',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS room_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
event_type TEXT,
notes TEXT,
status TEXT DEFAULT 'confirmed',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS table_merges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_table_id INTEGER NOT NULL,
child_table_id INTEGER NOT NULL,
merged_name TEXT,
active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE,
FOREIGN KEY (child_table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS email_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
port INTEGER DEFAULT 993,
user TEXT NOT NULL,
password TEXT NOT NULL,
ssl BOOLEAN DEFAULT 1,
folder TEXT DEFAULT 'INBOX'
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_table ON reservations(table_id);
CREATE INDEX IF NOT EXISTS idx_tables_room ON tables(room_id);
CREATE INDEX IF NOT EXISTS idx_reservations_time ON reservations(time_from, time_to);
CREATE INDEX IF NOT EXISTS idx_room_bookings_date ON room_bookings(date);
CREATE INDEX IF NOT EXISTS idx_room_bookings_room ON room_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_room_bookings_time ON room_bookings(time_from, time_to);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
// Room Methods
func (db *DB) CreateRoom(room *models.Room) error {
result, err := db.conn.Exec(
"INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)",
room.Name, room.Capacity, room.Color,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
room.ID = int(id)
room.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRooms() ([]models.Room, error) {
rows, err := db.conn.Query("SELECT id, name, capacity, color, created_at FROM rooms ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var rooms []models.Room
for rows.Next() {
var r models.Room
if err := rows.Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt); err != nil {
return nil, err
}
rooms = append(rooms, r)
}
return rooms, nil
}
func (db *DB) GetRoom(id int) (*models.Room, error) {
var r models.Room
err := db.conn.QueryRow(
"SELECT id, name, capacity, color, created_at FROM rooms WHERE id = ?",
id,
).Scan(&r.ID, &r.Name, &r.Capacity, &r.Color, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateRoom(room *models.Room) error {
_, err := db.conn.Exec(
"UPDATE rooms SET name = ?, capacity = ?, color = ? WHERE id = ?",
room.Name, room.Capacity, room.Color, room.ID,
)
return err
}
func (db *DB) DeleteRoom(id int) error {
_, err := db.conn.Exec("DELETE FROM rooms WHERE id = ?", id)
return err
}
// Table Methods
func (db *DB) CreateTable(table *models.Table) error {
result, err := db.conn.Exec(
"INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
table.ID = int(id)
table.CreatedAt = time.Now()
return nil
}
func (db *DB) GetTablesByRoom(roomID int) ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.room_id = ? AND t.status = 'active'
ORDER BY t.id`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetAllTables() ([]models.Table, error) {
rows, err := db.conn.Query(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.status = 'active'
ORDER BY t.room_id, t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName); err != nil {
return nil, err
}
tables = append(tables, t)
}
return tables, nil
}
func (db *DB) GetTable(id int) (*models.Table, error) {
var t models.Table
err := db.conn.QueryRow(`
SELECT t.id, t.room_id, t.x, t.y, t.width, t.height, t.max_guests, t.shape, t.status, t.created_at, r.name as room_name
FROM tables t
JOIN rooms r ON t.room_id = r.id
WHERE t.id = ?`, id).Scan(&t.ID, &t.RoomID, &t.X, &t.Y, &t.Width, &t.Height, &t.MaxGuests, &t.Shape, &t.Status, &t.CreatedAt, &t.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &t, err
}
func (db *DB) UpdateTable(table *models.Table) error {
_, err := db.conn.Exec(
"UPDATE tables SET room_id = ?, x = ?, y = ?, width = ?, height = ?, max_guests = ?, shape = ? WHERE id = ?",
table.RoomID, table.X, table.Y, table.Width, table.Height, table.MaxGuests, table.Shape, table.ID,
)
return err
}
func (db *DB) DeleteTable(id int) error {
_, err := db.conn.Exec("UPDATE tables SET status = 'deleted' WHERE id = ?", id)
return err
}
// Reservation Methods
func (db *DB) CreateReservation(r *models.Reservation) error {
result, err := db.conn.Exec(
"INSERT INTO reservations (table_id, date, time_from, time_to, guests, name, phone, email, source, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Source, r.Notes,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
r.ID = int(id)
r.CreatedAt = time.Now()
return nil
}
func (db *DB) GetReservationsByDate(date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT r.id, r.table_id, r.date, r.time_from, r.time_to, r.guests, r.name, r.phone, r.email, r.source, r.notes, r.created_at, r.table_id as table_name
FROM reservations r
WHERE r.date = ?
ORDER BY r.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt, &res.TableName); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservationsByTable(tableID int, date string) ([]models.Reservation, error) {
rows, err := db.conn.Query(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations
WHERE table_id = ? AND date = ?
ORDER BY time_from`, tableID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var reservations []models.Reservation
for rows.Next() {
var res models.Reservation
if err := rows.Scan(&res.ID, &res.TableID, &res.Date, &res.TimeFrom, &res.TimeTo, &res.Guests, &res.Name, &res.Phone, &res.Email, &res.Source, &res.Notes, &res.CreatedAt); err != nil {
return nil, err
}
reservations = append(reservations, res)
}
return reservations, nil
}
func (db *DB) GetReservation(id int) (*models.Reservation, error) {
var r models.Reservation
err := db.conn.QueryRow(`
SELECT id, table_id, date, time_from, time_to, guests, name, phone, email, source, notes, created_at
FROM reservations WHERE id = ?`, id).Scan(&r.ID, &r.TableID, &r.Date, &r.TimeFrom, &r.TimeTo, &r.Guests, &r.Name, &r.Phone, &r.Email, &r.Source, &r.Notes, &r.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
func (db *DB) UpdateReservation(r *models.Reservation) error {
_, err := db.conn.Exec(
"UPDATE reservations SET table_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, notes = ? WHERE id = ?",
r.TableID, r.Date, r.TimeFrom, r.TimeTo, r.Guests, r.Name, r.Phone, r.Email, r.Notes, r.ID,
)
return err
}
func (db *DB) DeleteReservation(id int) error {
_, err := db.conn.Exec("DELETE FROM reservations WHERE id = ?", id)
return err
}
// Room Booking Methods (NEW)
func (db *DB) CreateRoomBooking(rb *models.RoomBooking) error {
result, err := db.conn.Exec(
"INSERT INTO room_bookings (room_id, date, time_from, time_to, guests, name, phone, email, event_type, notes, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
rb.ID = int(id)
rb.CreatedAt = time.Now()
return nil
}
func (db *DB) GetRoomBookingsByRoom(roomID int) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.room_id = ?
ORDER BY rb.date DESC, rb.time_from`, roomID)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBookingsByDate(date string) ([]models.RoomBooking, error) {
rows, err := db.conn.Query(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.date = ?
ORDER BY rb.time_from`, date)
if err != nil {
return nil, err
}
defer rows.Close()
var bookings []models.RoomBooking
for rows.Next() {
var b models.RoomBooking
if err := rows.Scan(&b.ID, &b.RoomID, &b.Date, &b.TimeFrom, &b.TimeTo, &b.Guests, &b.Name, &b.Phone, &b.Email, &b.EventType, &b.Notes, &b.Status, &b.CreatedAt, &b.RoomName); err != nil {
return nil, err
}
bookings = append(bookings, b)
}
return bookings, nil
}
func (db *DB) GetRoomBooking(id int) (*models.RoomBooking, error) {
var rb models.RoomBooking
err := db.conn.QueryRow(`
SELECT rb.id, rb.room_id, rb.date, rb.time_from, rb.time_to, rb.guests, rb.name, rb.phone, rb.email, rb.event_type, rb.notes, rb.status, rb.created_at, r.name as room_name
FROM room_bookings rb
JOIN rooms r ON rb.room_id = r.id
WHERE rb.id = ?`, id).Scan(&rb.ID, &rb.RoomID, &rb.Date, &rb.TimeFrom, &rb.TimeTo, &rb.Guests, &rb.Name, &rb.Phone, &rb.Email, &rb.EventType, &rb.Notes, &rb.Status, &rb.CreatedAt, &rb.RoomName)
if err == sql.ErrNoRows {
return nil, nil
}
return &rb, err
}
func (db *DB) UpdateRoomBooking(rb *models.RoomBooking) error {
_, err := db.conn.Exec(
"UPDATE room_bookings SET room_id = ?, date = ?, time_from = ?, time_to = ?, guests = ?, name = ?, phone = ?, email = ?, event_type = ?, notes = ?, status = ? WHERE id = ?",
rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo, rb.Guests, rb.Name, rb.Phone, rb.Email, rb.EventType, rb.Notes, rb.Status, rb.ID,
)
return err
}
func (db *DB) DeleteRoomBooking(id int) error {
_, err := db.conn.Exec("DELETE FROM room_bookings WHERE id = ?", id)
return err
}
// Availability Checks
func (db *DB) CheckAvailability(tableID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM reservations
WHERE table_id = ? AND date = ?
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, tableID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) CheckRoomAvailability(roomID int, date, timeFrom, timeTo string) (bool, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM room_bookings
WHERE room_id = ? AND date = ? AND status = 'confirmed'
AND (
(time_from < ? AND time_to > ?) OR
(time_from < ? AND time_to > ?) OR
(time_from >= ? AND time_to <= ?)
)`, roomID, date, timeTo, timeFrom, timeTo, timeFrom, timeFrom, timeTo).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
func (db *DB) GetTableMerges(parentTableID int) ([]models.TableMerge, error) {
rows, err := db.conn.Query(`
SELECT id, parent_table_id, child_table_id, merged_name, active, created_at
FROM table_merges
WHERE parent_table_id = ? AND active = 1`, parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var merges []models.TableMerge
for rows.Next() {
var m models.TableMerge
if err := rows.Scan(&m.ID, &m.ParentTableID, &m.ChildTableID, &m.MergedName, &m.Active, &m.CreatedAt); err != nil {
return nil, err
}
merges = append(merges, m)
}
return merges, nil
}
func (db *DB) UnmergeTables(mergeID int) error {
var childID int
err := db.conn.QueryRow("SELECT child_table_id FROM table_merges WHERE id = ?", mergeID).Scan(&childID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE table_merges SET active = 0 WHERE id = ?", mergeID)
if err != nil {
return err
}
_, err = db.conn.Exec("UPDATE tables SET status = 'active' WHERE id = ?", childID)
return err
}
func (db *DB) GetEmailConfig() (*models.EmailConfig, error) {
var cfg models.EmailConfig
err := db.conn.QueryRow("SELECT id, host, port, user, password, ssl, folder FROM email_config LIMIT 1").Scan(
&cfg.ID, &cfg.Host, &cfg.Port, &cfg.Username, &cfg.Password, &cfg.SSL, &cfg.Folder,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &cfg, err
}
func (db *DB) SaveEmailConfig(cfg *models.EmailConfig) error {
if cfg.ID > 0 {
_, err := db.conn.Exec(
"UPDATE email_config SET host = ?, port = ?, user = ?, password = ?, ssl = ?, folder = ? WHERE id = ?",
cfg.Host, cfg.Port, &cfg.Username, cfg.Password, cfg.SSL, cfg.Folder, cfg.ID,
)
return err
}
result, err := db.conn.Exec(
"INSERT INTO email_config (host, port, user, password, ssl, folder) VALUES (?, ?, ?, ?, ?, ?)",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.SSL, cfg.Folder,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
cfg.ID = int(id)
return nil
}
func (db *DB) GetUserByUsername(username string) (*models.User, error) {
var u models.User
err := db.conn.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username).Scan(
&u.ID, &u.Username, &u.Password, &u.IsAdmin,
)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) CreateUser(user *models.User) error {
result, err := db.conn.Exec(
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
user.Username, user.Password, user.IsAdmin,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
user.ID = int(id)
return nil
}
func (db *DB) GetMergedTablesFor(parentTableID int) ([]int, error) {
rows, err := db.conn.Query("SELECT child_table_id FROM table_merges WHERE parent_table_id = ? AND active = 1", parentTableID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func (db *DB) GetTotalGuestsForDate(date string) (int, error) {
var total int
err := db.conn.QueryRow("SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ?", date).Scan(&total)
return total, err
}
func (db *DB) CreateTableMerge(merge *models.TableMerge) error {
result, err := db.conn.Exec(
"INSERT INTO table_merges (parent_table_id, child_table_id, merged_name, active) VALUES (?, ?, ?, ?)",
merge.ParentTableID, merge.ChildTableID, merge.MergedName, merge.Active,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
merge.ID = int(id)
merge.CreatedAt = time.Now()
// Mark child table as merged
_, err = db.conn.Exec("UPDATE tables SET status = 'merged' WHERE id = ?", merge.ChildTableID)
return err
}
+25
View File
@@ -0,0 +1,25 @@
version: '3.8'
services:
reservation:
build: .
container_name: reservation-system
restart: unless-stopped
ports:
- "8081:8080"
volumes:
- reservation-data:/data
environment:
- PORT=8080
- DATA_DIR=/data
- OLLAMA_URL=http://192.168.0.150:11434
networks:
- cctv-network
volumes:
reservation-data:
networks:
cctv-network:
external: true
name: cctv_default
+343
View File
@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reservierungssystem</title>
<link rel="stylesheet" href="/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-box">
<h1>Reservierungssystem</h1>
<form id="login-form">
<input type="text" id="username" placeholder="Benutzername" required>
<input type="password" id="password" placeholder="Passwort" required>
<button type="submit">Anmelden</button>
<div id="login-error" class="error"></div>
</form>
</div>
</div>
<!-- Main App -->
<div id="main-screen" class="screen hidden">
<header class="app-header">
<h1>Reservierungssystem</h1>
<nav>
<button class="nav-btn active" data-view="dashboard">Dashboard</button>
<button class="nav-btn" data-view="floorplan">Tischplan</button>
<button class="nav-btn" data-view="calendar">Kalender</button>
<button class="nav-btn" data-view="reservations">Reservierungen</button>
<button class="nav-btn" data-view="settings">Einstellungen</button>
<button id="logout-btn">Abmelden</button>
</nav>
</header>
<main class="app-main">
<!-- Dashboard View -->
<div id="dashboard-view" class="view active">
<div class="dashboard-grid">
<div class="stat-card">
<h3>Heute</h3>
<div class="stat-value" id="today-count">0</div>
<div class="stat-label">Reservierungen</div>
</div>
<div class="stat-card">
<h3>Gäste</h3>
<div class="stat-value" id="today-guests">0</div>
<div class="stat-label">Personen erwartet</div>
</div>
<div class="stat-card">
<h3>Schnellbuchung</h3>
<button class="btn-primary" onclick="app.showQuickBooking()">+ Telefonisch</button>
</div>
</div>
<div class="today-list">
<h2>Heutige Reservierungen</h2>
<div id="today-reservations"></div>
</div>
</div>
<!-- Floor Plan View -->
<div id="floorplan-view" class="view">
<div class="floorplan-header">
<select id="room-select">
<option value="">Raum wählen...</option>
</select>
<button class="btn-secondary" onclick="app.toggleEditMode()"> Bearbeiten</button>
<button class="btn-primary" onclick="app.showAddTable()">+ Tisch hinzufügen</button>
</div>
<div class="floorplan-container" id="floorplan-canvas">
<div class="floorplan-area" id="floorplan-area"></div>
</div>
<div class="floorplan-legend">
<div class="legend-item"><span class="legend-color free"></span> Frei</div>
<div class="legend-item"><span class="legend-color occupied"></span> Belegt</div>
<div class="legend-item"><span class="legend-color selected"></span> Ausgewählt</div>
</div>
</div>
<!-- Calendar View -->
<div id="calendar-view" class="view">
<div class="calendar-header">
<button id="prev-month"></button>
<h2 id="current-month"></h2>
<button id="next-month"></button>
</div>
<div class="calendar-grid" id="calendar-grid"></div>
</div>
<!-- Reservations View -->
<div id="reservations-view" class="view">
<div class="reservations-header">
<input type="date" id="reservation-date" value="">
<button class="btn-primary" onclick="app.showNewReservation()">+ Neue Reservierung</button>
</div>
<div class="reservations-list" id="reservations-list"></div>
</div>
<!-- Settings View -->
<div id="settings-view" class="view">
<div class="settings-tabs">
<button class="tab-btn active" data-tab="email">E-Mail</button>
<button class="tab-btn" data-tab="rooms">Räume</button>
</div>
<div id="email-settings" class="tab-content active">
<h2>E-Mail Konfiguration</h2>
<form id="email-config-form">
<div class="form-row">
<label>Server:</label>
<input type="text" name="host" placeholder="imap.example.com">
</div>
<div class="form-row">
<label>Port:</label>
<input type="number" name="port" value="993">
</div>
<div class="form-row">
<label>Benutzername:</label>
<input type="text" name="user">
</div>
<div class="form-row">
<label>Passwort:</label>
<input type="password" name="password">
</div>
<div class="form-row">
<label>SSL:</label>
<input type="checkbox" name="ssl" checked>
</div>
<div class="form-row">
<label>Ordner:</label>
<input type="text" name="folder" value="INBOX">
</div>
<button type="submit" class="btn-primary">Speichern</button>
</form>
</div>
<div id="rooms-settings" class="tab-content">
<h2>Räume verwalten</h2>
<div id="rooms-list"></div>
<button class="btn-primary" onclick="app.showAddRoom()">+ Raum hinzufügen</button>
</div>
</div>
</main>
</div>
</div>
<!-- Modals -->
<div id="modal-overlay" class="modal-overlay hidden">
<!-- Reservation Modal -->
<div id="reservation-modal" class="modal hidden">
<div class="modal-header">
<h2 id="reservation-modal-title">Neue Reservierung</h2>
<button class="modal-close">×</button>
</div>
<form id="reservation-form">
<input type="hidden" name="id">
<div class="form-row">
<label>Datum:</label>
<input type="date" name="date" required>
</div>
<div class="form-row">
<label>Zeit von:</label>
<input type="time" name="time_from" required>
</div>
<div class="form-row">
<label>Zeit bis:</label>
<input type="time" name="time_to" required>
</div>
<div class="form-row">
<label>Personen:</label>
<input type="number" name="guests" min="1" required>
</div>
<div class="form-row">
<label>Tisch:</label>
<select name="table_id" required></select>
</div>
<div class="form-row">
<label>Name:</label>
<input type="text" name="name" required>
</div>
<div class="form-row">
<label>Telefon:</label>
<input type="tel" name="phone">
</div>
<div class="form-row">
<label>E-Mail:</label>
<input type="email" name="email">
</div>
<div class="form-row">
<label>Quelle:</label>
<select name="source">
<option value="manual">Manuell</option>
<option value="phone">Telefon</option>
<option value="email">E-Mail</option>
<option value="walkin">Walk-in</option>
</select>
</div>
<div class="form-row">
<label>Notizen:</label>
<textarea name="notes" rows="3"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="app.closeModal()">Abbrechen</button>
<button type="submit" class="btn-primary">Speichern</button>
</div>
</form>
</div>
<!-- Quick Booking Modal -->
<div id="quick-booking-modal" class="modal hidden">
<div class="modal-header">
<h2>Schnellbuchung (Telefon)</h2>
<button class="modal-close">×</button>
</div>
<form id="quick-booking-form">
<div class="form-row">
<label>Datum:</label>
<input type="date" name="date" required>
</div>
<div class="form-row">
<label>Uhrzeit:</label>
<input type="time" name="time" required>
</div>
<div class="form-row">
<label>Personen:</label>
<input type="number" name="guests" min="1" required>
</div>
<div class="form-row">
<label>Name:</label>
<input type="text" name="name" required>
</div>
<div class="form-row">
<label>Telefon:</label>
<input type="tel" name="phone" required>
</div>
<div class="form-row">
<label>Notizen:</label>
<textarea name="notes" rows="2"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="app.closeModal()">Abbrechen</button>
<button type="submit" class="btn-primary">Schnell buchen</button>
</div>
</form>
</div>
<!-- Table Modal -->
<div id="table-modal" class="modal hidden">
<div class="modal-header">
<h2 id="table-modal-title">Tisch hinzufügen</h2>
<button class="modal-close">×</button>
</div>
<form id="table-form">
<input type="hidden" name="id">
<div class="form-row">
<label>Raum:</label>
<select name="room_id" required></select>
</div>
<div class="form-row">
<label>Max. Gäste:</label>
<input type="number" name="max_guests" min="1" required>
</div>
<div class="form-row">
<label>Form:</label>
<select name="shape">
<option value="rect">Rechteck</option>
<option value="circle">Kreis</option>
</select>
</div>
<div class="form-row">
<label>Breite (px):</label>
<input type="number" name="width" value="100" min="50">
</div>
<div class="form-row">
<label>Höhe (px):</label>
<input type="number" name="height" value="100" min="50">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="app.closeModal()">Abbrechen</button>
<button type="submit" class="btn-primary">Speichern</button>
</div>
</form>
</div>
<!-- Room Modal -->
<div id="room-modal" class="modal hidden">
<div class="modal-header">
<h2>Raum hinzufügen</h2>
<button class="modal-close">×</button>
</div>
<form id="room-form">
<div class="form-row">
<label>Name:</label>
<input type="text" name="name" required>
</div>
<div class="form-row">
<label>Kapazität:</label>
<input type="number" name="capacity" min="1">
</div>
<div class="form-row">
<label>Farbe:</label>
<input type="color" name="color" value="#3b82f6">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="app.closeModal()">Abbrechen</button>
<button type="submit" class="btn-primary">Speichern</button>
</div>
</form>
</div>
<!-- Merge Modal -->
<div id="merge-modal" class="modal hidden">
<div class="modal-header">
<h2>Tische zusammenlegen</h2>
<button class="modal-close">×</button>
</div>
<form id="merge-form">
<div class="form-row">
<label>Haupttisch:</label>
<select name="parent_table_id" required></select>
</div>
<div class="form-row">
<label>Zusätzlicher Tisch:</label>
<select name="child_table_id" required></select>
</div>
<div class="form-row">
<label>Name (optional):</label>
<input type="text" name="merged_name" placeholder="z.B. "Tisch A+B"">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="app.closeModal()">Abbrechen</button>
<button type="submit" class="btn-primary">Zusammenlegen</button>
</div>
</form>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+576
View File
@@ -0,0 +1,576 @@
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.5;
}
/* Screen Management */
.screen {
min-height: 100vh;
}
.screen.hidden {
display: none;
}
/* Login Screen */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
background: white;
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
width: 100%;
max-width: 400px;
}
.login-box h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.5rem;
}
.login-box input {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.login-box input:focus {
outline: none;
border-color: #667eea;
}
.login-box button {
width: 100%;
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.login-box button:hover {
background: #5a67d8;
}
.error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.5rem;
}
/* App Header */
.app-header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-header h1 {
font-size: 1.25rem;
color: #333;
}
.app-header nav {
display: flex;
gap: 0.5rem;
}
.nav-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.nav-btn:hover {
background: #f3f4f6;
}
.nav-btn.active {
background: #667eea;
color: white;
}
#logout-btn {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
background: transparent;
color: #dc2626;
cursor: pointer;
border-radius: 6px;
margin-left: 1rem;
}
#logout-btn:hover {
background: #dc2626;
color: white;
}
/* Main Content */
.app-main {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
/* View Management */
.view {
display: none;
}
.view.active {
display: block;
}
/* Dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card h3 {
font-size: 0.875rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: #333;
}
.stat-label {
font-size: 0.875rem;
color: #9ca3af;
}
.today-list {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.today-list h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
.reservation-item {
display: flex;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #f3f4f6;
}
.reservation-item:last-child {
border-bottom: none;
}
.reservation-time {
font-weight: 600;
color: #667eea;
}
.reservation-guests {
color: #6b7280;
}
.reservation-name {
font-weight: 500;
}
/* Floor Plan */
.floorplan-header {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
}
.floorplan-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
min-height: 500px;
padding: 2rem;
position: relative;
}
.floorplan-area {
position: relative;
width: 100%;
height: 450px;
background: #f9fafb;
border: 2px dashed #d1d5db;
border-radius: 8px;
}
.table {
position: absolute;
background: #22c55e;
border: 2px solid #16a34a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
color: white;
font-weight: 600;
user-select: none;
}
.table:hover {
transform: scale(1.05);
}
.table.occupied {
background: #ef4444;
border-color: #dc2626;
}
.table.selected {
background: #f59e0b;
border-color: #d97706;
}
.table.merged {
background: #8b5cf6;
border-color: #7c3aed;
}
.table.circle {
border-radius: 50%;
}
.floorplan-legend {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-color.free { background: #22c55e; }
.legend-color.occupied { background: #ef4444; }
.legend-color.selected { background: #f59e0b; }
/* Calendar */
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.calendar-header button {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #e5e7eb;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.calendar-day-header {
background: #f9fafb;
padding: 1rem;
text-align: center;
font-weight: 600;
font-size: 0.875rem;
}
.calendar-day {
background: white;
min-height: 100px;
padding: 0.5rem;
cursor: pointer;
transition: background 0.2s;
}
.calendar-day:hover {
background: #f3f4f6;
}
.calendar-day.other-month {
color: #9ca3af;
background: #fafafa;
}
.calendar-day.today {
background: #dbeafe;
}
.calendar-day.has-reservations::after {
content: '';
display: block;
width: 6px;
height: 6px;
background: #ef4444;
border-radius: 50%;
margin-top: 4px;
}
.day-number {
font-weight: 500;
font-size: 0.875rem;
}
.reservation-count {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* Reservations */
.reservations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.reservations-list {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Settings */
.settings-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.tab-btn {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab-btn.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Forms */
.form-row {
margin-bottom: 1rem;
}
.form-row label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
font-size: 0.875rem;
}
.form-row input,
.form-row select,
.form-row textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
}
.form-row input:focus,
.form-row select:focus,
.form-row textarea:focus {
outline: none;
border-color: #667eea;
}
/* Buttons */
.btn-primary {
padding: 0.5rem 1rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s;
}
.btn-primary:hover {
background: #5a67d8;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f3f4f6;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal.hidden {
display: none;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.modal-close:hover {
color: #333;
}
.modal form {
padding: 1.5rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.app-header {
flex-direction: column;
gap: 1rem;
}
.app-header nav {
flex-wrap: wrap;
justify-content: center;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.floorplan-container {
padding: 1rem;
}
}
+9
View File
@@ -0,0 +1,9 @@
module reservation-system
go 1.21
require (
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/mattn/go-sqlite3 v1.14.19
golang.org/x/crypto v0.18.0
)
+773
View File
@@ -0,0 +1,773 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"reservation-system/backend/internal/auth"
"reservation-system/backend/internal/db"
"reservation-system/backend/internal/models"
"golang.org/x/crypto/bcrypt"
)
type Handler struct {
db *db.DB
}
func NewHandler(database *db.DB) *Handler {
return &Handler{db: database}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Auth
mux.HandleFunc("/api/auth/login", h.handleLogin)
mux.HandleFunc("/api/auth/refresh", h.handleRefresh)
// Rooms (protected)
mux.HandleFunc("/api/rooms", h.authMiddleware(h.handleRooms))
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomDetail))
// Tables (protected)
mux.HandleFunc("/api/tables", h.authMiddleware(h.handleTables))
mux.HandleFunc("/api/tables/", h.authMiddleware(h.handleTableDetail))
mux.HandleFunc("/api/tables/merge", h.authMiddleware(h.handleMergeTables))
// NEW: Room tables endpoint
mux.HandleFunc("/api/rooms/", h.authMiddleware(h.handleRoomTables))
// Reservations (protected)
mux.HandleFunc("/api/reservations", h.authMiddleware(h.handleReservations))
mux.HandleFunc("/api/reservations/", h.authMiddleware(h.handleReservationDetail))
mux.HandleFunc("/api/reservations/date/", h.authMiddleware(h.handleReservationsByDate))
// NEW: Room Bookings
mux.HandleFunc("/api/room-bookings", h.authMiddleware(h.handleRoomBookings))
mux.HandleFunc("/api/room-bookings/", h.authMiddleware(h.handleRoomBookingDetail))
// Availability
mux.HandleFunc("/api/availability", h.authMiddleware(h.handleAvailability))
mux.HandleFunc("/api/availability/room", h.authMiddleware(h.handleRoomAvailability))
// Email config
mux.HandleFunc("/api/email-config", h.authMiddleware(h.handleEmailConfig))
// Dashboard stats
mux.HandleFunc("/api/dashboard", h.authMiddleware(h.handleDashboard))
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := h.db.GetUserByUsername(req.Username)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if user == nil || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := auth.GenerateToken(user.Username, user.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": token,
"user": user.Username,
})
}
func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenString)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
newToken, err := auth.GenerateToken(claims.Username, claims.IsAdmin)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"token": newToken,
})
}
func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if _, err := auth.ValidateToken(tokenString); err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// Rooms Handler
func (h *Handler) handleRooms(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rooms)
case http.MethodPost:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if room.Name == "" {
http.Error(w, "Name required", http.StatusBadRequest)
return
}
if err := h.db.CreateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(room)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomDetail(w http.ResponseWriter, r *http.Request) {
// Check if it's /api/rooms/:id/tables
path := r.URL.Path
if strings.HasSuffix(path, "/tables") {
h.handleRoomTables(w, r)
return
}
idStr := strings.TrimPrefix(path, "/api/rooms/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
room, err := h.db.GetRoom(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodPut:
var room models.Room
if err := json.NewDecoder(r.Body).Decode(&room); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
room.ID = id
if err := h.db.UpdateRoom(&room); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(room)
case http.MethodDelete:
if err := h.db.DeleteRoom(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// NEW: Room Tables Handler - GET /api/rooms/:id/tables
func (h *Handler) handleRoomTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := r.URL.Path
idStr := strings.TrimSuffix(strings.TrimPrefix(path, "/api/rooms/"), "/tables")
roomID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid room ID", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
tables, err := h.db.GetTablesByRoom(roomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
} else {
// POST - create table for room
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.RoomID = roomID
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
}
}
// Tables Handler
func (h *Handler) handleTables(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
var tables []models.Table
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
tables, err = h.db.GetTablesByRoom(rid)
} else {
tables, err = h.db.GetAllTables()
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tables)
case http.MethodPost:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if table.MaxGuests == 0 {
http.Error(w, "Max guests required", http.StatusBadRequest)
return
}
if err := h.db.CreateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(table)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleTableDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tables/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
table, err := h.db.GetTable(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodPut:
var table models.Table
if err := json.NewDecoder(r.Body).Decode(&table); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
table.ID = id
if err := h.db.UpdateTable(&table); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(table)
case http.MethodDelete:
if err := h.db.DeleteTable(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Reservations Handler
func (h *Handler) handleReservations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
case http.MethodPost:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check availability
available, err := h.db.CheckAvailability(res.TableID, res.Date, res.TimeFrom, res.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Table not available at this time", http.StatusConflict)
return
}
// Check table capacity
table, err := h.db.GetTable(res.TableID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if table != nil && res.Guests > table.MaxGuests {
http.Error(w, "Guest count exceeds table capacity", http.StatusBadRequest)
return
}
if res.Source == "" {
res.Source = "manual"
}
if err := h.db.CreateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(res)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/reservations/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
res, err := h.db.GetReservation(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if res == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodPut:
var res models.Reservation
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
res.ID = id
if err := h.db.UpdateReservation(&res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(res)
case http.MethodDelete:
if err := h.db.DeleteReservation(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleReservationsByDate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := strings.TrimPrefix(r.URL.Path, "/api/reservations/date/")
if date == "" {
http.Error(w, "Date required", http.StatusBadRequest)
return
}
reservations, err := h.db.GetReservationsByDate(date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(reservations)
}
// NEW: Room Bookings Handlers
func (h *Handler) handleRoomBookings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
roomID := r.URL.Query().Get("room_id")
date := r.URL.Query().Get("date")
var bookings []models.RoomBooking
var err error
if roomID != "" {
rid, _ := strconv.Atoi(roomID)
bookings, err = h.db.GetRoomBookingsByRoom(rid)
} else if date != "" {
bookings, err = h.db.GetRoomBookingsByDate(date)
} else {
// Get all bookings for today
bookings, err = h.db.GetRoomBookingsByDate(time.Now().Format("2006-01-02"))
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(bookings)
case http.MethodPost:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Check room availability
available, err := h.db.CheckRoomAvailability(rb.RoomID, rb.Date, rb.TimeFrom, rb.TimeTo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !available {
http.Error(w, "Room not available at this time", http.StatusConflict)
return
}
// Check room capacity
room, err := h.db.GetRoom(rb.RoomID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if room != nil && rb.Guests > room.Capacity {
http.Error(w, "Guest count exceeds room capacity", http.StatusBadRequest)
return
}
if rb.Status == "" {
rb.Status = "confirmed"
}
if err := h.db.CreateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(rb)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRoomBookingDetail(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/room-bookings/")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
rb, err := h.db.GetRoomBooking(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rb == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodPut:
var rb models.RoomBooking
if err := json.NewDecoder(r.Body).Decode(&rb); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
rb.ID = id
if err := h.db.UpdateRoomBooking(&rb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rb)
case http.MethodDelete:
if err := h.db.DeleteRoomBooking(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// Availability Handler
func (h *Handler) handleAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
tables, err := h.db.GetAllTables()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, table := range tables {
reservations, _ := h.db.GetReservationsByTable(table.ID, date)
availability = append(availability, map[string]interface{}{
"table_id": table.ID,
"table_name": table.RoomName + " - Tisch " + strconv.Itoa(table.ID),
"max_guests": table.MaxGuests,
"reservations": reservations,
})
}
json.NewEncoder(w).Encode(availability)
}
// NEW: Room Availability Handler
func (h *Handler) handleRoomAvailability(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
rooms, err := h.db.GetRooms()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var availability []map[string]interface{}
for _, room := range rooms {
bookings, _ := h.db.GetRoomBookingsByRoom(room.ID)
availability = append(availability, map[string]interface{}{
"room_id": room.ID,
"room_name": room.Name,
"capacity": room.Capacity,
"bookings": bookings,
})
}
json.NewEncoder(w).Encode(availability)
}
func (h *Handler) handleMergeTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ParentTableID int `json:"parent_table_id"`
ChildTableID int `json:"child_table_id"`
MergedName string `json:"merged_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
parentTable, err := h.db.GetTable(req.ParentTableID)
if err != nil || parentTable == nil {
http.Error(w, "Parent table not found", http.StatusBadRequest)
return
}
childTable, err := h.db.GetTable(req.ChildTableID)
if err != nil || childTable == nil {
http.Error(w, "Child table not found", http.StatusBadRequest)
return
}
if parentTable.RoomID != childTable.RoomID {
http.Error(w, "Tables must be in the same room", http.StatusBadRequest)
return
}
merge := models.TableMerge{
ParentTableID: req.ParentTableID,
ChildTableID: req.ChildTableID,
MergedName: req.MergedName,
Active: true,
}
if err := h.db.CreateTableMerge(&merge); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(merge)
}
func (h *Handler) handleEmailConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := h.db.GetEmailConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if cfg != nil {
cfg.Password = "***"
}
json.NewEncoder(w).Encode(cfg)
case http.MethodPost, http.MethodPut:
var cfg models.EmailConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := h.db.SaveEmailConfig(&cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(cfg)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
today := time.Now().Format("2006-01-02")
totalGuests, _ := h.db.GetTotalGuestsForDate(today)
reservations, _ := h.db.GetReservationsByDate(today)
roomBookings, _ := h.db.GetRoomBookingsByDate(today)
dashboard := map[string]interface{}{
"today": today,
"total_guests": totalGuests,
"reservation_count": len(reservations),
"room_booking_count": len(roomBookings),
"reservations": reservations,
"room_bookings": roomBookings,
}
json.NewEncoder(w).Encode(dashboard)
}
+104
View File
@@ -0,0 +1,104 @@
package models
import (
"time"
)
type Room struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Capacity int `json:"capacity" db:"capacity"`
Color string `json:"color" db:"color"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type Table struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
X int `json:"x" db:"x"`
Y int `json:"y" db:"y"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
MaxGuests int `json:"max_guests" db:"max_guests"`
Shape string `json:"shape" db:"shape"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type Reservation struct {
ID int `json:"id" db:"id"`
TableID int `json:"table_id" db:"table_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
Source string `json:"source" db:"source"`
Notes string `json:"notes" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
TableName string `json:"table_name,omitempty" db:"-"`
}
// NEW: RoomBooking for entire room reservations
type RoomBooking struct {
ID int `json:"id" db:"id"`
RoomID int `json:"room_id" db:"room_id"`
Date string `json:"date" db:"date"`
TimeFrom string `json:"time_from" db:"time_from"`
TimeTo string `json:"time_to" db:"time_to"`
Guests int `json:"guests" db:"guests"`
Name string `json:"name" db:"name"`
Phone string `json:"phone" db:"phone"`
Email string `json:"email" db:"email"`
EventType string `json:"event_type" db:"event_type"`
Notes string `json:"notes" db:"notes"`
Status string `json:"status" db:"status"` // confirmed, cancelled, pending
CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name,omitempty" db:"-"`
}
type TableMerge struct {
ID int `json:"id" db:"id"`
ParentTableID int `json:"parent_table_id" db:"parent_table_id"`
ChildTableID int `json:"child_table_id" db:"child_table_id"`
MergedName string `json:"merged_name" db:"merged_name"`
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type EmailConfig struct {
ID int `json:"id" db:"id"`
Host string `json:"host" db:"host"`
Port int `json:"port" db:"port"`
Username string `json:"user" db:"user"`
Password string `json:"password" db:"password"`
SSL bool `json:"ssl" db:"ssl"`
Folder string `json:"folder" db:"folder"`
}
type User struct {
ID int `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"`
IsAdmin bool `json:"is_admin" db:"is_admin"`
}
type Availability struct {
TableID int `json:"table_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
// NEW: RoomAvailability for checking room availability
type RoomAvailability struct {
RoomID int `json:"room_id"`
Date string `json:"date"`
TimeFrom string `json:"time_from"`
TimeTo string `json:"time_to"`
Status string `json:"status"`
}
+100
View File
@@ -0,0 +1,100 @@
-- Erstelle alle Tabellen zuerst
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
capacity INTEGER NOT NULL DEFAULT 0,
color TEXT DEFAULT '#3b82f6',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
x INTEGER DEFAULT 0,
y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
max_guests INTEGER NOT NULL,
shape TEXT DEFAULT 'rect',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS room_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
event_type TEXT,
notes TEXT,
status TEXT DEFAULT 'confirmed',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source TEXT DEFAULT 'manual',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (table_id) REFERENCES tables(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
CREATE INDEX IF NOT EXISTS idx_reservations_table ON reservations(table_id);
CREATE INDEX IF NOT EXISTS idx_tables_room ON tables(room_id);
CREATE INDEX IF NOT EXISTS idx_reservations_time ON reservations(time_from, time_to);
CREATE INDEX IF NOT EXISTS idx_room_bookings_date ON room_bookings(date);
CREATE INDEX IF NOT EXISTS idx_room_bookings_room ON room_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_room_bookings_time ON room_bookings(time_from, time_to);
-- Raeume einfuegen
INSERT INTO rooms (id, name, capacity, color) VALUES
(1, 'Hauptraum', 80, '#3b82f6'),
(2, 'Saal A', 40, '#10b981'),
(3, 'Saal B', 30, '#f59e0b');
-- Tische fuer Hauptraum
INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(1, 50, 50, 120, 80, 4, 'rect', 'active'),
(1, 200, 50, 120, 80, 4, 'rect', 'active'),
(1, 350, 50, 120, 80, 6, 'rect', 'active'),
(1, 50, 150, 100, 100, 6, 'round', 'active'),
(1, 200, 150, 120, 80, 4, 'rect', 'active'),
(1, 350, 150, 120, 80, 4, 'rect', 'active'),
(1, 50, 300, 150, 150, 8, 'round', 'active'),
(1, 250, 300, 150, 150, 10, 'round', 'active');
-- Tische fuer Saal A
INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(2, 50, 50, 100, 80, 4, 'rect', 'active'),
(2, 180, 50, 100, 80, 4, 'rect', 'active'),
(2, 310, 50, 100, 80, 4, 'rect', 'active'),
(2, 50, 150, 100, 80, 6, 'rect', 'active'),
(2, 180, 150, 100, 80, 6, 'rect', 'active');
-- Tische fuer Saal B
INSERT INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(3, 50, 50, 100, 80, 4, 'rect', 'active'),
(3, 180, 50, 100, 80, 4, 'rect', 'active'),
(3, 50, 150, 120, 80, 6, 'rect', 'active'),
(3, 200, 150, 80, 80, 4, 'round', 'active'),
(3, 320, 150, 80, 80, 4, 'round', 'active');
-- Test Daten
INSERT INTO users (username, password, is_admin) VALUES
('admin', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 1);
+62
View File
@@ -0,0 +1,62 @@
-- Migration: Room Bookings Table
-- Create room_bookings table
CREATE TABLE IF NOT EXISTS room_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
date TEXT NOT NULL,
time_from TEXT NOT NULL,
time_to TEXT NOT NULL,
guests INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
event_type TEXT,
notes TEXT,
status TEXT DEFAULT 'confirmed',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_room_bookings_date ON room_bookings(date);
CREATE INDEX IF NOT EXISTS idx_room_bookings_room ON room_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_room_bookings_time ON room_bookings(time_from, time_to);
-- Insert test rooms
INSERT OR IGNORE INTO rooms (name, capacity, color) VALUES
('Hauptraum', 80, '#3b82f6'),
('Saal A', 40, '#10b981'),
('Saal B', 30, '#f59e0b');
-- Insert test tables for Hauptraum (room_id 1)
INSERT OR IGNORE INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(1, 50, 50, 120, 80, 4, 'rect', 'active'),
(1, 200, 50, 120, 80, 4, 'rect', 'active'),
(1, 350, 50, 120, 80, 6, 'rect', 'active'),
(1, 50, 150, 100, 100, 6, 'round', 'active'),
(1, 200, 150, 120, 80, 4, 'rect', 'active'),
(1, 350, 150, 120, 80, 4, 'rect', 'active'),
(1, 50, 300, 150, 150, 8, 'round', 'active'),
(1, 250, 300, 150, 150, 10, 'round', 'active');
-- Insert test tables for Saal A (room_id 2)
INSERT OR IGNORE INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(2, 50, 50, 100, 80, 4, 'rect', 'active'),
(2, 180, 50, 100, 80, 4, 'rect', 'active'),
(2, 310, 50, 100, 80, 4, 'rect', 'active'),
(2, 50, 150, 100, 80, 6, 'rect', 'active'),
(2, 180, 150, 100, 80, 6, 'rect', 'active');
-- Insert test tables for Saal B (room_id 3)
INSERT OR IGNORE INTO tables (room_id, x, y, width, height, max_guests, shape, status) VALUES
(3, 50, 50, 100, 80, 4, 'rect', 'active'),
(3, 180, 50, 100, 80, 4, 'rect', 'active'),
(3, 50, 150, 120, 80, 6, 'rect', 'active'),
(3, 200, 150, 80, 80, 4, 'round', 'active'),
(3, 320, 150, 80, 80, 4, 'round', 'active');
-- Verify
SELECT 'Migration complete' as status;
SELECT * FROM rooms;
SELECT COUNT(*) as table_count FROM tables;