v2.0: 3-Raum-System - Hauptraum, Saal A, Saal B mit 18 Tischen, Raum-Buchungen, API-Doku
This commit is contained in:
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# App module
|
||||
Binary file not shown.
@@ -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", ""))
|
||||
@@ -0,0 +1 @@
|
||||
# App module
|
||||
Binary file not shown.
+184
@@ -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
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", ""))
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user