v2.0: 3-Raum-System - Hauptraum, Saal A, Saal B mit 18 Tischen, Raum-Buchungen, API-Doku
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# App module
|
||||
Binary file not shown.
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Datenbank-Modelle für Reservierungssystem"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from contextlib import contextmanager
|
||||
|
||||
DB_PATH = "/data/reservations.db"
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def init_db():
|
||||
"""Datenbank initialisieren"""
|
||||
with get_db() as db:
|
||||
db.executescript('''
|
||||
-- Gäste-Adressbuch
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
email TEXT UNIQUE,
|
||||
preferred_table_id INTEGER,
|
||||
notes TEXT,
|
||||
visit_count INTEGER DEFAULT 0,
|
||||
last_visit DATE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Räume
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
capacity INTEGER,
|
||||
color TEXT DEFAULT '#3498db'
|
||||
);
|
||||
|
||||
-- Bereiche innerhalb von Räumen
|
||||
CREATE TABLE IF NOT EXISTS areas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
available_from TIME DEFAULT '10:00',
|
||||
available_to TIME DEFAULT '23:00',
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tische
|
||||
CREATE TABLE IF NOT EXISTS tables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
area_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
x INTEGER DEFAULT 0,
|
||||
y INTEGER DEFAULT 0,
|
||||
width INTEGER DEFAULT 100,
|
||||
height INTEGER DEFAULT 100,
|
||||
shape TEXT DEFAULT 'rect' CHECK(shape IN ('rect', 'circle', 'oval')),
|
||||
seats INTEGER DEFAULT 4,
|
||||
is_combinable BOOLEAN DEFAULT 1,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tisch-Kombinationen (Zusammenlegungen)
|
||||
CREATE TABLE IF NOT EXISTS table_combinations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_table_id INTEGER NOT NULL,
|
||||
child_table_ids TEXT NOT NULL, -- JSON-Array
|
||||
total_seats INTEGER,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (parent_table_id) REFERENCES tables(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Reservierungen
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
booking_number TEXT UNIQUE NOT NULL, -- RES-20250512-001
|
||||
guest_id INTEGER,
|
||||
table_ids TEXT NOT NULL, -- JSON-Array
|
||||
date DATE NOT NULL,
|
||||
time_from TIME NOT NULL,
|
||||
time_to TIME,
|
||||
guests INTEGER NOT NULL,
|
||||
occasion TEXT,
|
||||
notes TEXT,
|
||||
source TEXT DEFAULT 'email' CHECK(source IN ('email', 'phone', 'web', 'walk-in')),
|
||||
phone_caller_name TEXT, -- Für Telefon-Buchungen
|
||||
status TEXT DEFAULT 'confirmed' CHECK(status IN ('confirmed', 'pending', 'cancelled', 'completed')),
|
||||
email_thread_id TEXT,
|
||||
created_by TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Änderungshistorie
|
||||
CREATE TABLE IF NOT EXISTS reservation_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reservation_id INTEGER NOT NULL,
|
||||
booking_number TEXT NOT NULL,
|
||||
changed_field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reason TEXT,
|
||||
FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- E-Mails
|
||||
CREATE TABLE IF NOT EXISTS emails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE NOT NULL,
|
||||
thread_id TEXT,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
sender TEXT,
|
||||
sender_email TEXT,
|
||||
parsed_json TEXT, -- JSON mit extrahierten Daten
|
||||
confidence REAL,
|
||||
action_type TEXT CHECK(action_type IN ('new', 'modification', 'cancellation', 'unknown')),
|
||||
status TEXT DEFAULT 'new' CHECK(status IN ('new', 'auto_processed', 'needs_review', 'confirmed', 'failed')),
|
||||
linked_reservation_id INTEGER,
|
||||
booking_number_found TEXT, -- Extrahierte Buchungsnummer
|
||||
auto_reply_sent BOOLEAN DEFAULT 0,
|
||||
auto_reply_status TEXT,
|
||||
received_at DATETIME,
|
||||
processed_at DATETIME,
|
||||
FOREIGN KEY (linked_reservation_id) REFERENCES reservations(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indizes
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_booking ON reservations(booking_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_guest ON reservations(guest_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_status ON reservations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_status ON emails(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_booking ON emails(booking_number_found);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_booking ON reservation_history(booking_number);
|
||||
|
||||
-- Trigger für updated_at
|
||||
CREATE TRIGGER IF NOT EXISTS update_reservations_timestamp
|
||||
AFTER UPDATE ON reservations
|
||||
BEGIN
|
||||
UPDATE reservations SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
''')
|
||||
|
||||
def generate_booking_number(date=None):
|
||||
"""Generiere Buchungsnummer RES-YYYY-MM-DD-XXX"""
|
||||
if date is None:
|
||||
date = datetime.now()
|
||||
|
||||
date_str = date.strftime('%Y-%m-%d')
|
||||
|
||||
with get_db() as db:
|
||||
# Zähle bestehende Reservierungen an diesem Tag
|
||||
count = db.execute(
|
||||
"SELECT COUNT(*) FROM reservations WHERE date = ?",
|
||||
(date_str,)
|
||||
).fetchone()[0]
|
||||
|
||||
return f"RES-{date_str}-{count + 1:03d}"
|
||||
|
||||
def log_change(db, reservation_id, booking_number, field, old_val, new_val, reason=None):
|
||||
"""Änderung in Historie speichern"""
|
||||
db.execute('''
|
||||
INSERT INTO reservation_history
|
||||
(reservation_id, booking_number, changed_field, old_value, new_value, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (reservation_id, booking_number, field, str(old_val), str(new_val), reason))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,637 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reservierungssystem</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #16213e;
|
||||
padding: 15px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #2d3a5c;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #00d4aa;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #00d4aa;
|
||||
color: #00d4aa;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn:hover, .nav-btn.active {
|
||||
background: #00d4aa;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #16213e;
|
||||
border: 1px solid #2d3a5c;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #00d4aa;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.alert-card:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.alert-card h3 {
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.alert-card .stat-value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #16213e;
|
||||
border: 1px solid #2d3a5c;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 20px 25px;
|
||||
border-bottom: 1px solid #2d3a5c;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #00d4aa;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #00b894;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #2d3a5c;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: rgba(0,212,170,0.05);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-confirmed {
|
||||
background: rgba(0,212,170,0.2);
|
||||
color: #00d4aa;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: rgba(241,196,15,0.2);
|
||||
color: #f1c40f;
|
||||
}
|
||||
|
||||
.badge-cancelled {
|
||||
background: rgba(231,76,60,0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.booking-number {
|
||||
font-family: monospace;
|
||||
background: rgba(0,212,170,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #00d4aa;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #16213e;
|
||||
border: 1px solid #2d3a5c;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #2d3a5c;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">🍽️ Reservierungssystem</div>
|
||||
<div class="nav">
|
||||
<button class="nav-btn active" onclick="showView('dashboard')">Dashboard</button>
|
||||
<button class="nav-btn" onclick="showView('reservations')">Reservierungen</button>
|
||||
<button class="nav-btn" onclick="showView('floorplan')">Tischplan</button>
|
||||
<button class="nav-btn" onclick="showView('guests')">Gäste</button>
|
||||
<button class="nav-btn" onclick="showView('emails')">E-Mails</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Dashboard View -->
|
||||
<div id="view-dashboard">
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Heute Reserviert</h3>
|
||||
<div class="stat-value" id="stat-today">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Gäste Heute</h3>
|
||||
<div class="stat-value" id="stat-guests">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Freie Tische</h3>
|
||||
<div class="stat-value" id="stat-available">0</div>
|
||||
</div>
|
||||
<div class="stat-card alert-card" onclick="showView('emails')" id="alert-card">
|
||||
<h3>⚠️ Klärung Erforderlich</h3>
|
||||
<div class="stat-value" id="stat-pending">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Heutige Reservierungen</h2>
|
||||
<button class="btn" onclick="openNewReservation()">+ Neue Reservierung</button>
|
||||
</div>
|
||||
<div id="today-reservations">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Buchungsnr.</th>
|
||||
<th>Zeit</th>
|
||||
<th>Name</th>
|
||||
<th>Personen</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="today-table-body">
|
||||
<!-- Dynamisch gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Views (hidden by default) -->
|
||||
<div id="view-reservations" class="hidden">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Alle Reservierungen</h2>
|
||||
<div>
|
||||
<input type="date" id="filter-date" onchange="loadReservations()">
|
||||
<button class="btn" onclick="openNewReservation()">+ Neu</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="all-reservations">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Buchungsnr.</th>
|
||||
<th>Datum</th>
|
||||
<th>Zeit</th>
|
||||
<th>Name</th>
|
||||
<th>Pers.</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reservations-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-floorplan" class="hidden">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Tischplan</h2>
|
||||
<select id="floorplan-date" onchange="loadFloorplan()">
|
||||
<option>Heute</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="floorplan-container" style="min-height: 500px; position: relative;"
|
||||
003e
|
||||
<!-- Dynamisch rendern -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-guests" class="hidden">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Gäste-Adressbuch</h2>
|
||||
<div>
|
||||
<input type="text" placeholder="Suchen..." id="guest-search" onkeyup="searchGuests()">
|
||||
<button class="btn" onclick="openNewGuest()">+ Neuer Gast</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="guests-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-emails" class="hidden">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>E-Mail-Verarbeitung</h2>
|
||||
<div class="nav">
|
||||
<button class="nav-btn active" onclick="filterEmails('needs_review')">Klärung</button>
|
||||
<button class="nav-btn" onclick="filterEmails('auto_processed')">Automatisch</button>
|
||||
<button class="nav-btn" onclick="filterEmails('confirmed')">Bestätigt</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="emails-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neue Reservierung -->
|
||||
<div id="modal-reservation" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<h2>Neue Reservierung</h2>
|
||||
|
||||
<form id="reservation-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Datum *</label>
|
||||
<input type="date" id="res-date" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Uhrzeit *</label>
|
||||
<input type="time" id="res-time" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input type="text" id="res-name" placeholder="Name suchen..." onkeyup="searchGuestForReservation()" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Personen *</label>
|
||||
<input type="number" id="res-guests" min="1" max="50" value="2" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Telefon</label>
|
||||
<input type="tel" id="res-phone">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="res-email">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Quelle</label>
|
||||
<select id="res-source">
|
||||
<option value="phone">Telefon</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="web">Web</option>
|
||||
<option value="walk-in">Walk-in</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="phone-caller-group">
|
||||
<label>Anrufer (bei Telefon-Buchung)</label>
|
||||
<input type="text" id="res-caller">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notizen</label>
|
||||
<textarea id="res-notes" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Abbrechen</button>
|
||||
<button class="btn" onclick="saveReservation()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global state
|
||||
let currentView = 'dashboard';
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDashboard();
|
||||
document.getElementById('res-date').valueAsDate = new Date();
|
||||
|
||||
// Source change handler
|
||||
document.getElementById('res-source').addEventListener('change', (e) => {
|
||||
const callerGroup = document.getElementById('phone-caller-group');
|
||||
callerGroup.style.display = e.target.value === 'phone' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// View switching
|
||||
function showView(view) {
|
||||
// Hide all views
|
||||
document.querySelectorAll('[id^="view-"]').forEach(el => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show selected view
|
||||
document.getElementById(`view-${view}`).classList.remove('hidden');
|
||||
|
||||
// Update nav buttons
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Load data for view
|
||||
if (view === 'dashboard') loadDashboard();
|
||||
if (view === 'reservations') loadReservations();
|
||||
if (view === 'floorplan') loadFloorplan();
|
||||
if (view === 'guests') loadGuests();
|
||||
if (view === 'emails') loadEmails();
|
||||
|
||||
currentView = view;
|
||||
}
|
||||
|
||||
// API calls
|
||||
async function api(endpoint, options = {}) {
|
||||
const res = await fetch(`/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
async function loadDashboard() {
|
||||
const data = await api('/dashboard');
|
||||
|
||||
document.getElementById('stat-today').textContent = data.today_count;
|
||||
document.getElementById('stat-guests').textContent = data.guests_today;
|
||||
document.getElementById('stat-available').textContent = data.total_tables - data.today_count;
|
||||
document.getElementById('stat-pending').textContent = data.pending_emails;
|
||||
|
||||
// Today's reservations
|
||||
const tbody = document.getElementById('today-table-body');
|
||||
tbody.innerHTML = data.today_reservations.map(r => `
|
||||
<tr>
|
||||
<td><span class="booking-number">${r.booking_number}</span></td>
|
||||
<td>${r.time_from}</td>
|
||||
<td>${r.guest_name || r.phone || 'Unbekannt'}</td>
|
||||
<td>${r.guests}</td>
|
||||
<td><span class="badge badge-${r.status}">${r.status}</span></td>
|
||||
<td><button class="btn btn-secondary" onclick="viewReservation(${r.id})">Details</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Reservations
|
||||
async function loadReservations() {
|
||||
const date = document.getElementById('filter-date').value;
|
||||
const params = date ? `?date=${date}` : '';
|
||||
const data = await api(`/reservations${params}`);
|
||||
|
||||
const tbody = document.getElementById('reservations-table-body');
|
||||
tbody.innerHTML = data.map(r => `
|
||||
<tr>
|
||||
<td><span class="booking-number">${r.booking_number}</span></td>
|
||||
<td>${r.date}</td>
|
||||
<td>${r.time_from}</td>
|
||||
<td>${r.guest_name || 'Unbekannt'}</td>
|
||||
<td>${r.guests}</td>
|
||||
<td><span class="badge badge-${r.status}">${r.status}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function openNewReservation() {
|
||||
document.getElementById('modal-reservation').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-reservation').classList.add('hidden');
|
||||
document.getElementById('reservation-form').reset();
|
||||
}
|
||||
|
||||
async function saveReservation() {
|
||||
const data = {
|
||||
date: document.getElementById('res-date').value,
|
||||
time_from: document.getElementById('res-time').value,
|
||||
guests: parseInt(document.getElementById('res-guests').value),
|
||||
name: document.getElementById('res-name').value,
|
||||
phone: document.getElementById('res-phone').value,
|
||||
email: document.getElementById('res-email').value,
|
||||
source: document.getElementById('res-source').value,
|
||||
phone_caller_name: document.getElementById('res-caller').value,
|
||||
notes: document.getElementById('res-notes').value
|
||||
};
|
||||
|
||||
const result = await api('/reservations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (result.booking_number) {
|
||||
alert(`Reservierung erstellt: ${result.booking_number}`);
|
||||
closeModal();
|
||||
loadDashboard();
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder functions
|
||||
function loadFloorplan() { console.log('Floorplan loading...'); }
|
||||
function loadGuests() { console.log('Guests loading...'); }
|
||||
function loadEmails() { console.log('Emails loading...'); }
|
||||
function filterEmails(status) { console.log('Filter:', status); }
|
||||
function searchGuests() { console.log('Search guests...'); }
|
||||
function openNewGuest() { console.log('New guest...'); }
|
||||
function searchGuestForReservation() { console.log('Search guest...'); }
|
||||
function viewReservation(id) { console.log('View:', id); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
# Utils module
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ollama Integration für KI-gestütztes E-Mail-Parsing"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
OLLAMA_URL = "http://192.168.0.150:11434/api/generate"
|
||||
DEFAULT_MODEL = "gemma4:latest"
|
||||
|
||||
def query_ollama(prompt, model=DEFAULT_MODEL, temperature=0.1):
|
||||
"""Ollama API aufrufen"""
|
||||
try:
|
||||
response = requests.post(
|
||||
OLLAMA_URL,
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": 500
|
||||
}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def extract_json_from_response(text):
|
||||
"""JSON aus Ollama-Antwort extrahieren"""
|
||||
# Versuche direkt als JSON zu parsen
|
||||
try:
|
||||
return json.loads(text)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Suche nach JSON-Block
|
||||
json_match = re.search(r'\{[\s\S]*\}', text)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"error": "Kein gültiges JSON gefunden", "raw": text}
|
||||
|
||||
def parse_email_with_ollama(email_subject, email_body, sender_name):
|
||||
"""E-Mail mit Ollama parsen"""
|
||||
|
||||
prompt = f"""Analysiere diese Restaurant-Reservierungs-E-Mail.
|
||||
|
||||
WICHTIG: Suche nach einer Buchungsnummer im Format RES-YYYY-MM-DD-XXX.
|
||||
Wenn eine Buchungsnummer erwähnt wird, ist es eine Änderung oder Stornierung.
|
||||
Wenn keine Buchungsnummer vorhanden ist, ist es eine neue Reservierung.
|
||||
|
||||
Absender: {sender_name}
|
||||
Betreff: {email_subject}
|
||||
|
||||
E-Mail-Inhalt:
|
||||
---
|
||||
{email_body}
|
||||
---
|
||||
|
||||
Extrahiere folgende Informationen und antworte NUR mit JSON:
|
||||
{{
|
||||
"booking_number": "RES-2025-05-12-001" oder null,
|
||||
"intent": "new" | "modification" | "cancellation",
|
||||
"name": "Name des Gastes",
|
||||
"phone": "Telefonnummer",
|
||||
"email": "E-Mail-Adresse",
|
||||
"date": "YYYY-MM-DD",
|
||||
"time": "HH:MM",
|
||||
"guests": 4,
|
||||
"occasion": "Geburtstag/Dinner/etc. oder null",
|
||||
"notes": "Weitere Informationen",
|
||||
"confidence": 0.95,
|
||||
"needs_review": false,
|
||||
"review_reason": null
|
||||
}}
|
||||
|
||||
Beispiele für Änderungen:
|
||||
- "Ich möchte meine Reservierung RES-2025-05-12-001 verschieben"
|
||||
- "Bitte stornieren Sie meinen Tisch"
|
||||
- "Wir sind jetzt 6 statt 4 Personen"
|
||||
|
||||
Antworte nur mit dem JSON-Objekt, keine Erklärungen."""
|
||||
|
||||
result = query_ollama(prompt)
|
||||
|
||||
if "error" in result:
|
||||
return {
|
||||
"error": result["error"],
|
||||
"needs_review": True,
|
||||
"review_reason": "Ollama-Fehler"
|
||||
}
|
||||
|
||||
response_text = result.get("response", "")
|
||||
parsed = extract_json_from_response(response_text)
|
||||
|
||||
# Validiere und ergänze
|
||||
if "confidence" not in parsed:
|
||||
parsed["confidence"] = 0.5
|
||||
|
||||
if "needs_review" not in parsed:
|
||||
parsed["needs_review"] = parsed.get("confidence", 0) < 0.7
|
||||
|
||||
return parsed
|
||||
|
||||
def generate_confirmation_email(reservation, is_new=True):
|
||||
"""Bestätigungsmail-Text generieren"""
|
||||
|
||||
if is_new:
|
||||
template = f"""Hallo {reservation.get('name', 'Gast')},
|
||||
|
||||
vielen Dank für Ihre Reservierung bei uns!
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📅 Datum: {reservation.get('date', 'unbekannt')}
|
||||
⏰ Uhrzeit: {reservation.get('time_from', 'unbekannt')}
|
||||
👥 Personen: {reservation.get('guests', 'unbekannt')}
|
||||
🍽️ Tisch: {reservation.get('table_name', 'wird zugewiesen')}
|
||||
|
||||
Bei Änderungen antworten Sie einfach auf diese E-Mail
|
||||
und nennen Sie Ihre Buchungsnummer: {reservation['booking_number']}
|
||||
|
||||
Wir freuen uns auf Ihren Besuch!
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr Restaurant-Team"""
|
||||
else:
|
||||
template = f"""Hallo {reservation.get('name', 'Gast')},
|
||||
|
||||
Ihre Reservierung wurde aktualisiert.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📋 BUCHUNGSNUMMER: {reservation['booking_number']}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📅 Neuer Termin: {reservation.get('date', 'unbekannt')} um {reservation.get('time_from', 'unbekannt')}
|
||||
👥 Personen: {reservation.get('guests', 'unbekannt')}
|
||||
|
||||
Alle anderen Details bleiben bestehen.
|
||||
|
||||
Bei weiteren Änderungen nennen Sie bitte immer Ihre Buchungsnummer: {reservation['booking_number']}
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr Restaurant-Team"""
|
||||
|
||||
return template
|
||||
|
||||
def suggest_table_for_group(available_tables, guests, date, time, ollama_model=DEFAULT_MODEL):
|
||||
"""Ollama schlägt optimalen Tisch vor"""
|
||||
|
||||
tables_info = "\n".join([
|
||||
f"- {t['name']}: {t['seats']} Plätze, Bereich: {t.get('area_name', 'unbekannt')}"
|
||||
for t in available_tables
|
||||
])
|
||||
|
||||
prompt = f"""Schlage den besten Tisch für diese Reservierung vor:
|
||||
|
||||
Gruppe: {guests} Personen
|
||||
Datum: {date}
|
||||
Uhrzeit: {time}
|
||||
|
||||
Verfügbare Tische:
|
||||
{tables_info}
|
||||
|
||||
Berücksichtige:
|
||||
- Passende Größe für {guests} Personen
|
||||
- Atmosphäre (Fenster bevorzugt)
|
||||
- Nicht zu groß oder zu klein
|
||||
|
||||
Antworte mit JSON:
|
||||
{{
|
||||
"suggested_table_id": 5,
|
||||
"reasoning": "Tisch 5 hat 6 Plätze, ist am Fenster und ideal für {guests} Personen",
|
||||
"alternatives": [3, 7]
|
||||
}}"""
|
||||
|
||||
result = query_ollama(prompt, model=ollama_model)
|
||||
|
||||
if "error" in result:
|
||||
return None
|
||||
|
||||
return extract_json_from_response(result.get("response", ""))
|
||||
Reference in New Issue
Block a user