From 1ae070f82fd87e975c7749fea13ec528349dbf8c Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 16 May 2026 12:15:46 +0000 Subject: [PATCH] v2.0: 3-Raum-System - Hauptraum, Saal A, Saal B mit 18 Tischen, Raum-Buchungen, API-Doku --- DOKUMENTATION.md | 237 ++++ Dockerfile | 19 + README.md | 67 + app-backup-20260516-1208/__init__.py | 1 + .../__pycache__/main.cpython-311.pyc | Bin 0 -> 61631 bytes app-backup-20260516-1208/database.py | 184 +++ app-backup-20260516-1208/main.py | 1128 +++++++++++++++++ app-backup-20260516-1208/templates/index.html | 637 ++++++++++ app-backup-20260516-1208/utils/__init__.py | 1 + .../utils/ollama_client.py | 192 +++ app/__init__.py | 1 + app/__pycache__/main.cpython-311.pyc | Bin 0 -> 61631 bytes app/database.py | 184 +++ app/main.py | 1128 +++++++++++++++++ app/templates/index.html | 637 ++++++++++ app/utils/__init__.py | 1 + app/utils/ollama_client.py | 192 +++ backend/cmd/main.go | 94 ++ backend/internal/api/handlers.go | 773 +++++++++++ backend/internal/auth/jwt.go | 53 + backend/internal/db/database.go | 616 +++++++++ backend/internal/db/database_new.go | 616 +++++++++ backend/internal/email/imap.go | 290 +++++ backend/internal/handlers/handlers_new.go | 773 +++++++++++ backend/internal/models/models.go | 104 ++ backend/internal/models/models_new.go | 104 ++ database_new.go | 616 +++++++++ docker-compose.yml | 25 + frontend/css/style.css | 343 +++++ frontend/index.html | 576 +++++++++ go.mod | 9 + handlers_new.go | 773 +++++++++++ models_new.go | 104 ++ scripts/init-db.sql | 100 ++ scripts/migrate.sql | 62 + 35 files changed, 10640 insertions(+) create mode 100644 DOKUMENTATION.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app-backup-20260516-1208/__init__.py create mode 100644 app-backup-20260516-1208/__pycache__/main.cpython-311.pyc create mode 100644 app-backup-20260516-1208/database.py create mode 100644 app-backup-20260516-1208/main.py create mode 100644 app-backup-20260516-1208/templates/index.html create mode 100644 app-backup-20260516-1208/utils/__init__.py create mode 100644 app-backup-20260516-1208/utils/ollama_client.py create mode 100644 app/__init__.py create mode 100644 app/__pycache__/main.cpython-311.pyc create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/templates/index.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/ollama_client.py create mode 100644 backend/cmd/main.go create mode 100644 backend/internal/api/handlers.go create mode 100644 backend/internal/auth/jwt.go create mode 100644 backend/internal/db/database.go create mode 100644 backend/internal/db/database_new.go create mode 100644 backend/internal/email/imap.go create mode 100644 backend/internal/handlers/handlers_new.go create mode 100644 backend/internal/models/models.go create mode 100644 backend/internal/models/models_new.go create mode 100644 database_new.go create mode 100644 docker-compose.yml create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 go.mod create mode 100644 handlers_new.go create mode 100644 models_new.go create mode 100644 scripts/init-db.sql create mode 100644 scripts/migrate.sql diff --git a/DOKUMENTATION.md b/DOKUMENTATION.md new file mode 100644 index 0000000..0f46463 --- /dev/null +++ b/DOKUMENTATION.md @@ -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/ # Tische eines Bereichs +POST /api/tables/ # Tisch erstellen +PUT /api/tables/ # Tisch aktualisieren +DELETE /api/tables/ # Tisch löschen +``` + +### Reservierungen +``` +GET /api/reservations # Alle Reservierungen +POST /api/reservations # Reservierung erstellen +PUT /api/reservations/ # Reservierung aktualisieren +DELETE /api/reservations/ # 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/ # Buchung aktualisieren +DELETE /api/room-bookings/ # 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92acc90 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8719e0c --- /dev/null +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/app-backup-20260516-1208/__init__.py b/app-backup-20260516-1208/__init__.py new file mode 100644 index 0000000..837cb4e --- /dev/null +++ b/app-backup-20260516-1208/__init__.py @@ -0,0 +1 @@ +# App module \ No newline at end of file diff --git a/app-backup-20260516-1208/__pycache__/main.cpython-311.pyc b/app-backup-20260516-1208/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9f65fa8f611957a28cf6a39056a8acc3476f80b GIT binary patch literal 61631 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 VSQ&y;26sS_mO_kXy_3-x|1TQE;|u@* literal 0 HcmV?d00001 diff --git a/app-backup-20260516-1208/database.py b/app-backup-20260516-1208/database.py new file mode 100644 index 0000000..35e0680 --- /dev/null +++ b/app-backup-20260516-1208/database.py @@ -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)) \ No newline at end of file diff --git a/app-backup-20260516-1208/main.py b/app-backup-20260516-1208/main.py new file mode 100644 index 0000000..c54791b --- /dev/null +++ b/app-backup-20260516-1208/main.py @@ -0,0 +1,1128 @@ +#!/usr/bin/env python3 +"""Reservierungssystem - Flask Backend""" + +import os +import json +from datetime import datetime, timedelta +from functools import wraps + +from flask import Flask, request, jsonify, render_template, send_from_directory, g +from flask_cors import CORS + +from database import get_db, init_db, generate_booking_number, log_change +from utils.ollama_client import ( + parse_email_with_ollama, + generate_confirmation_email, + suggest_table_for_group +) + +app = Flask(__name__, + template_folder='templates', + static_folder='static') +CORS(app) + +# Konfiguration +DEFAULT_OPEN_HOUR = 10 # 10:00 +DEFAULT_CLOSE_HOUR = 23 # 23:00 +RESERVATION_DURATION = 120 # Minuten + +def init_app(): + """App initialisieren""" + init_db() + + # Standard-Daten einfügen + with get_db() as db: + # Default Raum + room = db.execute("SELECT id FROM rooms LIMIT 1").fetchone() + if not room: + cursor = db.execute( + "INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)", + ("Hauptsaal", 50, "#3498db") + ) + room_id = cursor.lastrowid + + # Default Bereich + cursor = db.execute( + "INSERT INTO areas (room_id, name) VALUES (?, ?)", + (room_id, "Fensterbereich") + ) + area_id = cursor.lastrowid + + # Beispiel-Tische + tables = [ + ("T1", 2, 50, 50), + ("T2", 4, 200, 50), + ("T3", 4, 350, 50), + ("T4", 6, 500, 50), + ("T5", 8, 50, 200), + ("T6", 2, 200, 200), + ] + for name, seats, x, y in tables: + db.execute( + """INSERT INTO tables (area_id, name, seats, x, y) + VALUES (?, ?, ?, ?, ?)""", + (area_id, name, seats, x, y) + ) + + db.commit() + print("✓ Standard-Daten erstellt") + +# API-Routen + +@app.route('/') +def index(): + """Hauptseite - Dashboard""" + return render_template('index.html') + +@app.route('/api/health') +def health(): + """Health-Check""" + return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) + +# Dashboard-Daten +@app.route('/api/dashboard') +def dashboard(): + """Dashboard-Daten laden""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Heutige Reservierungen + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische heute + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste gesamt + guests_today = db.execute( + "SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Heutige Reservierungen (Liste) + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + return jsonify({ + "today_count": today_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "today_reservations": [dict(r) for r in today_reservations] + }) + +# Gäste-Verwaltung +@app.route('/api/guests', methods=['GET', 'POST']) +def guests(): + """Gäste auflisten oder neuen Gast erstellen""" + if request.method == 'GET': + search = request.args.get('search', '') + with get_db() as db: + if search: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + WHERE g.name LIKE ? OR g.phone LIKE ? OR g.email LIKE ? + ORDER BY g.name + ''', (f'%{search}%', f'%{search}%', f'%{search}%')).fetchall() + else: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + ORDER BY g.name + ''').fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neuen Gast erstellen + data = request.get_json() + with get_db() as db: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email, notes) + VALUES (?, ?, ?, ?) + ''', (data.get('name'), data.get('phone'), data.get('email'), data.get('notes'))) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Gast erstellt"}) + +@app.route('/api/guests/', methods=['GET', 'PUT', 'DELETE']) +def guest_detail(guest_id): + """Einzelnen Gast verwalten""" + with get_db() as db: + if request.method == 'GET': + # Gast mit Historie + guest = db.execute('SELECT * FROM guests WHERE id = ?', (guest_id,)).fetchone() + if not guest: + return jsonify({"error": "Gast nicht gefunden"}), 404 + + # Letzte Reservierungen + history = db.execute(''' + SELECT booking_number, date, time_from, guests + FROM reservations + WHERE guest_id = ? + ORDER BY date DESC + LIMIT 5 + ''', (guest_id,)).fetchall() + + result = dict(guest) + result['reservation_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + db.execute(''' + UPDATE guests + SET name = ?, phone = ?, email = ?, preferred_table_id = ?, notes = ? + WHERE id = ? + ''', (data.get('name'), data.get('phone'), data.get('email'), + data.get('preferred_table_id'), data.get('notes'), guest_id)) + db.commit() + return jsonify({"message": "Gast aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM guests WHERE id = ?', (guest_id,)) + db.commit() + return jsonify({"message": "Gast gelöscht"}) + +# Tische und Räume +@app.route('/api/rooms') +def rooms(): + """Alle Räume mit Bereichen und Tischen""" + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + result.append(room_dict) + + return jsonify(result) + +# Reservierungen +@app.route('/api/reservations', methods=['GET', 'POST']) +def reservations(): + """Reservierungen auflisten oder neu erstellen""" + if request.method == 'GET': + date = request.args.get('date') + status = request.args.get('status', 'confirmed,pending') + + with get_db() as db: + query = ''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.status IN ({}) + '''.format(','.join(f'"{s}"' for s in status.split(','))) + + params = [] + if date: + query += ' AND r.date = ?' + params.append(date) + + query += ' ORDER BY r.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Reservierung + data = request.get_json() + + # Gast finden oder erstellen + guest_id = data.get('guest_id') + if not guest_id and data.get('email'): + with get_db() as db: + guest = db.execute('SELECT id FROM guests WHERE email = ?', + (data['email'],)).fetchone() + if guest: + guest_id = guest['id'] + + # Buchungsnummer generieren + booking_number = generate_booking_number() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, time_to, guests, + occasion, notes, source, phone_caller_name, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + booking_number, + guest_id, + json.dumps(data.get('table_ids', [])), + data.get('date'), + data.get('time_from'), + data.get('time_to'), + data.get('guests'), + data.get('occasion'), + data.get('notes'), + data.get('source', 'manual'), + data.get('phone_caller_name'), + data.get('created_by', 'system') + )) + + # Gast-Zähler aktualisieren + if guest_id: + db.execute(''' + UPDATE guests + SET visit_count = visit_count + 1, last_visit = ? + WHERE id = ? + ''', (data.get('date'), guest_id)) + + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "booking_number": booking_number, + "message": "Reservierung erstellt" + }) + +@app.route('/api/reservations/', methods=['GET', 'PUT', 'DELETE']) +def reservation_detail(res_id): + """Einzelne Reservierung verwalten""" + with get_db() as db: + if request.method == 'GET': + # Reservierung mit Historie + res = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.id = ? + ''', (res_id,)).fetchone() + + if not res: + return jsonify({"error": "Reservierung nicht gefunden"}), 404 + + # Änderungshistorie + history = db.execute(''' + SELECT * FROM reservation_history + WHERE reservation_id = ? + ORDER BY changed_at DESC + ''', (res_id,)).fetchall() + + result = dict(res) + result['change_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + + # Alte Werte für Historie + old = db.execute('SELECT * FROM reservations WHERE id = ?', (res_id,)).fetchone() + + # Felder aktualisieren + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + log_change(db, res_id, old['booking_number'], 'date', old['date'], data['date']) + + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + log_change(db, res_id, old['booking_number'], 'time_from', old['time_from'], data['time_from']) + + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + log_change(db, res_id, old['booking_number'], 'guests', old['guests'], data['guests']) + + if 'table_ids' in data: + update_fields.append('table_ids = ?') + params.append(json.dumps(data['table_ids'])) + + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(res_id) + db.execute(f''' + UPDATE reservations + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Reservierung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE reservations SET status = "cancelled" WHERE id = ?', (res_id,)) + db.commit() + return jsonify({"message": "Reservierung storniert"}) + +# E-Mail-Verarbeitung +@app.route('/api/emails', methods=['GET', 'POST']) +def emails(): + """E-Mails verwalten""" + if request.method == 'GET': + status = request.args.get('status', 'needs_review') + with get_db() as db: + rows = db.execute(''' + SELECT e.*, r.booking_number as linked_booking + FROM emails e + LEFT JOIN reservations r ON e.linked_reservation_id = r.id + WHERE e.status = ? + ORDER BY e.received_at DESC + ''', (status,)).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue E-Mail verarbeiten + data = request.get_json() + + # Mit Ollama parsen + parsed = parse_email_with_ollama( + data.get('subject', ''), + data.get('body', ''), + data.get('sender_name', '') + ) + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO emails + (message_id, subject, body, sender, sender_email, parsed_json, + confidence, action_type, status, booking_number_found, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data.get('message_id'), + data.get('subject'), + data.get('body'), + data.get('sender_name'), + data.get('sender_email'), + json.dumps(parsed), + parsed.get('confidence', 0), + parsed.get('intent', 'unknown'), + 'auto_processed' if not parsed.get('needs_review') else 'needs_review', + parsed.get('booking_number'), + datetime.now() + )) + db.commit() + + email_id = cursor.lastrowid + + # Wenn keine Review nötig, automatisch verarbeiten + if not parsed.get('needs_review') and parsed.get('intent') in ['new', 'modification']: + process_email_reservation(db, email_id, parsed) + + return jsonify({ + "email_id": email_id, + "parsed": parsed, + "status": "processed" if not parsed.get('needs_review') else "needs_review" + }) + +def process_email_reservation(db, email_id, parsed_data): + """E-Mail automatisch in Reservierung umwandeln""" + + intent = parsed_data.get('intent') + booking_number = parsed_data.get('booking_number') + + if intent == 'modification' and booking_number: + # Bestehende Reservierung ändern + existing = db.execute( + 'SELECT id FROM reservations WHERE booking_number = ?', + (booking_number,) + ).fetchone() + + if existing: + # Felder aktualisieren + updates = [] + params = [] + + if parsed_data.get('date'): + updates.append('date = ?') + params.append(parsed_data['date']) + if parsed_data.get('time'): + updates.append('time_from = ?') + params.append(parsed_data['time']) + if parsed_data.get('guests'): + updates.append('guests = ?') + params.append(parsed_data['guests']) + + if updates: + params.extend([existing['id']]) + db.execute(f''' + UPDATE reservations + SET {', '.join(updates)} + WHERE id = ? + ''', params) + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (existing['id'], email_id)) + + elif intent == 'new': + # Neue Reservierung erstellen + # Gast finden oder erstellen + guest_id = None + if parsed_data.get('email'): + guest = db.execute( + 'SELECT id FROM guests WHERE email = ?', + (parsed_data['email'],) + ).fetchone() + if guest: + guest_id = guest['id'] + else: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email) + VALUES (?, ?, ?) + ''', (parsed_data.get('name'), parsed_data.get('phone'), parsed_data['email'])) + guest_id = cursor.lastrowid + + # Neue Buchungsnummer + new_booking = generate_booking_number() + + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, guests, + occasion, notes, source, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + new_booking, + guest_id, + json.dumps([]), # Tische später zuweisen + parsed_data.get('date'), + parsed_data.get('time'), + parsed_data.get('guests', 2), + parsed_data.get('occasion'), + parsed_data.get('notes'), + 'email', + 'confirmed' + )) + + res_id = cursor.lastrowid + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (res_id, email_id)) + +# Verfügbarkeit prüfen +@app.route('/api/availability') +def check_availability(): + """Verfügbare Tische für Zeitfenster prüfen""" + date = request.args.get('date') + time_from = request.args.get('time_from') + time_to = request.args.get('time_to') + guests = int(request.args.get('guests', 2)) + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + if not time_to: + # Standard-Dauer 2 Stunden + from datetime import datetime as dt + tf = dt.strptime(time_from, '%H:%M') + time_to = (tf + timedelta(minutes=120)).strftime('%H:%M') + + with get_db() as db: + # Alle aktiven Tische laden + all_tables = db.execute(''' + SELECT t.*, a.name as area_name + FROM tables t + JOIN areas a ON t.area_id = a.id + WHERE t.is_active = 1 + ''').fetchall() + + # Belegte Tische in diesem Zeitraum + occupied = db.execute(''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''', (date, time_from, time_to)).fetchall() + + occupied_ids = {row['table_id'] for row in occupied} + + # Verfügbare Tische filtern + available = [dict(t) for t in all_tables + if t['id'] not in occupied_ids and t['seats'] >= guests] + + # Nach Größe sortieren (beste Passung zuerst) + available.sort(key=lambda x: (x['seats'] - guests, x['seats'])) + + return jsonify({ + "available_tables": available, + "total": len(all_tables), + "occupied": len(occupied_ids) + }) + +# Statistiken +@app.route('/api/stats') +def statistics(): + """Statistiken für Dashboard""" + from_date = request.args.get('from', (datetime.now() - timedelta(days=30)).date().isoformat()) + to_date = request.args.get('to', datetime.now().date().isoformat()) + + with get_db() as db: + # Gesamtreservierungen + total = db.execute(''' + SELECT COUNT(*) as count, COALESCE(SUM(guests), 0) as guests + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Durchschnittliche Gruppengröße + avg_size = db.execute(''' + SELECT AVG(guests) as avg + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Nach Wochentag + by_day = db.execute(''' + SELECT strftime('%w', date) as day, COUNT(*) as count + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + GROUP BY day + ''', (from_date, to_date)).fetchall() + + # Stammgäste (mehr als 2 Besuche) + regulars = db.execute(''' + SELECT COUNT(*) FROM guests WHERE visit_count >= 2 + ''').fetchone()[0] + + return jsonify({ + "period": {"from": from_date, "to": to_date}, + "total_reservations": total['count'], + "total_guests": total['guests'], + "average_group_size": round(avg_size['avg'], 1) if avg_size['avg'] else 0, + "by_day": {row['day']: row['count'] for row in by_day}, + "regular_guests": regulars + }) + +if __name__ == '__main__': + init_app() + port = int(os.environ.get('PORT', 8080)) + print(f"🍽️ Reservierungssystem starting on port {port}") + app.run(host='0.0.0.0', port=port, debug=False)# NEUE API ENDPOINTS FÜR ROOM BOOKINGS +# Diese werden ans Ende der main.py angehängt + +# ============================================================================= +# ROOM BOOKING APIs (NEU) +# ============================================================================= + +@app.route('/api/rooms-with-bookings', methods=['GET']) +def rooms_with_bookings(): + """Räume mit aktuellen Raumbuchungen laden""" + date = request.args.get('date', datetime.now().date().isoformat()) + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + + # Bereiche und Tische laden + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + # Raumbuchungen für das Datum laden + bookings = db.execute(''' + SELECT * FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ORDER BY time_from + ''', (room['id'], date)).fetchall() + room_dict['bookings'] = [dict(b) for b in bookings] + + result.append(room_dict) + + return jsonify(result) + +@app.route('/api/room-bookings', methods=['GET', 'POST']) +def room_bookings(): + """Raumbuchungen auflisten oder erstellen""" + if request.method == 'GET': + date = request.args.get('date') + room_id = request.args.get('room_id') + + with get_db() as db: + query = ''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE 1=1 + ''' + params = [] + + if date: + query += ' AND rb.date = ?' + params.append(date) + if room_id: + query += ' AND rb.room_id = ?' + params.append(room_id) + + query += ' ORDER BY rb.date DESC, rb.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Raumbuchung erstellen + data = request.get_json() + + # Validierung + required = ['room_id', 'date', 'time_from', 'time_to', 'guests', 'name'] + for field in required: + if not data.get(field): + return jsonify({"error": f"{field} ist erforderlich"}), 400 + + with get_db() as db: + # Prüfe Verfügbarkeit + existing = db.execute(''' + 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 <= ?) + ) + ''', ( + data['room_id'], data['date'], + data['time_to'], data['time_from'], + data['time_to'], data['time_from'], + data['time_from'], data['time_to'] + )).fetchone()[0] + + if existing > 0: + return jsonify({"error": "Raum ist im gewählten Zeitraum bereits belegt"}), 409 + + # Kapazität prüfen + room = db.execute('SELECT capacity FROM rooms WHERE id = ?', + (data['room_id'],)).fetchone() + if room and data['guests'] > room['capacity']: + return jsonify({"error": f"Maximale Kapazität: {room['capacity']} Personen"}), 400 + + # Buchung erstellen + cursor = db.execute(''' + INSERT INTO room_bookings + (room_id, date, time_from, time_to, guests, name, phone, email, + event_type, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data['room_id'], data['date'], data['time_from'], data['time_to'], + data['guests'], data['name'], data.get('phone'), data.get('email'), + data.get('event_type'), data.get('notes'), + data.get('status', 'confirmed') + )) + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "message": "Raumbuchung erstellt" + }), 201 + +@app.route('/api/room-bookings/', methods=['GET', 'PUT', 'DELETE']) +def room_booking_detail(booking_id): + """Einzelne Raumbuchung verwalten""" + with get_db() as db: + if request.method == 'GET': + booking = db.execute(''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.id = ? + ''', (booking_id,)).fetchone() + + if not booking: + return jsonify({"error": "Raumbuchung nicht gefunden"}), 404 + + return jsonify(dict(booking)) + + elif request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + if 'time_to' in data: + update_fields.append('time_to = ?') + params.append(data['time_to']) + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'phone' in data: + update_fields.append('phone = ?') + params.append(data['phone']) + if 'email' in data: + update_fields.append('email = ?') + params.append(data['email']) + if 'event_type' in data: + update_fields.append('event_type = ?') + params.append(data['event_type']) + if 'notes' in data: + update_fields.append('notes = ?') + params.append(data['notes']) + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(booking_id) + db.execute(f''' + UPDATE room_bookings + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Raumbuchung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM room_bookings WHERE id = ?', (booking_id,)) + db.commit() + return jsonify({"message": "Raumbuchung gelöscht"}) + +@app.route('/api/room-availability', methods=['GET']) +def room_availability(): + """Verfügbarkeit für alle Räume prüfen""" + date = request.args.get('date', datetime.now().date().isoformat()) + time_from = request.args.get('time_from', '10:00') + time_to = request.args.get('time_to', '12:00') + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + # Prüfe ob Raum belegt ist + existing = db.execute(''' + 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 <= ?) + ) + ''', (room['id'], date, time_to, time_from, time_to, time_from, + time_from, time_to)).fetchone()[0] + + # Lade Tische im Raum + areas = db.execute('SELECT id FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + area_ids = [a['id'] for a in areas] + + table_count = 0 + if area_ids: + placeholders = ','.join('?' for _ in area_ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', area_ids).fetchone()[0] + + result.append({ + 'room_id': room['id'], + 'room_name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'available': existing == 0, + 'table_count': table_count, + 'date': date, + 'time_from': time_from, + 'time_to': time_to + }) + + return jsonify(result) + +@app.route('/api/tables/', methods=['GET', 'POST']) +def tables_by_area(area_id): + """Tische für einen Bereich verwalten""" + if request.method == 'GET': + with get_db() as db: + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? ORDER BY name', + (area_id,) + ).fetchall() + return jsonify([dict(t) for t in tables]) + + # POST: Neuen Tisch erstellen + data = request.get_json() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO tables (area_id, name, x, y, width, height, seats, shape, is_combinable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + area_id, + data.get('name', 'Neuer Tisch'), + data.get('x', 0), + data.get('y', 0), + data.get('width', 100), + data.get('height', 100), + data.get('seats', 4), + data.get('shape', 'rect'), + data.get('is_combinable', 1) + )) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Tisch erstellt"}), 201 + +@app.route('/api/tables/', methods=['PUT', 'DELETE']) +def table_detail(table_id): + """Einzelnen Tisch verwalten""" + with get_db() as db: + if request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'x' in data: + update_fields.append('x = ?') + params.append(data['x']) + if 'y' in data: + update_fields.append('y = ?') + params.append(data['y']) + if 'seats' in data: + update_fields.append('seats = ?') + params.append(data['seats']) + if 'is_active' in data: + update_fields.append('is_active = ?') + params.append(data['is_active']) + + if update_fields: + params.append(table_id) + db.execute(f''' + UPDATE tables SET {', '.join(update_fields)} WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Tisch aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE tables SET is_active = 0 WHERE id = ?', (table_id,)) + db.commit() + return jsonify({"message": "Tisch deaktiviert"}) + +# ============================================================================= +# ERWEITERTE RESERVIERUNGS-ENDPOINTS +# ============================================================================= + +@app.route('/api/reservations/check-availability', methods=['POST']) +def check_reservation_availability(): + """Verfügbarkeit für Reservierung prüfen (erweitert)""" + data = request.get_json() + + date = data.get('date') + time_from = data.get('time_from') + time_to = data.get('time_to', '23:00') + guests = data.get('guests', 2) + preferred_room_id = data.get('room_id') + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + with get_db() as db: + # Basis-Query für verfügbare Tische + base_query = ''' + SELECT t.*, a.name as area_name, r.id as room_id, r.name as room_name + FROM tables t + JOIN areas a ON t.area_id = a.id + JOIN rooms r ON a.room_id = r.id + WHERE t.is_active = 1 + ''' + params = [] + + if preferred_room_id: + base_query += ' AND r.id = ?' + params.append(preferred_room_id) + + base_query += ' AND t.seats >= ?' + params.append(guests) + + all_tables = db.execute(base_query, params).fetchall() + + # Belegte Tische finden + occupied_query = ''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''' + occupied = db.execute(occupied_query, (date, time_from, time_to)).fetchall() + occupied_ids = {str(row['table_id']) for row in occupied} + + # Verfügbare Tische filtern + available = [] + for t in all_tables: + if str(t['id']) not in occupied_ids: + table_dict = dict(t) + table_dict['is_available'] = True + available.append(table_dict) + + # Nach Raum gruppieren + rooms_with_tables = {} + for t in available: + room_id = t['room_id'] + if room_id not in rooms_with_tables: + rooms_with_tables[room_id] = { + 'room_id': room_id, + 'room_name': t['room_name'], + 'tables': [] + } + rooms_with_tables[room_id]['tables'].append(t) + + return jsonify({ + "date": date, + "time_from": time_from, + "time_to": time_to, + "guests": guests, + "available_tables": available, + "available_count": len(available), + "rooms": list(rooms_with_tables.values()) + }) + +@app.route('/api/dashboard/extended', methods=['GET']) +def dashboard_extended(): + """Erweitertes Dashboard mit Raumbuchungen""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Standard-Dashboard-Daten + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Raumbuchungen heute + room_bookings_count = db.execute( + "SELECT COUNT(*) FROM room_bookings WHERE date = ? AND status = 'confirmed'", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste + guests_today = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM reservations + WHERE date = ? AND status IN ('confirmed', 'pending') + ''', (today,)).fetchone()[0] + + # Raumbuchungs-Gäste + room_guests = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM room_bookings + WHERE date = ? AND status = 'confirmed' + ''', (today,)).fetchone()[0] + + # Aktuelle Reservierungen + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + # Aktuelle Raumbuchungen + today_room_bookings = db.execute(''' + SELECT rb.*, r.name as room_name + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.date = ? AND rb.status = 'confirmed' + ORDER BY rb.time_from + ''', (today,)).fetchall() + + # Räume mit Auslastung + rooms = db.execute('SELECT * FROM rooms').fetchall() + rooms_data = [] + for room in rooms: + # Tische im Raum zählen + area_ids = db.execute('SELECT id FROM areas WHERE room_id = ?', + (room['id'],)).fetchall() + table_count = 0 + if area_ids: + ids = [a['id'] for a in area_ids] + placeholders = ','.join('?' for _ in ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', ids).fetchone()[0] + + # Aktuelle Buchungen + bookings = db.execute(''' + SELECT COUNT(*) FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ''', (room['id'], today)).fetchone()[0] + + rooms_data.append({ + 'id': room['id'], + 'name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'table_count': table_count, + 'bookings_today': bookings + }) + + return jsonify({ + "today": today, + "reservations_count": today_count, + "room_bookings_count": room_bookings_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "room_guests_today": room_guests, + "total_guests_today": guests_today + room_guests, + "reservations": [dict(r) for r in today_reservations], + "room_bookings": [dict(r) for r in today_room_bookings], + "rooms": rooms_data + }) diff --git a/app-backup-20260516-1208/templates/index.html b/app-backup-20260516-1208/templates/index.html new file mode 100644 index 0000000..60f467f --- /dev/null +++ b/app-backup-20260516-1208/templates/index.html @@ -0,0 +1,637 @@ + + + + + + Reservierungssystem + + + +
+ + +
+ +
+ +
+
+
+

Heute Reserviert

+
0
+
+
+

Gäste Heute

+
0
+
+
+

Freie Tische

+
0
+
+
+

⚠️ Klärung Erforderlich

+
0
+
+
+ +
+
+

Heutige Reservierungen

+ +
+
+ + + + + + + + + + + + + + +
Buchungsnr.ZeitNamePersonenStatusAktion
+
+
+
+ + + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/app-backup-20260516-1208/utils/__init__.py b/app-backup-20260516-1208/utils/__init__.py new file mode 100644 index 0000000..b48462a --- /dev/null +++ b/app-backup-20260516-1208/utils/__init__.py @@ -0,0 +1 @@ +# Utils module \ No newline at end of file diff --git a/app-backup-20260516-1208/utils/ollama_client.py b/app-backup-20260516-1208/utils/ollama_client.py new file mode 100644 index 0000000..f24b19c --- /dev/null +++ b/app-backup-20260516-1208/utils/ollama_client.py @@ -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", "")) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..837cb4e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# App module \ No newline at end of file diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9f65fa8f611957a28cf6a39056a8acc3476f80b GIT binary patch literal 61631 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 VSQ&y;26sS_mO_kXy_3-x|1TQE;|u@* literal 0 HcmV?d00001 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..35e0680 --- /dev/null +++ b/app/database.py @@ -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)) \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c54791b --- /dev/null +++ b/app/main.py @@ -0,0 +1,1128 @@ +#!/usr/bin/env python3 +"""Reservierungssystem - Flask Backend""" + +import os +import json +from datetime import datetime, timedelta +from functools import wraps + +from flask import Flask, request, jsonify, render_template, send_from_directory, g +from flask_cors import CORS + +from database import get_db, init_db, generate_booking_number, log_change +from utils.ollama_client import ( + parse_email_with_ollama, + generate_confirmation_email, + suggest_table_for_group +) + +app = Flask(__name__, + template_folder='templates', + static_folder='static') +CORS(app) + +# Konfiguration +DEFAULT_OPEN_HOUR = 10 # 10:00 +DEFAULT_CLOSE_HOUR = 23 # 23:00 +RESERVATION_DURATION = 120 # Minuten + +def init_app(): + """App initialisieren""" + init_db() + + # Standard-Daten einfügen + with get_db() as db: + # Default Raum + room = db.execute("SELECT id FROM rooms LIMIT 1").fetchone() + if not room: + cursor = db.execute( + "INSERT INTO rooms (name, capacity, color) VALUES (?, ?, ?)", + ("Hauptsaal", 50, "#3498db") + ) + room_id = cursor.lastrowid + + # Default Bereich + cursor = db.execute( + "INSERT INTO areas (room_id, name) VALUES (?, ?)", + (room_id, "Fensterbereich") + ) + area_id = cursor.lastrowid + + # Beispiel-Tische + tables = [ + ("T1", 2, 50, 50), + ("T2", 4, 200, 50), + ("T3", 4, 350, 50), + ("T4", 6, 500, 50), + ("T5", 8, 50, 200), + ("T6", 2, 200, 200), + ] + for name, seats, x, y in tables: + db.execute( + """INSERT INTO tables (area_id, name, seats, x, y) + VALUES (?, ?, ?, ?, ?)""", + (area_id, name, seats, x, y) + ) + + db.commit() + print("✓ Standard-Daten erstellt") + +# API-Routen + +@app.route('/') +def index(): + """Hauptseite - Dashboard""" + return render_template('index.html') + +@app.route('/api/health') +def health(): + """Health-Check""" + return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) + +# Dashboard-Daten +@app.route('/api/dashboard') +def dashboard(): + """Dashboard-Daten laden""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Heutige Reservierungen + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische heute + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste gesamt + guests_today = db.execute( + "SELECT COALESCE(SUM(guests), 0) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Heutige Reservierungen (Liste) + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + return jsonify({ + "today_count": today_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "today_reservations": [dict(r) for r in today_reservations] + }) + +# Gäste-Verwaltung +@app.route('/api/guests', methods=['GET', 'POST']) +def guests(): + """Gäste auflisten oder neuen Gast erstellen""" + if request.method == 'GET': + search = request.args.get('search', '') + with get_db() as db: + if search: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + WHERE g.name LIKE ? OR g.phone LIKE ? OR g.email LIKE ? + ORDER BY g.name + ''', (f'%{search}%', f'%{search}%', f'%{search}%')).fetchall() + else: + rows = db.execute(''' + SELECT g.*, t.name as preferred_table + FROM guests g + LEFT JOIN tables t ON g.preferred_table_id = t.id + ORDER BY g.name + ''').fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neuen Gast erstellen + data = request.get_json() + with get_db() as db: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email, notes) + VALUES (?, ?, ?, ?) + ''', (data.get('name'), data.get('phone'), data.get('email'), data.get('notes'))) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Gast erstellt"}) + +@app.route('/api/guests/', methods=['GET', 'PUT', 'DELETE']) +def guest_detail(guest_id): + """Einzelnen Gast verwalten""" + with get_db() as db: + if request.method == 'GET': + # Gast mit Historie + guest = db.execute('SELECT * FROM guests WHERE id = ?', (guest_id,)).fetchone() + if not guest: + return jsonify({"error": "Gast nicht gefunden"}), 404 + + # Letzte Reservierungen + history = db.execute(''' + SELECT booking_number, date, time_from, guests + FROM reservations + WHERE guest_id = ? + ORDER BY date DESC + LIMIT 5 + ''', (guest_id,)).fetchall() + + result = dict(guest) + result['reservation_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + db.execute(''' + UPDATE guests + SET name = ?, phone = ?, email = ?, preferred_table_id = ?, notes = ? + WHERE id = ? + ''', (data.get('name'), data.get('phone'), data.get('email'), + data.get('preferred_table_id'), data.get('notes'), guest_id)) + db.commit() + return jsonify({"message": "Gast aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM guests WHERE id = ?', (guest_id,)) + db.commit() + return jsonify({"message": "Gast gelöscht"}) + +# Tische und Räume +@app.route('/api/rooms') +def rooms(): + """Alle Räume mit Bereichen und Tischen""" + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + result.append(room_dict) + + return jsonify(result) + +# Reservierungen +@app.route('/api/reservations', methods=['GET', 'POST']) +def reservations(): + """Reservierungen auflisten oder neu erstellen""" + if request.method == 'GET': + date = request.args.get('date') + status = request.args.get('status', 'confirmed,pending') + + with get_db() as db: + query = ''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.status IN ({}) + '''.format(','.join(f'"{s}"' for s in status.split(','))) + + params = [] + if date: + query += ' AND r.date = ?' + params.append(date) + + query += ' ORDER BY r.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Reservierung + data = request.get_json() + + # Gast finden oder erstellen + guest_id = data.get('guest_id') + if not guest_id and data.get('email'): + with get_db() as db: + guest = db.execute('SELECT id FROM guests WHERE email = ?', + (data['email'],)).fetchone() + if guest: + guest_id = guest['id'] + + # Buchungsnummer generieren + booking_number = generate_booking_number() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, time_to, guests, + occasion, notes, source, phone_caller_name, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + booking_number, + guest_id, + json.dumps(data.get('table_ids', [])), + data.get('date'), + data.get('time_from'), + data.get('time_to'), + data.get('guests'), + data.get('occasion'), + data.get('notes'), + data.get('source', 'manual'), + data.get('phone_caller_name'), + data.get('created_by', 'system') + )) + + # Gast-Zähler aktualisieren + if guest_id: + db.execute(''' + UPDATE guests + SET visit_count = visit_count + 1, last_visit = ? + WHERE id = ? + ''', (data.get('date'), guest_id)) + + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "booking_number": booking_number, + "message": "Reservierung erstellt" + }) + +@app.route('/api/reservations/', methods=['GET', 'PUT', 'DELETE']) +def reservation_detail(res_id): + """Einzelne Reservierung verwalten""" + with get_db() as db: + if request.method == 'GET': + # Reservierung mit Historie + res = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone, g.email + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.id = ? + ''', (res_id,)).fetchone() + + if not res: + return jsonify({"error": "Reservierung nicht gefunden"}), 404 + + # Änderungshistorie + history = db.execute(''' + SELECT * FROM reservation_history + WHERE reservation_id = ? + ORDER BY changed_at DESC + ''', (res_id,)).fetchall() + + result = dict(res) + result['change_history'] = [dict(h) for h in history] + return jsonify(result) + + elif request.method == 'PUT': + data = request.get_json() + + # Alte Werte für Historie + old = db.execute('SELECT * FROM reservations WHERE id = ?', (res_id,)).fetchone() + + # Felder aktualisieren + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + log_change(db, res_id, old['booking_number'], 'date', old['date'], data['date']) + + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + log_change(db, res_id, old['booking_number'], 'time_from', old['time_from'], data['time_from']) + + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + log_change(db, res_id, old['booking_number'], 'guests', old['guests'], data['guests']) + + if 'table_ids' in data: + update_fields.append('table_ids = ?') + params.append(json.dumps(data['table_ids'])) + + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(res_id) + db.execute(f''' + UPDATE reservations + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Reservierung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE reservations SET status = "cancelled" WHERE id = ?', (res_id,)) + db.commit() + return jsonify({"message": "Reservierung storniert"}) + +# E-Mail-Verarbeitung +@app.route('/api/emails', methods=['GET', 'POST']) +def emails(): + """E-Mails verwalten""" + if request.method == 'GET': + status = request.args.get('status', 'needs_review') + with get_db() as db: + rows = db.execute(''' + SELECT e.*, r.booking_number as linked_booking + FROM emails e + LEFT JOIN reservations r ON e.linked_reservation_id = r.id + WHERE e.status = ? + ORDER BY e.received_at DESC + ''', (status,)).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue E-Mail verarbeiten + data = request.get_json() + + # Mit Ollama parsen + parsed = parse_email_with_ollama( + data.get('subject', ''), + data.get('body', ''), + data.get('sender_name', '') + ) + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO emails + (message_id, subject, body, sender, sender_email, parsed_json, + confidence, action_type, status, booking_number_found, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data.get('message_id'), + data.get('subject'), + data.get('body'), + data.get('sender_name'), + data.get('sender_email'), + json.dumps(parsed), + parsed.get('confidence', 0), + parsed.get('intent', 'unknown'), + 'auto_processed' if not parsed.get('needs_review') else 'needs_review', + parsed.get('booking_number'), + datetime.now() + )) + db.commit() + + email_id = cursor.lastrowid + + # Wenn keine Review nötig, automatisch verarbeiten + if not parsed.get('needs_review') and parsed.get('intent') in ['new', 'modification']: + process_email_reservation(db, email_id, parsed) + + return jsonify({ + "email_id": email_id, + "parsed": parsed, + "status": "processed" if not parsed.get('needs_review') else "needs_review" + }) + +def process_email_reservation(db, email_id, parsed_data): + """E-Mail automatisch in Reservierung umwandeln""" + + intent = parsed_data.get('intent') + booking_number = parsed_data.get('booking_number') + + if intent == 'modification' and booking_number: + # Bestehende Reservierung ändern + existing = db.execute( + 'SELECT id FROM reservations WHERE booking_number = ?', + (booking_number,) + ).fetchone() + + if existing: + # Felder aktualisieren + updates = [] + params = [] + + if parsed_data.get('date'): + updates.append('date = ?') + params.append(parsed_data['date']) + if parsed_data.get('time'): + updates.append('time_from = ?') + params.append(parsed_data['time']) + if parsed_data.get('guests'): + updates.append('guests = ?') + params.append(parsed_data['guests']) + + if updates: + params.extend([existing['id']]) + db.execute(f''' + UPDATE reservations + SET {', '.join(updates)} + WHERE id = ? + ''', params) + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (existing['id'], email_id)) + + elif intent == 'new': + # Neue Reservierung erstellen + # Gast finden oder erstellen + guest_id = None + if parsed_data.get('email'): + guest = db.execute( + 'SELECT id FROM guests WHERE email = ?', + (parsed_data['email'],) + ).fetchone() + if guest: + guest_id = guest['id'] + else: + cursor = db.execute(''' + INSERT INTO guests (name, phone, email) + VALUES (?, ?, ?) + ''', (parsed_data.get('name'), parsed_data.get('phone'), parsed_data['email'])) + guest_id = cursor.lastrowid + + # Neue Buchungsnummer + new_booking = generate_booking_number() + + cursor = db.execute(''' + INSERT INTO reservations + (booking_number, guest_id, table_ids, date, time_from, guests, + occasion, notes, source, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + new_booking, + guest_id, + json.dumps([]), # Tische später zuweisen + parsed_data.get('date'), + parsed_data.get('time'), + parsed_data.get('guests', 2), + parsed_data.get('occasion'), + parsed_data.get('notes'), + 'email', + 'confirmed' + )) + + res_id = cursor.lastrowid + + db.execute(''' + UPDATE emails + SET linked_reservation_id = ?, status = 'confirmed' + WHERE id = ? + ''', (res_id, email_id)) + +# Verfügbarkeit prüfen +@app.route('/api/availability') +def check_availability(): + """Verfügbare Tische für Zeitfenster prüfen""" + date = request.args.get('date') + time_from = request.args.get('time_from') + time_to = request.args.get('time_to') + guests = int(request.args.get('guests', 2)) + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + if not time_to: + # Standard-Dauer 2 Stunden + from datetime import datetime as dt + tf = dt.strptime(time_from, '%H:%M') + time_to = (tf + timedelta(minutes=120)).strftime('%H:%M') + + with get_db() as db: + # Alle aktiven Tische laden + all_tables = db.execute(''' + SELECT t.*, a.name as area_name + FROM tables t + JOIN areas a ON t.area_id = a.id + WHERE t.is_active = 1 + ''').fetchall() + + # Belegte Tische in diesem Zeitraum + occupied = db.execute(''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''', (date, time_from, time_to)).fetchall() + + occupied_ids = {row['table_id'] for row in occupied} + + # Verfügbare Tische filtern + available = [dict(t) for t in all_tables + if t['id'] not in occupied_ids and t['seats'] >= guests] + + # Nach Größe sortieren (beste Passung zuerst) + available.sort(key=lambda x: (x['seats'] - guests, x['seats'])) + + return jsonify({ + "available_tables": available, + "total": len(all_tables), + "occupied": len(occupied_ids) + }) + +# Statistiken +@app.route('/api/stats') +def statistics(): + """Statistiken für Dashboard""" + from_date = request.args.get('from', (datetime.now() - timedelta(days=30)).date().isoformat()) + to_date = request.args.get('to', datetime.now().date().isoformat()) + + with get_db() as db: + # Gesamtreservierungen + total = db.execute(''' + SELECT COUNT(*) as count, COALESCE(SUM(guests), 0) as guests + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Durchschnittliche Gruppengröße + avg_size = db.execute(''' + SELECT AVG(guests) as avg + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + ''', (from_date, to_date)).fetchone() + + # Nach Wochentag + by_day = db.execute(''' + SELECT strftime('%w', date) as day, COUNT(*) as count + FROM reservations + WHERE date BETWEEN ? AND ? + AND status IN ('confirmed', 'completed') + GROUP BY day + ''', (from_date, to_date)).fetchall() + + # Stammgäste (mehr als 2 Besuche) + regulars = db.execute(''' + SELECT COUNT(*) FROM guests WHERE visit_count >= 2 + ''').fetchone()[0] + + return jsonify({ + "period": {"from": from_date, "to": to_date}, + "total_reservations": total['count'], + "total_guests": total['guests'], + "average_group_size": round(avg_size['avg'], 1) if avg_size['avg'] else 0, + "by_day": {row['day']: row['count'] for row in by_day}, + "regular_guests": regulars + }) + +if __name__ == '__main__': + init_app() + port = int(os.environ.get('PORT', 8080)) + print(f"🍽️ Reservierungssystem starting on port {port}") + app.run(host='0.0.0.0', port=port, debug=False)# NEUE API ENDPOINTS FÜR ROOM BOOKINGS +# Diese werden ans Ende der main.py angehängt + +# ============================================================================= +# ROOM BOOKING APIs (NEU) +# ============================================================================= + +@app.route('/api/rooms-with-bookings', methods=['GET']) +def rooms_with_bookings(): + """Räume mit aktuellen Raumbuchungen laden""" + date = request.args.get('date', datetime.now().date().isoformat()) + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + room_dict = dict(room) + + # Bereiche und Tische laden + areas = db.execute('SELECT * FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + room_dict['areas'] = [] + for area in areas: + area_dict = dict(area) + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? AND is_active = 1', + (area['id'],) + ).fetchall() + area_dict['tables'] = [dict(t) for t in tables] + room_dict['areas'].append(area_dict) + + # Raumbuchungen für das Datum laden + bookings = db.execute(''' + SELECT * FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ORDER BY time_from + ''', (room['id'], date)).fetchall() + room_dict['bookings'] = [dict(b) for b in bookings] + + result.append(room_dict) + + return jsonify(result) + +@app.route('/api/room-bookings', methods=['GET', 'POST']) +def room_bookings(): + """Raumbuchungen auflisten oder erstellen""" + if request.method == 'GET': + date = request.args.get('date') + room_id = request.args.get('room_id') + + with get_db() as db: + query = ''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE 1=1 + ''' + params = [] + + if date: + query += ' AND rb.date = ?' + params.append(date) + if room_id: + query += ' AND rb.room_id = ?' + params.append(room_id) + + query += ' ORDER BY rb.date DESC, rb.time_from' + + rows = db.execute(query, params).fetchall() + return jsonify([dict(r) for r in rows]) + + # POST: Neue Raumbuchung erstellen + data = request.get_json() + + # Validierung + required = ['room_id', 'date', 'time_from', 'time_to', 'guests', 'name'] + for field in required: + if not data.get(field): + return jsonify({"error": f"{field} ist erforderlich"}), 400 + + with get_db() as db: + # Prüfe Verfügbarkeit + existing = db.execute(''' + 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 <= ?) + ) + ''', ( + data['room_id'], data['date'], + data['time_to'], data['time_from'], + data['time_to'], data['time_from'], + data['time_from'], data['time_to'] + )).fetchone()[0] + + if existing > 0: + return jsonify({"error": "Raum ist im gewählten Zeitraum bereits belegt"}), 409 + + # Kapazität prüfen + room = db.execute('SELECT capacity FROM rooms WHERE id = ?', + (data['room_id'],)).fetchone() + if room and data['guests'] > room['capacity']: + return jsonify({"error": f"Maximale Kapazität: {room['capacity']} Personen"}), 400 + + # Buchung erstellen + cursor = db.execute(''' + INSERT INTO room_bookings + (room_id, date, time_from, time_to, guests, name, phone, email, + event_type, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data['room_id'], data['date'], data['time_from'], data['time_to'], + data['guests'], data['name'], data.get('phone'), data.get('email'), + data.get('event_type'), data.get('notes'), + data.get('status', 'confirmed') + )) + db.commit() + + return jsonify({ + "id": cursor.lastrowid, + "message": "Raumbuchung erstellt" + }), 201 + +@app.route('/api/room-bookings/', methods=['GET', 'PUT', 'DELETE']) +def room_booking_detail(booking_id): + """Einzelne Raumbuchung verwalten""" + with get_db() as db: + if request.method == 'GET': + booking = db.execute(''' + SELECT rb.*, r.name as room_name, r.capacity as room_capacity + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.id = ? + ''', (booking_id,)).fetchone() + + if not booking: + return jsonify({"error": "Raumbuchung nicht gefunden"}), 404 + + return jsonify(dict(booking)) + + elif request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'date' in data: + update_fields.append('date = ?') + params.append(data['date']) + if 'time_from' in data: + update_fields.append('time_from = ?') + params.append(data['time_from']) + if 'time_to' in data: + update_fields.append('time_to = ?') + params.append(data['time_to']) + if 'guests' in data: + update_fields.append('guests = ?') + params.append(data['guests']) + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'phone' in data: + update_fields.append('phone = ?') + params.append(data['phone']) + if 'email' in data: + update_fields.append('email = ?') + params.append(data['email']) + if 'event_type' in data: + update_fields.append('event_type = ?') + params.append(data['event_type']) + if 'notes' in data: + update_fields.append('notes = ?') + params.append(data['notes']) + if 'status' in data: + update_fields.append('status = ?') + params.append(data['status']) + + if update_fields: + params.append(booking_id) + db.execute(f''' + UPDATE room_bookings + SET {', '.join(update_fields)} + WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Raumbuchung aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('DELETE FROM room_bookings WHERE id = ?', (booking_id,)) + db.commit() + return jsonify({"message": "Raumbuchung gelöscht"}) + +@app.route('/api/room-availability', methods=['GET']) +def room_availability(): + """Verfügbarkeit für alle Räume prüfen""" + date = request.args.get('date', datetime.now().date().isoformat()) + time_from = request.args.get('time_from', '10:00') + time_to = request.args.get('time_to', '12:00') + + with get_db() as db: + rooms = db.execute('SELECT * FROM rooms').fetchall() + result = [] + + for room in rooms: + # Prüfe ob Raum belegt ist + existing = db.execute(''' + 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 <= ?) + ) + ''', (room['id'], date, time_to, time_from, time_to, time_from, + time_from, time_to)).fetchone()[0] + + # Lade Tische im Raum + areas = db.execute('SELECT id FROM areas WHERE room_id = ?', (room['id'],)).fetchall() + area_ids = [a['id'] for a in areas] + + table_count = 0 + if area_ids: + placeholders = ','.join('?' for _ in area_ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', area_ids).fetchone()[0] + + result.append({ + 'room_id': room['id'], + 'room_name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'available': existing == 0, + 'table_count': table_count, + 'date': date, + 'time_from': time_from, + 'time_to': time_to + }) + + return jsonify(result) + +@app.route('/api/tables/', methods=['GET', 'POST']) +def tables_by_area(area_id): + """Tische für einen Bereich verwalten""" + if request.method == 'GET': + with get_db() as db: + tables = db.execute( + 'SELECT * FROM tables WHERE area_id = ? ORDER BY name', + (area_id,) + ).fetchall() + return jsonify([dict(t) for t in tables]) + + # POST: Neuen Tisch erstellen + data = request.get_json() + + with get_db() as db: + cursor = db.execute(''' + INSERT INTO tables (area_id, name, x, y, width, height, seats, shape, is_combinable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + area_id, + data.get('name', 'Neuer Tisch'), + data.get('x', 0), + data.get('y', 0), + data.get('width', 100), + data.get('height', 100), + data.get('seats', 4), + data.get('shape', 'rect'), + data.get('is_combinable', 1) + )) + db.commit() + return jsonify({"id": cursor.lastrowid, "message": "Tisch erstellt"}), 201 + +@app.route('/api/tables/', methods=['PUT', 'DELETE']) +def table_detail(table_id): + """Einzelnen Tisch verwalten""" + with get_db() as db: + if request.method == 'PUT': + data = request.get_json() + + update_fields = [] + params = [] + + if 'name' in data: + update_fields.append('name = ?') + params.append(data['name']) + if 'x' in data: + update_fields.append('x = ?') + params.append(data['x']) + if 'y' in data: + update_fields.append('y = ?') + params.append(data['y']) + if 'seats' in data: + update_fields.append('seats = ?') + params.append(data['seats']) + if 'is_active' in data: + update_fields.append('is_active = ?') + params.append(data['is_active']) + + if update_fields: + params.append(table_id) + db.execute(f''' + UPDATE tables SET {', '.join(update_fields)} WHERE id = ? + ''', params) + db.commit() + + return jsonify({"message": "Tisch aktualisiert"}) + + elif request.method == 'DELETE': + db.execute('UPDATE tables SET is_active = 0 WHERE id = ?', (table_id,)) + db.commit() + return jsonify({"message": "Tisch deaktiviert"}) + +# ============================================================================= +# ERWEITERTE RESERVIERUNGS-ENDPOINTS +# ============================================================================= + +@app.route('/api/reservations/check-availability', methods=['POST']) +def check_reservation_availability(): + """Verfügbarkeit für Reservierung prüfen (erweitert)""" + data = request.get_json() + + date = data.get('date') + time_from = data.get('time_from') + time_to = data.get('time_to', '23:00') + guests = data.get('guests', 2) + preferred_room_id = data.get('room_id') + + if not all([date, time_from]): + return jsonify({"error": "Datum und Uhrzeit erforderlich"}), 400 + + with get_db() as db: + # Basis-Query für verfügbare Tische + base_query = ''' + SELECT t.*, a.name as area_name, r.id as room_id, r.name as room_name + FROM tables t + JOIN areas a ON t.area_id = a.id + JOIN rooms r ON a.room_id = r.id + WHERE t.is_active = 1 + ''' + params = [] + + if preferred_room_id: + base_query += ' AND r.id = ?' + params.append(preferred_room_id) + + base_query += ' AND t.seats >= ?' + params.append(guests) + + all_tables = db.execute(base_query, params).fetchall() + + # Belegte Tische finden + occupied_query = ''' + SELECT DISTINCT json_each.value as table_id + FROM reservations r, json_each(r.table_ids) + WHERE r.date = ? + AND r.status IN ('confirmed', 'pending') + AND NOT (r.time_to <= ? OR r.time_from >= ?) + ''' + occupied = db.execute(occupied_query, (date, time_from, time_to)).fetchall() + occupied_ids = {str(row['table_id']) for row in occupied} + + # Verfügbare Tische filtern + available = [] + for t in all_tables: + if str(t['id']) not in occupied_ids: + table_dict = dict(t) + table_dict['is_available'] = True + available.append(table_dict) + + # Nach Raum gruppieren + rooms_with_tables = {} + for t in available: + room_id = t['room_id'] + if room_id not in rooms_with_tables: + rooms_with_tables[room_id] = { + 'room_id': room_id, + 'room_name': t['room_name'], + 'tables': [] + } + rooms_with_tables[room_id]['tables'].append(t) + + return jsonify({ + "date": date, + "time_from": time_from, + "time_to": time_to, + "guests": guests, + "available_tables": available, + "available_count": len(available), + "rooms": list(rooms_with_tables.values()) + }) + +@app.route('/api/dashboard/extended', methods=['GET']) +def dashboard_extended(): + """Erweitertes Dashboard mit Raumbuchungen""" + today = datetime.now().date().isoformat() + + with get_db() as db: + # Standard-Dashboard-Daten + today_count = db.execute( + "SELECT COUNT(*) FROM reservations WHERE date = ? AND status IN ('confirmed', 'pending')", + (today,) + ).fetchone()[0] + + # Raumbuchungen heute + room_bookings_count = db.execute( + "SELECT COUNT(*) FROM room_bookings WHERE date = ? AND status = 'confirmed'", + (today,) + ).fetchone()[0] + + # Unbearbeitete E-Mails + pending_emails = db.execute( + "SELECT COUNT(*) FROM emails WHERE status = 'needs_review'" + ).fetchone()[0] + + # Freie Tische + total_tables = db.execute( + "SELECT COUNT(*) FROM tables WHERE is_active = 1" + ).fetchone()[0] + + # Heutige Gäste + guests_today = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM reservations + WHERE date = ? AND status IN ('confirmed', 'pending') + ''', (today,)).fetchone()[0] + + # Raumbuchungs-Gäste + room_guests = db.execute(''' + SELECT COALESCE(SUM(guests), 0) FROM room_bookings + WHERE date = ? AND status = 'confirmed' + ''', (today,)).fetchone()[0] + + # Aktuelle Reservierungen + today_reservations = db.execute(''' + SELECT r.*, g.name as guest_name, g.phone + FROM reservations r + LEFT JOIN guests g ON r.guest_id = g.id + WHERE r.date = ? AND r.status IN ('confirmed', 'pending') + ORDER BY r.time_from + ''', (today,)).fetchall() + + # Aktuelle Raumbuchungen + today_room_bookings = db.execute(''' + SELECT rb.*, r.name as room_name + FROM room_bookings rb + JOIN rooms r ON rb.room_id = r.id + WHERE rb.date = ? AND rb.status = 'confirmed' + ORDER BY rb.time_from + ''', (today,)).fetchall() + + # Räume mit Auslastung + rooms = db.execute('SELECT * FROM rooms').fetchall() + rooms_data = [] + for room in rooms: + # Tische im Raum zählen + area_ids = db.execute('SELECT id FROM areas WHERE room_id = ?', + (room['id'],)).fetchall() + table_count = 0 + if area_ids: + ids = [a['id'] for a in area_ids] + placeholders = ','.join('?' for _ in ids) + table_count = db.execute(f''' + SELECT COUNT(*) FROM tables + WHERE area_id IN ({placeholders}) AND is_active = 1 + ''', ids).fetchone()[0] + + # Aktuelle Buchungen + bookings = db.execute(''' + SELECT COUNT(*) FROM room_bookings + WHERE room_id = ? AND date = ? AND status = 'confirmed' + ''', (room['id'], today)).fetchone()[0] + + rooms_data.append({ + 'id': room['id'], + 'name': room['name'], + 'capacity': room['capacity'], + 'color': room['color'], + 'table_count': table_count, + 'bookings_today': bookings + }) + + return jsonify({ + "today": today, + "reservations_count": today_count, + "room_bookings_count": room_bookings_count, + "pending_emails": pending_emails, + "total_tables": total_tables, + "guests_today": guests_today, + "room_guests_today": room_guests, + "total_guests_today": guests_today + room_guests, + "reservations": [dict(r) for r in today_reservations], + "room_bookings": [dict(r) for r in today_room_bookings], + "rooms": rooms_data + }) diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..60f467f --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,637 @@ + + + + + + Reservierungssystem + + + +
+ + +
+ +
+ +
+
+
+

Heute Reserviert

+
0
+
+
+

Gäste Heute

+
0
+
+
+

Freie Tische

+
0
+
+
+

⚠️ Klärung Erforderlich

+
0
+
+
+ +
+
+

Heutige Reservierungen

+ +
+
+ + + + + + + + + + + + + + +
Buchungsnr.ZeitNamePersonenStatusAktion
+
+
+
+ + + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..b48462a --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils module \ No newline at end of file diff --git a/app/utils/ollama_client.py b/app/utils/ollama_client.py new file mode 100644 index 0000000..f24b19c --- /dev/null +++ b/app/utils/ollama_client.py @@ -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", "")) \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..ea8735e --- /dev/null +++ b/backend/cmd/main.go @@ -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) + }) +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..78eb229 --- /dev/null +++ b/backend/internal/api/handlers.go @@ -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) +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..25dbaaa --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -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") +} diff --git a/backend/internal/db/database.go b/backend/internal/db/database.go new file mode 100644 index 0000000..e1415e7 --- /dev/null +++ b/backend/internal/db/database.go @@ -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 +} diff --git a/backend/internal/db/database_new.go b/backend/internal/db/database_new.go new file mode 100644 index 0000000..e1415e7 --- /dev/null +++ b/backend/internal/db/database_new.go @@ -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 +} diff --git a/backend/internal/email/imap.go b/backend/internal/email/imap.go new file mode 100644 index 0000000..5b4b6c1 --- /dev/null +++ b/backend/internal/email/imap.go @@ -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 +} diff --git a/backend/internal/handlers/handlers_new.go b/backend/internal/handlers/handlers_new.go new file mode 100644 index 0000000..78eb229 --- /dev/null +++ b/backend/internal/handlers/handlers_new.go @@ -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) +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..69204ee --- /dev/null +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/models/models_new.go b/backend/internal/models/models_new.go new file mode 100644 index 0000000..69204ee --- /dev/null +++ b/backend/internal/models/models_new.go @@ -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"` +} diff --git a/database_new.go b/database_new.go new file mode 100644 index 0000000..e1415e7 --- /dev/null +++ b/database_new.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a723c9 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..7140303 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,343 @@ + + + + + + Reservierungssystem + + + + +
+ +
+ +
+ + + +
+ + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..264e738 --- /dev/null +++ b/frontend/index.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; + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aababb5 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/handlers_new.go b/handlers_new.go new file mode 100644 index 0000000..78eb229 --- /dev/null +++ b/handlers_new.go @@ -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) +} diff --git a/models_new.go b/models_new.go new file mode 100644 index 0000000..69204ee --- /dev/null +++ b/models_new.go @@ -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"` +} diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..21f310a --- /dev/null +++ b/scripts/init-db.sql @@ -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); diff --git a/scripts/migrate.sql b/scripts/migrate.sql new file mode 100644 index 0000000..6a69376 --- /dev/null +++ b/scripts/migrate.sql @@ -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;