Initial commit - Customer Portal for Coolify
This commit is contained in:
1
customer_portal/web/__init__.py
Executable file
1
customer_portal/web/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Web application package."""
|
||||
BIN
customer_portal/web/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/app.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/app.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/app.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/app.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/filters.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/filters.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/filters.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/filters.cpython-312.pyc
Executable file
Binary file not shown.
93
customer_portal/web/app.py
Executable file
93
customer_portal/web/app.py
Executable file
@@ -0,0 +1,93 @@
|
||||
"""Flask application factory."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from flask import Flask, g
|
||||
from flask_cors import CORS
|
||||
|
||||
|
||||
def create_app(config_name: str | None = None) -> Flask:
|
||||
"""Create Flask application."""
|
||||
from customer_portal.config import config
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load config
|
||||
config_name = config_name or os.getenv("FLASK_ENV", "default")
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Extensions
|
||||
CORS(app)
|
||||
|
||||
# Initialize Flask-Mail
|
||||
from customer_portal.services.email import mail
|
||||
|
||||
mail.init_app(app)
|
||||
|
||||
# Initialize database
|
||||
from customer_portal.models import close_db, create_tables, init_db
|
||||
|
||||
init_db(app.config["SQLALCHEMY_DATABASE_URI"])
|
||||
|
||||
# Create tables on first request (development)
|
||||
with app.app_context():
|
||||
create_tables()
|
||||
|
||||
# Teardown - close db session
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
# Register Jinja2 filters
|
||||
from customer_portal.web.filters import register_filters
|
||||
|
||||
register_filters(app)
|
||||
|
||||
# Context processor for templates
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.settings import (
|
||||
DEFAULT_BRANDING_CONFIG,
|
||||
PortalSettings,
|
||||
)
|
||||
|
||||
# Get branding config (with fallback to defaults)
|
||||
branding = DEFAULT_BRANDING_CONFIG.copy()
|
||||
try:
|
||||
db = get_db()
|
||||
branding = PortalSettings.get_branding(db)
|
||||
except Exception:
|
||||
pass # Use defaults if DB not available
|
||||
|
||||
return {
|
||||
"now": lambda: datetime.now(UTC),
|
||||
"branding": branding,
|
||||
}
|
||||
|
||||
# Make g.customer available in templates
|
||||
@app.before_request
|
||||
def load_customer():
|
||||
g.customer = None
|
||||
|
||||
# Blueprints
|
||||
from customer_portal.web.routes import (
|
||||
admin,
|
||||
api,
|
||||
auth,
|
||||
bookings,
|
||||
invoices,
|
||||
main,
|
||||
profile,
|
||||
videos,
|
||||
)
|
||||
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(auth.bp, url_prefix="/auth")
|
||||
app.register_blueprint(api.bp, url_prefix="/api")
|
||||
app.register_blueprint(admin.bp, url_prefix="/admin")
|
||||
app.register_blueprint(bookings.bp, url_prefix="/bookings")
|
||||
app.register_blueprint(invoices.bp, url_prefix="/invoices")
|
||||
app.register_blueprint(profile.bp, url_prefix="/profile")
|
||||
app.register_blueprint(videos.bp, url_prefix="/videos")
|
||||
|
||||
return app
|
||||
154
customer_portal/web/filters.py
Executable file
154
customer_portal/web/filters.py
Executable file
@@ -0,0 +1,154 @@
|
||||
"""Jinja2 template filters."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register custom Jinja2 filters."""
|
||||
|
||||
@app.template_filter("date")
|
||||
def format_date(value: Any, fmt: str = "%d.%m.%Y") -> str:
|
||||
"""Format date string or datetime object.
|
||||
|
||||
Args:
|
||||
value: Date string (ISO format) or datetime object
|
||||
fmt: Output format (default: German date format)
|
||||
|
||||
Returns:
|
||||
Formatted date string or empty string if invalid
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
# Handle ISO format dates
|
||||
if "T" in value:
|
||||
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
else:
|
||||
# Try common formats
|
||||
for parse_fmt in ["%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%d %H:%M:%S"]:
|
||||
try:
|
||||
value = datetime.strptime(value, parse_fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime(fmt)
|
||||
|
||||
return str(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value) if value else ""
|
||||
|
||||
@app.template_filter("datetime")
|
||||
def format_datetime(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str:
|
||||
"""Format datetime with time.
|
||||
|
||||
Args:
|
||||
value: Date string or datetime object
|
||||
fmt: Output format (default: German datetime format)
|
||||
|
||||
Returns:
|
||||
Formatted datetime string
|
||||
"""
|
||||
return format_date(value, fmt)
|
||||
|
||||
@app.template_filter("format_price")
|
||||
def format_price(value: Any) -> str:
|
||||
"""Format price with German number format.
|
||||
|
||||
Args:
|
||||
value: Numeric value or string
|
||||
|
||||
Returns:
|
||||
Formatted price string (e.g., "1.234,56")
|
||||
"""
|
||||
try:
|
||||
num = float(value)
|
||||
# German format: 1.234,56
|
||||
formatted = f"{num:,.2f}"
|
||||
# Swap . and , for German format
|
||||
formatted = formatted.replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
return formatted
|
||||
except (ValueError, TypeError):
|
||||
return str(value) if value else "0,00"
|
||||
|
||||
@app.template_filter("status_badge")
|
||||
def status_badge(status: str) -> str:
|
||||
"""Get Bootstrap badge class for booking status.
|
||||
|
||||
Args:
|
||||
status: Booking status string
|
||||
|
||||
Returns:
|
||||
Bootstrap badge class
|
||||
"""
|
||||
badges = {
|
||||
"confirmed": "bg-success",
|
||||
"pending": "bg-warning text-dark",
|
||||
"cancelled": "bg-danger",
|
||||
"cancel_requested": "bg-secondary",
|
||||
}
|
||||
return badges.get(status, "bg-secondary")
|
||||
|
||||
@app.template_filter("status_label")
|
||||
def status_label(status: str) -> str:
|
||||
"""Get German label for booking status.
|
||||
|
||||
Args:
|
||||
status: Booking status string
|
||||
|
||||
Returns:
|
||||
German status label
|
||||
"""
|
||||
labels = {
|
||||
"confirmed": "Bestaetigt",
|
||||
"pending": "Ausstehend",
|
||||
"cancelled": "Storniert",
|
||||
"cancel_requested": "Storno beantragt",
|
||||
}
|
||||
return labels.get(status, status or "Unbekannt")
|
||||
|
||||
@app.template_filter("invoice_status_badge")
|
||||
def invoice_status_badge(status: str) -> str:
|
||||
"""Get Bootstrap badge class for invoice status.
|
||||
|
||||
Args:
|
||||
status: Invoice status string from sevDesk
|
||||
|
||||
Returns:
|
||||
Bootstrap badge class
|
||||
"""
|
||||
badges = {
|
||||
"paid": "bg-success",
|
||||
"open": "bg-warning text-dark",
|
||||
"overdue": "bg-danger",
|
||||
"draft": "bg-secondary",
|
||||
"cancelled": "bg-secondary",
|
||||
"unknown": "bg-secondary",
|
||||
}
|
||||
return badges.get(status, "bg-secondary")
|
||||
|
||||
@app.template_filter("invoice_status_label")
|
||||
def invoice_status_label(status: str) -> str:
|
||||
"""Get German label for invoice status.
|
||||
|
||||
Args:
|
||||
status: Invoice status string from sevDesk
|
||||
|
||||
Returns:
|
||||
German status label
|
||||
"""
|
||||
labels = {
|
||||
"paid": "Bezahlt",
|
||||
"open": "Offen",
|
||||
"overdue": "Ueberfaellig",
|
||||
"draft": "Entwurf",
|
||||
"cancelled": "Storniert",
|
||||
"unknown": "Unbekannt",
|
||||
}
|
||||
return labels.get(status, status or "Unbekannt")
|
||||
1
customer_portal/web/routes/__init__.py
Executable file
1
customer_portal/web/routes/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Route blueprints."""
|
||||
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/admin.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/admin.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/admin.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/admin.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/api.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/api.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/api.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/api.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/auth.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/auth.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/auth.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/main.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/main.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/profile.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/profile.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/profile.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/profile.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/videos.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/videos.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/videos.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/videos.cpython-312.pyc
Executable file
Binary file not shown.
2079
customer_portal/web/routes/admin.py
Executable file
2079
customer_portal/web/routes/admin.py
Executable file
File diff suppressed because it is too large
Load Diff
502
customer_portal/web/routes/api.py
Executable file
502
customer_portal/web/routes/api.py
Executable file
@@ -0,0 +1,502 @@
|
||||
"""Internal API routes for external integrations."""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.customer import Customer
|
||||
from customer_portal.services.email import EmailService
|
||||
from customer_portal.services.otp import OTPService
|
||||
from customer_portal.services.token import TokenService
|
||||
from customer_portal.services.wordpress_api import WordPressWebhook
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_consent_field(field_name: str, field_type: str) -> bool:
|
||||
"""Check if field is a consent/checkbox field that should be excluded.
|
||||
|
||||
Consent fields contain keywords like "stimme", "akzeptiere", "einwilligung"
|
||||
and are typically checkboxes.
|
||||
|
||||
Args:
|
||||
field_name: The field name (lowercase)
|
||||
field_type: The field type
|
||||
|
||||
Returns:
|
||||
True if this is a consent field
|
||||
"""
|
||||
consent_keywords = [
|
||||
"stimme",
|
||||
"akzeptiere",
|
||||
"einwilligung",
|
||||
"zustimmung",
|
||||
"consent",
|
||||
"agree",
|
||||
"accept",
|
||||
"newsletter",
|
||||
"marketing",
|
||||
"agb",
|
||||
"datenschutz",
|
||||
"widerruf",
|
||||
"kontaktaufnahme",
|
||||
]
|
||||
return field_type == "checkbox" or any(
|
||||
keyword in field_name for keyword in consent_keywords
|
||||
)
|
||||
|
||||
|
||||
def _generate_field_mapping(booking_fields: dict) -> dict:
|
||||
"""Generate field mapping from WordPress booking fields.
|
||||
|
||||
Analyzes the booking fields sent by WordPress to determine which
|
||||
form field names correspond to standard customer data fields.
|
||||
|
||||
Sprint 6.7: Improved email field detection to exclude consent fields.
|
||||
|
||||
Args:
|
||||
booking_fields: Dictionary of booking fields from WordPress
|
||||
Format: {"fieldname": {"type": "email", "label": "E-Mail"}, ...}
|
||||
|
||||
Returns:
|
||||
Dictionary with field mappings like:
|
||||
{
|
||||
"email_field": "e_mail",
|
||||
"name_field": "name",
|
||||
"phone_field": "telefon",
|
||||
"address_field": "adresse",
|
||||
"zip_field": "plz",
|
||||
"city_field": "ort"
|
||||
}
|
||||
"""
|
||||
# Default fallbacks
|
||||
mapping = {
|
||||
"email_field": "email",
|
||||
"name_field": "name",
|
||||
"phone_field": "phone",
|
||||
"address_field": "address",
|
||||
"zip_field": "zip",
|
||||
"city_field": "city",
|
||||
}
|
||||
|
||||
if not booking_fields or not isinstance(booking_fields, dict):
|
||||
return mapping
|
||||
|
||||
# booking_fields structure: {"fieldname": {"type": "email", "label": "E-Mail"}, ...}
|
||||
for field_name, field_info in booking_fields.items():
|
||||
if not isinstance(field_info, dict):
|
||||
continue
|
||||
|
||||
field_type = field_info.get("type", "")
|
||||
name_lower = field_name.lower()
|
||||
|
||||
# Skip consent/checkbox fields entirely
|
||||
if _is_consent_field(name_lower, field_type):
|
||||
continue
|
||||
|
||||
# Email field detection (more specific):
|
||||
# 1. type="email" is the most reliable indicator
|
||||
# 2. Field name is exactly or starts with email/e_mail/e-mail/mail
|
||||
# 3. Must NOT be a long field name (consent fields are usually long)
|
||||
is_email_field = (
|
||||
field_type == "email"
|
||||
or name_lower in ("email", "e_mail", "e-mail", "mail")
|
||||
or (
|
||||
name_lower.startswith(("email", "e_mail", "e-mail"))
|
||||
and len(name_lower) < 15
|
||||
)
|
||||
)
|
||||
if is_email_field:
|
||||
mapping["email_field"] = field_name
|
||||
|
||||
# Name field: name is exactly "name" or contains "name" (excluding nachname, eltern, pferd)
|
||||
if field_name == "name" or (
|
||||
"name" in name_lower
|
||||
and field_type == "text"
|
||||
and "nach" not in name_lower
|
||||
and "eltern" not in name_lower
|
||||
and "pferd" not in name_lower
|
||||
):
|
||||
mapping["name_field"] = field_name
|
||||
|
||||
# Phone field: type="tel" or name contains "telefon"/"phone"
|
||||
if field_type == "tel" or "telefon" in name_lower or "phone" in name_lower:
|
||||
# Prefer "telefon" over "mobil"
|
||||
if mapping["phone_field"] == "phone" or "telefon" in name_lower:
|
||||
mapping["phone_field"] = field_name
|
||||
|
||||
# Address field: name contains "adresse"/"address"/"strasse"
|
||||
if (
|
||||
"adresse" in name_lower
|
||||
or "address" in name_lower
|
||||
or "strasse" in name_lower
|
||||
or "straße" in name_lower
|
||||
):
|
||||
mapping["address_field"] = field_name
|
||||
|
||||
# ZIP/PLZ field: name contains "plz"/"zip"/"postleitzahl"
|
||||
if "plz" in name_lower or "zip" in name_lower or "postleitzahl" in name_lower:
|
||||
mapping["zip_field"] = field_name
|
||||
|
||||
# City/Ort field: name contains "ort"/"city"/"stadt"
|
||||
if "ort" in name_lower or "city" in name_lower or "stadt" in name_lower:
|
||||
mapping["city_field"] = field_name
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
@bp.route("/webhook/booking", methods=["POST"])
|
||||
def webhook_booking():
|
||||
"""Handle booking webhook from WordPress.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"email": "customer@example.com",
|
||||
"name": "Customer Name",
|
||||
"phone": "optional phone"
|
||||
}
|
||||
|
||||
Required header:
|
||||
X-Portal-Secret: <WP_API_SECRET>
|
||||
"""
|
||||
# Verify secret
|
||||
secret = request.headers.get("X-Portal-Secret")
|
||||
expected_secret = current_app.config.get("WP_API_SECRET")
|
||||
|
||||
if not expected_secret:
|
||||
logger.error("WP_API_SECRET not configured")
|
||||
return jsonify({"error": "Server configuration error"}), 500
|
||||
|
||||
if secret != expected_secret:
|
||||
logger.warning(f"Unauthorized webhook attempt from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No JSON data provided"}), 400
|
||||
|
||||
db = get_db()
|
||||
result = WordPressWebhook.handle_booking_created(db, data)
|
||||
|
||||
if "error" in result:
|
||||
return jsonify(result), 400
|
||||
|
||||
logger.info(
|
||||
f"Webhook processed successfully for customer ID: {result.get('customer_id')}"
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
def _verify_portal_secret() -> bool:
|
||||
"""Verify X-Portal-Secret header matches configured secret."""
|
||||
secret = request.headers.get("X-Portal-Secret")
|
||||
expected_secret = current_app.config.get("WP_API_SECRET")
|
||||
|
||||
if not expected_secret:
|
||||
logger.error("WP_API_SECRET not configured")
|
||||
return False
|
||||
|
||||
return secret == expected_secret
|
||||
|
||||
|
||||
@bp.route("/customer/exists", methods=["POST"])
|
||||
def customer_exists():
|
||||
"""Check if a customer with given email exists.
|
||||
|
||||
WordPress calls this when customer enters email in booking form
|
||||
to automatically detect existing customers.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"email": "customer@example.com"
|
||||
}
|
||||
|
||||
Required header:
|
||||
X-Portal-Secret: <WP_API_SECRET>
|
||||
|
||||
Returns:
|
||||
{
|
||||
"exists": true/false
|
||||
}
|
||||
"""
|
||||
if not _verify_portal_secret():
|
||||
logger.warning(f"Unauthorized customer check from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get("email"):
|
||||
return jsonify({"error": "Email required"}), 400
|
||||
|
||||
email = data["email"].lower().strip()
|
||||
db = get_db()
|
||||
|
||||
customer = Customer.get_by_email(db, email)
|
||||
|
||||
# Don't reveal too much - just exists or not
|
||||
return jsonify({"exists": customer is not None})
|
||||
|
||||
|
||||
@bp.route("/prefill/request", methods=["POST"])
|
||||
def prefill_request():
|
||||
"""Request OTP for form pre-filling from WordPress.
|
||||
|
||||
WordPress calls this when customer clicks "Ich bin bereits Kunde"
|
||||
and enters their email. If the customer exists, we send an OTP.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"email": "customer@example.com"
|
||||
}
|
||||
|
||||
Required header:
|
||||
X-Portal-Secret: <WP_API_SECRET>
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "OTP sent if customer exists"
|
||||
}
|
||||
"""
|
||||
if not _verify_portal_secret():
|
||||
logger.warning(f"Unauthorized prefill request from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data or not data.get("email"):
|
||||
return jsonify({"error": "Email required"}), 400
|
||||
|
||||
email = data["email"].lower().strip()
|
||||
db = get_db()
|
||||
|
||||
# Check if customer exists
|
||||
customer = Customer.get_by_email(db, email)
|
||||
|
||||
if customer:
|
||||
# Generate OTP and send email
|
||||
try:
|
||||
code = OTPService.create_for_customer(db, customer.id, purpose="prefill")
|
||||
EmailService.send_otp(customer.email, code, purpose="prefill")
|
||||
logger.info(f"Prefill OTP sent to {email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send prefill OTP: {e}")
|
||||
# Don't reveal if customer exists or not
|
||||
pass
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Falls Sie bei uns registriert sind, erhalten Sie einen Code per E-Mail.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/prefill/verify", methods=["POST"])
|
||||
def prefill_verify():
|
||||
"""Verify OTP and return prefill token.
|
||||
|
||||
WordPress calls this after customer enters the OTP code.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"email": "customer@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
|
||||
Required header:
|
||||
X-Portal-Secret: <WP_API_SECRET>
|
||||
|
||||
Returns on success:
|
||||
{
|
||||
"success": true,
|
||||
"token": "base64_encoded_token",
|
||||
"customer": {
|
||||
"name": "Max Mustermann",
|
||||
"email": "max@example.com",
|
||||
"phone": "+43..."
|
||||
}
|
||||
}
|
||||
|
||||
Returns on failure:
|
||||
{
|
||||
"success": false,
|
||||
"error": "Invalid or expired code"
|
||||
}
|
||||
"""
|
||||
if not _verify_portal_secret():
|
||||
logger.warning(f"Unauthorized prefill verify from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
email = (data.get("email") or "").lower().strip()
|
||||
code = (data.get("code") or "").strip()
|
||||
|
||||
if not email or not code:
|
||||
return jsonify({"success": False, "error": "Email and code required"}), 400
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Get customer
|
||||
customer = Customer.get_by_email(db, email)
|
||||
if not customer:
|
||||
return jsonify({"success": False, "error": "Ungueltiger Code"}), 400
|
||||
|
||||
# Verify OTP
|
||||
if not OTPService.verify(db, customer.id, code, purpose="prefill"):
|
||||
return (
|
||||
jsonify({"success": False, "error": "Ungueltiger oder abgelaufener Code"}),
|
||||
400,
|
||||
)
|
||||
|
||||
# Generate prefill token
|
||||
try:
|
||||
token = TokenService.generate_prefill_token(customer)
|
||||
except ValueError as e:
|
||||
logger.error(f"Token generation failed: {e}")
|
||||
return jsonify({"success": False, "error": "Server error"}), 500
|
||||
|
||||
logger.info(f"Prefill token generated for customer {customer.id}")
|
||||
|
||||
# Sprint 12: All customer data comes from custom_fields
|
||||
# Use display properties for backwards compatibility
|
||||
addr = customer.display_address
|
||||
customer_data = {
|
||||
"name": customer.display_name,
|
||||
"email": customer.email,
|
||||
"phone": customer.display_phone,
|
||||
"address_street": addr.get("street", ""),
|
||||
"address_city": addr.get("city", ""),
|
||||
"address_zip": addr.get("zip", ""),
|
||||
}
|
||||
|
||||
# Include all custom_fields for full data access
|
||||
custom_fields = customer.get_custom_fields()
|
||||
if custom_fields:
|
||||
customer_data["custom_fields"] = custom_fields
|
||||
|
||||
# Generate field mapping from booking_fields sent by WordPress
|
||||
booking_fields = data.get("booking_fields", {})
|
||||
field_mapping = _generate_field_mapping(booking_fields)
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Customer data: {customer_data}")
|
||||
logger.info(f"Booking fields from WP: {booking_fields}")
|
||||
logger.info(f"Generated field mapping: {field_mapping}")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"token": token,
|
||||
"customer": customer_data,
|
||||
"field_mapping": field_mapping,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Sprint 6.6: Schema endpoint for WordPress field sync
|
||||
|
||||
|
||||
@bp.route("/schema", methods=["GET"])
|
||||
def get_schema():
|
||||
"""Get field schema from WordPress.
|
||||
|
||||
Proxies the WordPress schema endpoint for frontend use.
|
||||
Enables dynamic form generation based on WordPress settings.
|
||||
|
||||
Query params:
|
||||
kurs_id: Optional kurs ID for kurs-specific fields
|
||||
|
||||
Returns:
|
||||
Schema dictionary from WordPress
|
||||
"""
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
|
||||
kurs_id = request.args.get("kurs_id", type=int)
|
||||
|
||||
try:
|
||||
schema = WordPressAPI.get_schema(kurs_id)
|
||||
return jsonify(schema)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching schema: {e}")
|
||||
return jsonify({"error": "Failed to fetch schema"}), 500
|
||||
|
||||
|
||||
@bp.route("/customer/fields", methods=["GET", "PATCH"])
|
||||
def customer_fields():
|
||||
"""Get or update customer custom fields.
|
||||
|
||||
Requires authentication via session cookie.
|
||||
|
||||
GET: Returns all custom fields for current customer
|
||||
PATCH: Updates custom fields for current customer
|
||||
|
||||
Returns:
|
||||
Customer custom fields dictionary
|
||||
"""
|
||||
from customer_portal.web.decorators import get_current_customer
|
||||
|
||||
customer = get_current_customer()
|
||||
if not customer:
|
||||
return jsonify({"error": "Not authenticated"}), 401
|
||||
|
||||
db = get_db()
|
||||
|
||||
if request.method == "GET":
|
||||
# Sprint 12: All data comes from custom_fields
|
||||
# Return display properties for backwards compatibility
|
||||
addr = customer.display_address
|
||||
return jsonify(
|
||||
{
|
||||
"custom_fields": customer.get_custom_fields(),
|
||||
"core_fields": {
|
||||
"name": customer.display_name,
|
||||
"email": customer.email,
|
||||
"phone": customer.display_phone,
|
||||
"address_street": addr.get("street", ""),
|
||||
"address_city": addr.get("city", ""),
|
||||
"address_zip": addr.get("zip", ""),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# PATCH: Update fields - Sprint 12: All updates go to custom_fields
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
updated = []
|
||||
existing = customer.get_custom_fields()
|
||||
|
||||
# Update core fields (now stored in custom_fields)
|
||||
core_fields = data.get("core_fields", {})
|
||||
if core_fields:
|
||||
for key in ("phone", "address_street", "address_city", "address_zip"):
|
||||
if key in core_fields:
|
||||
existing[key] = core_fields[key]
|
||||
updated.append(key)
|
||||
|
||||
# Update custom fields
|
||||
custom_fields = data.get("custom_fields", {})
|
||||
if custom_fields:
|
||||
existing.update(custom_fields)
|
||||
updated.extend(list(custom_fields.keys()))
|
||||
|
||||
# Save all to custom_fields JSON
|
||||
if updated:
|
||||
customer.set_custom_fields(existing)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Customer {customer.id} fields updated: {updated}")
|
||||
|
||||
return jsonify({"success": True, "updated": updated})
|
||||
332
customer_portal/web/routes/auth.py
Executable file
332
customer_portal/web/routes/auth.py
Executable file
@@ -0,0 +1,332 @@
|
||||
"""Authentication routes."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.customer import Customer
|
||||
from customer_portal.services import AuthService, EmailService, OTPService
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require authentication."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = session.get("auth_token")
|
||||
if not token:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
db = get_db()
|
||||
customer = AuthService.get_customer_by_token(db, token)
|
||||
if not customer:
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
g.customer = customer
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page - enter email."""
|
||||
# Already logged in?
|
||||
if session.get("auth_token"):
|
||||
db = get_db()
|
||||
if AuthService.get_customer_by_token(db, session["auth_token"]):
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
flash("Bitte geben Sie eine gueltige E-Mail-Adresse ein.", "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Get or create customer
|
||||
customer = AuthService.get_or_create_customer(db, email)
|
||||
|
||||
# Rate limiting: max 5 OTPs per hour
|
||||
max_attempts = current_app.config.get("OTP_MAX_ATTEMPTS", 5)
|
||||
recent = OTPService.count_recent_attempts(db, customer.id)
|
||||
if recent >= max_attempts:
|
||||
flash(
|
||||
"Zu viele Anfragen. Bitte warten Sie eine Stunde.",
|
||||
"error",
|
||||
)
|
||||
return render_template("auth/login.html")
|
||||
|
||||
# Generate and send OTP
|
||||
code = OTPService.create_for_customer(db, customer.id, "login")
|
||||
EmailService.send_otp(email, code, "login")
|
||||
|
||||
# Store customer ID for verification step
|
||||
session["pending_customer_id"] = customer.id
|
||||
session["pending_email"] = email
|
||||
|
||||
return redirect(url_for("auth.verify"))
|
||||
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@bp.route("/verify", methods=["GET", "POST"])
|
||||
def verify():
|
||||
"""Verify OTP code."""
|
||||
customer_id = session.get("pending_customer_id")
|
||||
email = session.get("pending_email")
|
||||
|
||||
if not customer_id:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "").strip()
|
||||
|
||||
if not code or len(code) != 6:
|
||||
flash("Bitte geben Sie den 6-stelligen Code ein.", "error")
|
||||
return render_template("auth/verify.html", email=email)
|
||||
|
||||
db = get_db()
|
||||
|
||||
if OTPService.verify(db, customer_id, code, "login"):
|
||||
# Create session
|
||||
token = AuthService.create_session(
|
||||
db,
|
||||
customer_id,
|
||||
request.remote_addr,
|
||||
request.user_agent.string if request.user_agent else "",
|
||||
)
|
||||
|
||||
# Store session token
|
||||
session["auth_token"] = token
|
||||
session.pop("pending_customer_id", None)
|
||||
session.pop("pending_email", None)
|
||||
|
||||
flash("Erfolgreich eingeloggt!", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
flash("Ungueltiger oder abgelaufener Code.", "error")
|
||||
|
||||
return render_template("auth/verify.html", email=email)
|
||||
|
||||
|
||||
@bp.route("/resend", methods=["POST"])
|
||||
def resend():
|
||||
"""Resend OTP code."""
|
||||
customer_id = session.get("pending_customer_id")
|
||||
email = session.get("pending_email")
|
||||
|
||||
if not customer_id or not email:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Rate limiting
|
||||
max_attempts = current_app.config.get("OTP_MAX_ATTEMPTS", 5)
|
||||
recent = OTPService.count_recent_attempts(db, customer_id)
|
||||
if recent >= max_attempts:
|
||||
flash("Zu viele Anfragen. Bitte warten Sie eine Stunde.", "error")
|
||||
return redirect(url_for("auth.verify"))
|
||||
|
||||
# Generate new code
|
||||
code = OTPService.create_for_customer(db, customer_id, "login")
|
||||
EmailService.send_otp(email, code, "login")
|
||||
|
||||
flash("Ein neuer Code wurde gesendet.", "success")
|
||||
return redirect(url_for("auth.verify"))
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"""Logout and clear session."""
|
||||
token = session.get("auth_token")
|
||||
if token:
|
||||
db = get_db()
|
||||
AuthService.logout(db, token)
|
||||
|
||||
session.clear()
|
||||
flash("Sie wurden erfolgreich ausgeloggt.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registration Routes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
"""Registration - Step 1: Enter email."""
|
||||
# Already logged in?
|
||||
if session.get("auth_token"):
|
||||
db = get_db()
|
||||
if AuthService.get_customer_by_token(db, session["auth_token"]):
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
flash("Bitte geben Sie eine gueltige E-Mail-Adresse ein.", "error")
|
||||
return render_template("auth/register.html")
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Check if customer already exists
|
||||
customer = db.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if customer:
|
||||
flash(
|
||||
"Diese E-Mail ist bereits registriert. Bitte melden Sie sich an.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# Store email in session for registration flow
|
||||
session["register_email"] = email
|
||||
|
||||
# Generate OTP
|
||||
otp_minutes = current_app.config.get("OTP_LIFETIME_MINUTES", 10)
|
||||
code = OTPService.generate()
|
||||
session["register_otp"] = code
|
||||
session["register_otp_expires"] = (
|
||||
datetime.now(UTC) + timedelta(minutes=otp_minutes)
|
||||
).isoformat()
|
||||
|
||||
# Send OTP email
|
||||
EmailService.send_otp(email, code, "register")
|
||||
|
||||
flash("Ein Bestaetigungscode wurde an Ihre E-Mail gesendet.", "success")
|
||||
return redirect(url_for("auth.register_verify"))
|
||||
|
||||
return render_template("auth/register.html")
|
||||
|
||||
|
||||
@bp.route("/register/verify", methods=["GET", "POST"])
|
||||
def register_verify():
|
||||
"""Registration - Step 2: Verify OTP."""
|
||||
email = session.get("register_email")
|
||||
if not email:
|
||||
return redirect(url_for("auth.register"))
|
||||
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "").strip()
|
||||
stored_code = session.get("register_otp")
|
||||
expires_str = session.get("register_otp_expires")
|
||||
|
||||
if not code or len(code) != 6:
|
||||
flash("Bitte geben Sie den 6-stelligen Code ein.", "error")
|
||||
return render_template("auth/register_verify.html", email=email)
|
||||
|
||||
if stored_code and code == stored_code and expires_str:
|
||||
# Check if not expired
|
||||
expires = datetime.fromisoformat(expires_str)
|
||||
if expires > datetime.now(UTC):
|
||||
session["register_verified"] = True
|
||||
return redirect(url_for("auth.register_profile"))
|
||||
|
||||
flash("Ungueltiger oder abgelaufener Code.", "error")
|
||||
|
||||
return render_template("auth/register_verify.html", email=email)
|
||||
|
||||
|
||||
@bp.route("/register/resend", methods=["POST"])
|
||||
def register_resend():
|
||||
"""Resend registration OTP."""
|
||||
email = session.get("register_email")
|
||||
if not email:
|
||||
return redirect(url_for("auth.register"))
|
||||
|
||||
# Generate new OTP
|
||||
otp_minutes = current_app.config.get("OTP_LIFETIME_MINUTES", 10)
|
||||
code = OTPService.generate()
|
||||
session["register_otp"] = code
|
||||
session["register_otp_expires"] = (
|
||||
datetime.now(UTC) + timedelta(minutes=otp_minutes)
|
||||
).isoformat()
|
||||
|
||||
# Send OTP email
|
||||
EmailService.send_otp(email, code, "register")
|
||||
|
||||
flash("Ein neuer Code wurde gesendet.", "success")
|
||||
return redirect(url_for("auth.register_verify"))
|
||||
|
||||
|
||||
@bp.route("/register/profile", methods=["GET", "POST"])
|
||||
def register_profile():
|
||||
"""Registration - Step 3: Enter profile data."""
|
||||
email = session.get("register_email")
|
||||
verified = session.get("register_verified")
|
||||
|
||||
if not email or not verified:
|
||||
return redirect(url_for("auth.register"))
|
||||
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
phone = request.form.get("phone", "").strip()
|
||||
|
||||
if not name:
|
||||
flash("Bitte geben Sie Ihren Namen ein.", "error")
|
||||
return render_template("auth/register_profile.html", email=email)
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Double-check email doesn't exist (race condition protection)
|
||||
existing = db.query(Customer).filter(Customer.email == email).first()
|
||||
if existing:
|
||||
session.pop("register_email", None)
|
||||
session.pop("register_otp", None)
|
||||
session.pop("register_otp_expires", None)
|
||||
session.pop("register_verified", None)
|
||||
flash("Diese E-Mail ist bereits registriert.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# Create customer with defaults from settings
|
||||
customer = Customer.create_with_defaults(
|
||||
db,
|
||||
email=email,
|
||||
name=name,
|
||||
phone=phone if phone else None,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
|
||||
# Clear registration session data
|
||||
session.pop("register_email", None)
|
||||
session.pop("register_otp", None)
|
||||
session.pop("register_otp_expires", None)
|
||||
session.pop("register_verified", None)
|
||||
|
||||
# Auto-login after registration
|
||||
token = AuthService.create_session(
|
||||
db,
|
||||
customer.id,
|
||||
request.remote_addr,
|
||||
request.user_agent.string if request.user_agent else "",
|
||||
)
|
||||
session["auth_token"] = token
|
||||
|
||||
# Send welcome email
|
||||
EmailService.send_welcome(email, name)
|
||||
|
||||
flash("Registrierung erfolgreich! Willkommen im Kundenportal.", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("auth/register_profile.html", email=email)
|
||||
176
customer_portal/web/routes/bookings.py
Executable file
176
customer_portal/web/routes/bookings.py
Executable file
@@ -0,0 +1,176 @@
|
||||
"""Bookings routes.
|
||||
|
||||
Sprint 14: Added local database storage for bookings.
|
||||
Bookings can be synced from WordPress and stored locally.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.booking import Booking
|
||||
from customer_portal.services.token import TokenService
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("bookings", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def list_bookings():
|
||||
"""List customer bookings from local database.
|
||||
|
||||
Sprint 14: Now uses local database instead of WordPress API.
|
||||
Bookings are synced via admin panel.
|
||||
"""
|
||||
db = get_db()
|
||||
|
||||
# Get filter
|
||||
status_filter = request.args.get("status", "")
|
||||
|
||||
# Get bookings from local database
|
||||
query = db.query(Booking).filter(Booking.customer_id == g.customer.id)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(Booking.status == status_filter)
|
||||
|
||||
bookings = query.order_by(Booking.kurs_date.desc()).all()
|
||||
|
||||
return render_template(
|
||||
"bookings/list.html",
|
||||
bookings=bookings,
|
||||
status_filter=status_filter,
|
||||
use_local_db=True,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<int:booking_id>")
|
||||
@login_required
|
||||
def detail(booking_id: int):
|
||||
"""Booking detail page from local database.
|
||||
|
||||
Sprint 14: Now uses local database.
|
||||
"""
|
||||
db = get_db()
|
||||
|
||||
booking = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.id == booking_id,
|
||||
Booking.customer_id == g.customer.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not booking:
|
||||
flash("Buchung nicht gefunden oder kein Zugriff.", "error")
|
||||
return redirect(url_for("bookings.list_bookings"))
|
||||
|
||||
return render_template("bookings/detail.html", booking=booking, use_local_db=True)
|
||||
|
||||
|
||||
@bp.route("/<int:booking_id>/cancel", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def cancel(booking_id: int):
|
||||
"""Request booking cancellation.
|
||||
|
||||
Sprint 14: Uses local database for booking lookup,
|
||||
but still calls WordPress API for the actual cancellation.
|
||||
"""
|
||||
db = get_db()
|
||||
|
||||
# Get booking from local database
|
||||
booking = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.id == booking_id,
|
||||
Booking.customer_id == g.customer.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not booking:
|
||||
flash("Buchung nicht gefunden oder kein Zugriff.", "error")
|
||||
return redirect(url_for("bookings.list_bookings"))
|
||||
|
||||
# Check if already cancelled or pending
|
||||
if booking.status in ("cancelled", "cancel_requested"):
|
||||
flash("Diese Buchung wurde bereits storniert oder ist in Bearbeitung.", "info")
|
||||
return redirect(url_for("bookings.detail", booking_id=booking_id))
|
||||
|
||||
if request.method == "POST":
|
||||
reason = request.form.get("reason", "").strip()
|
||||
|
||||
try:
|
||||
# Call WordPress API to cancel (uses wp_booking_id)
|
||||
result = WordPressAPI.cancel_booking(
|
||||
booking.wp_booking_id, g.customer.email, reason
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
# Update local status
|
||||
booking.status = "cancel_requested"
|
||||
db.commit()
|
||||
|
||||
flash(
|
||||
"Stornierungsanfrage wurde gesendet. Sie erhalten eine Bestätigung per E-Mail.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("bookings.detail", booking_id=booking_id))
|
||||
else:
|
||||
error = result.get("error", "Unbekannter Fehler")
|
||||
flash(f"Stornierung fehlgeschlagen: {error}", "error")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling booking {booking_id}: {e}")
|
||||
flash("Stornierung konnte nicht durchgeführt werden.", "error")
|
||||
|
||||
return render_template("bookings/cancel.html", booking=booking, use_local_db=True)
|
||||
|
||||
|
||||
@bp.route("/book/<int:kurs_id>")
|
||||
@login_required
|
||||
def book_kurs(kurs_id: int):
|
||||
"""Redirect to WordPress booking with pre-filled customer data.
|
||||
|
||||
Generates a signed token containing customer data and redirects
|
||||
to the WordPress kurs page with the token as URL parameter.
|
||||
WordPress validates the token and pre-fills the booking form.
|
||||
|
||||
Args:
|
||||
kurs_id: WordPress post ID of the kurs to book
|
||||
"""
|
||||
try:
|
||||
token = TokenService.generate_prefill_token(g.customer)
|
||||
except ValueError as e:
|
||||
logger.error(f"Token generation failed: {e}")
|
||||
flash("Verbindung zu WordPress nicht konfiguriert.", "error")
|
||||
return redirect(url_for("bookings.list_bookings"))
|
||||
|
||||
# Get WordPress base URL (remove API path).
|
||||
wp_api_url = current_app.config.get("WP_API_URL", "")
|
||||
wp_base_url = wp_api_url.replace("/wp-json/kurs-booking/v1", "")
|
||||
|
||||
if not wp_base_url:
|
||||
logger.error("WP_API_URL not configured")
|
||||
flash("WordPress-URL nicht konfiguriert.", "error")
|
||||
return redirect(url_for("bookings.list_bookings"))
|
||||
|
||||
# Build URL to kurs page with prefill token.
|
||||
# Using ?p=ID format for compatibility with any permalink structure.
|
||||
booking_url = f"{wp_base_url}/?p={kurs_id}&kb_prefill={token}"
|
||||
|
||||
logger.info(f"Redirecting customer {g.customer.id} to book kurs {kurs_id}")
|
||||
return redirect(booking_url)
|
||||
119
customer_portal/web/routes/invoices.py
Executable file
119
customer_portal/web/routes/invoices.py
Executable file
@@ -0,0 +1,119 @@
|
||||
"""Invoice routes."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("invoices", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def list_invoices():
|
||||
"""List customer invoices."""
|
||||
try:
|
||||
invoices = WordPressAPI.get_invoices(g.customer.email)
|
||||
except ValueError as e:
|
||||
logger.error(f"WordPress API not configured: {e}")
|
||||
flash("WordPress-Verbindung nicht konfiguriert.", "error")
|
||||
invoices = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoices: {e}")
|
||||
flash("Rechnungen konnten nicht geladen werden.", "error")
|
||||
invoices = []
|
||||
|
||||
# Extract available years for filter
|
||||
years = set()
|
||||
for invoice in invoices:
|
||||
if invoice.get("created_at"):
|
||||
try:
|
||||
date_str = invoice["created_at"]
|
||||
year = date_str[:4] if "T" in date_str else date_str[:4]
|
||||
years.add(year)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sort years descending
|
||||
years = sorted(years, reverse=True)
|
||||
|
||||
# Get filter parameters
|
||||
status_filter = request.args.get("status", "")
|
||||
year_filter = request.args.get("year", "")
|
||||
|
||||
# Apply filters
|
||||
filtered_invoices = invoices
|
||||
if status_filter:
|
||||
filtered_invoices = [
|
||||
inv for inv in filtered_invoices if inv.get("status") == status_filter
|
||||
]
|
||||
if year_filter:
|
||||
filtered_invoices = [
|
||||
inv
|
||||
for inv in filtered_invoices
|
||||
if inv.get("created_at", "")[:4] == year_filter
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"invoices/list.html",
|
||||
invoices=filtered_invoices,
|
||||
all_invoices=invoices,
|
||||
years=years,
|
||||
status_filter=status_filter,
|
||||
year_filter=year_filter,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<int:invoice_id>/pdf")
|
||||
@login_required
|
||||
def download_pdf(invoice_id: int):
|
||||
"""Download invoice PDF from WordPress/sevDesk."""
|
||||
try:
|
||||
# Get PDF via WordPress API (includes ownership verification)
|
||||
pdf_data = WordPressAPI.get_invoice_pdf(invoice_id, g.customer.email)
|
||||
except ValueError as e:
|
||||
logger.error(f"WordPress API not configured: {e}")
|
||||
flash("WordPress-Verbindung nicht konfiguriert.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoice PDF {invoice_id}: {e}")
|
||||
flash("PDF konnte nicht geladen werden.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
if not pdf_data:
|
||||
flash("Rechnung nicht gefunden oder kein Zugriff.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Check for error response
|
||||
if "error" in pdf_data:
|
||||
flash(f"Fehler: {pdf_data['error']}", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Decode base64 PDF
|
||||
try:
|
||||
pdf_content = base64.b64decode(pdf_data["pdf"])
|
||||
filename = pdf_data.get("filename", f"Rechnung_{invoice_id}.pdf")
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding PDF: {e}")
|
||||
flash("PDF konnte nicht dekodiert werden.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Return PDF as download
|
||||
return Response(
|
||||
pdf_content,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
90
customer_portal/web/routes/main.py
Executable file
90
customer_portal/web/routes/main.py
Executable file
@@ -0,0 +1,90 @@
|
||||
"""Main routes."""
|
||||
|
||||
from flask import Blueprint, g, jsonify, redirect, render_template, url_for
|
||||
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
"""Home page - redirect to dashboard or login."""
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health():
|
||||
"""Health check for Docker and load balancers.
|
||||
|
||||
Returns:
|
||||
- 200 OK if database connection works
|
||||
- 503 Service Unavailable if database is down
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from customer_portal.models import get_db
|
||||
|
||||
try:
|
||||
db = get_db()
|
||||
db.execute(text("SELECT 1"))
|
||||
return (
|
||||
jsonify({"status": "healthy", "database": "connected", "version": "1.0.0"}),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
return (
|
||||
jsonify(
|
||||
{"status": "unhealthy", "database": "disconnected", "error": str(e)}
|
||||
),
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/api/status")
|
||||
def api_status():
|
||||
"""API status endpoint."""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Kundenportal laeuft",
|
||||
"version": "0.1.0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard."""
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.booking import Booking
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Get upcoming bookings (confirmed, future dates)
|
||||
from datetime import date
|
||||
|
||||
upcoming_bookings = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.customer_id == g.customer.id,
|
||||
Booking.status == "confirmed",
|
||||
Booking.kurs_date >= date.today(),
|
||||
)
|
||||
.order_by(Booking.kurs_date.asc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get recent bookings count
|
||||
total_bookings = (
|
||||
db.query(Booking).filter(Booking.customer_id == g.customer.id).count()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
customer=g.customer,
|
||||
upcoming_bookings=upcoming_bookings,
|
||||
total_bookings=total_bookings,
|
||||
)
|
||||
187
customer_portal/web/routes/profile.py
Executable file
187
customer_portal/web/routes/profile.py
Executable file
@@ -0,0 +1,187 @@
|
||||
"""Profile routes with dynamic WordPress field synchronization.
|
||||
|
||||
Sprint 6.6: Dynamic fields from WordPress schema.
|
||||
Sprint 10: Admin-configurable field visibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.settings import PortalSettings
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("profile", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def show():
|
||||
"""Display profile with dynamic fields from WordPress schema."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
# Get field configuration from admin settings
|
||||
field_config = PortalSettings.get_field_config(db)
|
||||
|
||||
# Fetch schema from WordPress (only if custom fields are enabled)
|
||||
schema = {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
if field_config.get("custom_fields_visible"):
|
||||
try:
|
||||
schema = WordPressAPI.get_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch schema: {e}")
|
||||
|
||||
# Get current custom field values
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
return render_template(
|
||||
"profile/show.html",
|
||||
customer=customer,
|
||||
schema=schema,
|
||||
custom_fields=custom_fields,
|
||||
field_config=field_config,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit():
|
||||
"""Edit profile with dynamic fields."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
# Get field configuration from admin settings
|
||||
field_config = PortalSettings.get_field_config(db)
|
||||
|
||||
# Fetch schema from WordPress (only if custom fields are enabled)
|
||||
schema = {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
if field_config.get("custom_fields_visible"):
|
||||
try:
|
||||
schema = WordPressAPI.get_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch schema: {e}")
|
||||
|
||||
if request.method == "POST":
|
||||
# Sprint 12: All updates go to custom_fields
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
# Update core fields (now stored in custom_fields)
|
||||
if request.form.get("phone") is not None:
|
||||
custom_fields["phone"] = request.form.get("phone", "").strip()
|
||||
|
||||
if request.form.get("address_street") is not None:
|
||||
custom_fields["address_street"] = request.form.get(
|
||||
"address_street", ""
|
||||
).strip()
|
||||
|
||||
if request.form.get("address_city") is not None:
|
||||
custom_fields["address_city"] = request.form.get("address_city", "").strip()
|
||||
|
||||
if request.form.get("address_zip") is not None:
|
||||
custom_fields["address_zip"] = request.form.get("address_zip", "").strip()
|
||||
|
||||
# Update custom fields based on schema
|
||||
schema_custom_fields = schema.get("custom_fields", [])
|
||||
|
||||
for field in schema_custom_fields:
|
||||
field_name = field.get("name")
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
form_value = request.form.get(f"custom_{field_name}")
|
||||
if form_value is not None:
|
||||
# Handle different field types
|
||||
field_type = field.get("type", "text")
|
||||
if field_type == "checkbox":
|
||||
custom_fields[field_name] = form_value in {"on", "1"}
|
||||
else:
|
||||
custom_fields[field_name] = form_value.strip()
|
||||
|
||||
customer.set_custom_fields(custom_fields)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Profile updated for customer {customer.id}")
|
||||
flash("Profil erfolgreich aktualisiert.", "success")
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
# GET: Show edit form
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
return render_template(
|
||||
"profile/edit.html",
|
||||
customer=customer,
|
||||
schema=schema,
|
||||
custom_fields=custom_fields,
|
||||
field_config=field_config,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/sync", methods=["POST"])
|
||||
@login_required
|
||||
def sync_from_wordpress():
|
||||
"""Sync custom fields from latest booking.
|
||||
|
||||
Fetches the most recent booking and syncs any custom field values
|
||||
that are missing in the customer profile.
|
||||
"""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
# Get latest booking
|
||||
bookings = WordPressAPI.get_bookings(customer.email)
|
||||
if not bookings:
|
||||
flash("Keine Buchungen zum Synchronisieren gefunden.", "info")
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
# Get most recent booking (first in list, sorted by date desc)
|
||||
latest_booking = bookings[0]
|
||||
booking_id = latest_booking.get("id")
|
||||
|
||||
if booking_id:
|
||||
synced = WordPressAPI.sync_customer_fields_from_booking(
|
||||
customer, booking_id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if synced:
|
||||
synced_names = list(synced.keys())
|
||||
flash(f"Felder synchronisiert: {', '.join(synced_names)}", "success")
|
||||
else:
|
||||
flash("Alle Felder sind bereits aktuell.", "info")
|
||||
else:
|
||||
flash("Buchungs-ID nicht gefunden.", "warning")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed for customer {customer.id}: {e}")
|
||||
flash(
|
||||
"Synchronisation fehlgeschlagen. Bitte versuchen Sie es spaeter.", "error"
|
||||
)
|
||||
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
|
||||
@bp.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings():
|
||||
"""Manage notification and communication settings."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
if request.method == "POST":
|
||||
# Update notification settings
|
||||
customer.email_notifications = request.form.get("email_notifications") == "on"
|
||||
customer.email_reminders = request.form.get("email_reminders") == "on"
|
||||
customer.email_invoices = request.form.get("email_invoices") == "on"
|
||||
customer.email_marketing = request.form.get("email_marketing") == "on"
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Settings updated for customer {customer.id}")
|
||||
flash("Einstellungen erfolgreich gespeichert.", "success")
|
||||
return redirect(url_for("profile.settings"))
|
||||
|
||||
return render_template("profile/settings.html", customer=customer)
|
||||
114
customer_portal/web/routes/videos.py
Executable file
114
customer_portal/web/routes/videos.py
Executable file
@@ -0,0 +1,114 @@
|
||||
"""Video routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, flash, g, redirect, render_template, url_for
|
||||
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("videos", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def list_videos():
|
||||
"""List customer videos grouped by bundles and courses."""
|
||||
try:
|
||||
videos = WordPressAPI.get_videos(g.customer.email)
|
||||
except ValueError as e:
|
||||
logger.error(f"WordPress API not configured: {e}")
|
||||
flash("WordPress-Verbindung nicht konfiguriert.", "error")
|
||||
videos = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching videos: {e}")
|
||||
flash("Videos konnten nicht geladen werden.", "error")
|
||||
videos = []
|
||||
|
||||
# Group videos by bundles and courses
|
||||
bundles = (
|
||||
{}
|
||||
) # bundle_name -> {bundle_id, courses: {kurs_title -> {kurs_id, videos}}}
|
||||
courses = {} # Single course purchases: kurs_title -> {kurs_id, videos}
|
||||
|
||||
for video in videos:
|
||||
bundle_id = video.get("bundle_id")
|
||||
bundle_name = video.get("bundle_name")
|
||||
kurs_title = video.get("kurs_title", "Sonstige")
|
||||
kurs_id = video.get("kurs_id")
|
||||
|
||||
if bundle_id:
|
||||
# Video from a bundle purchase
|
||||
if bundle_name not in bundles:
|
||||
bundles[bundle_name] = {"bundle_id": bundle_id, "courses": {}}
|
||||
if kurs_title not in bundles[bundle_name]["courses"]:
|
||||
bundles[bundle_name]["courses"][kurs_title] = {
|
||||
"kurs_id": kurs_id,
|
||||
"videos": [],
|
||||
}
|
||||
bundles[bundle_name]["courses"][kurs_title]["videos"].append(video)
|
||||
else:
|
||||
# Single course purchase
|
||||
if kurs_title not in courses:
|
||||
courses[kurs_title] = {"kurs_id": kurs_id, "videos": []}
|
||||
courses[kurs_title]["videos"].append(video)
|
||||
|
||||
return render_template(
|
||||
"videos/list.html",
|
||||
bundles=bundles,
|
||||
courses=courses,
|
||||
total_videos=len(videos),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<int:video_id>")
|
||||
@login_required
|
||||
def watch(video_id: int):
|
||||
"""Watch video page."""
|
||||
try:
|
||||
videos = WordPressAPI.get_videos(g.customer.email)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching videos: {e}")
|
||||
flash("Video konnte nicht geladen werden.", "error")
|
||||
return redirect(url_for("videos.list_videos"))
|
||||
|
||||
# Find the requested video
|
||||
video = None
|
||||
related_videos = []
|
||||
for v in videos:
|
||||
if v.get("id") == video_id:
|
||||
video = v
|
||||
else:
|
||||
related_videos.append(v)
|
||||
|
||||
if not video:
|
||||
flash("Video nicht gefunden oder kein Zugriff.", "error")
|
||||
return redirect(url_for("videos.list_videos"))
|
||||
|
||||
# Get stream URL from WordPress
|
||||
try:
|
||||
stream_data = WordPressAPI.get_video_stream(video_id, g.customer.email)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stream URL for video {video_id}: {e}")
|
||||
stream_data = None
|
||||
|
||||
if not stream_data or stream_data.get("error"):
|
||||
error_msg = (
|
||||
stream_data.get("error", "Stream nicht verfuegbar")
|
||||
if stream_data
|
||||
else "Stream nicht verfuegbar"
|
||||
)
|
||||
flash(f"Video-Stream nicht verfuegbar: {error_msg}", "error")
|
||||
return redirect(url_for("videos.list_videos"))
|
||||
|
||||
# Filter related videos to same course, limit to 5
|
||||
kurs_id = video.get("kurs_id")
|
||||
related_videos = [v for v in related_videos if v.get("kurs_id") == kurs_id][:5]
|
||||
|
||||
return render_template(
|
||||
"videos/watch.html",
|
||||
video=video,
|
||||
stream_url=stream_data.get("stream_url"),
|
||||
related_videos=related_videos,
|
||||
)
|
||||
498
customer_portal/web/static/css/sidebar.css
Executable file
498
customer_portal/web/static/css/sidebar.css
Executable file
@@ -0,0 +1,498 @@
|
||||
/* ===========================================
|
||||
Customer Portal - Sidebar & Dashboard Styles
|
||||
Based on Webwerkstatt Design (Sprint 10)
|
||||
=========================================== */
|
||||
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-bg: #1a1d21;
|
||||
--sidebar-border: #2d3238;
|
||||
--primary: #00868b;
|
||||
--primary-hover: #006d71;
|
||||
--primary-light: rgba(0, 134, 139, 0.15);
|
||||
--text-light: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--card-overlay: rgba(26, 29, 33, 0.75);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Body with Sidebar
|
||||
=========================================== */
|
||||
body.has-sidebar {
|
||||
background: #111318;
|
||||
color: var(--text-light);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Sidebar
|
||||
=========================================== */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
max-height: 80px;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-logo .logo-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sidebar-logo .logo-fallback i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 1rem 0.75rem;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link i {
|
||||
font-size: 1.1rem;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover {
|
||||
color: var(--text-light);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active {
|
||||
color: var(--text-light);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.sidebar-footer .user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-footer .user-info i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sidebar-footer .logout-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-footer .logout-link:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Main Content
|
||||
=========================================== */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.flash-container {
|
||||
padding: 1rem 2rem 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Top Bar (Mobile)
|
||||
=========================================== */
|
||||
.topbar {
|
||||
display: none;
|
||||
padding: 1rem;
|
||||
background: var(--sidebar-bg);
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Sidebar Toggle (Mobile) */
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 1001;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Dashboard Cards
|
||||
=========================================== */
|
||||
.dashboard-welcome {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-welcome h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-welcome p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
min-height: 180px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dashboard-card .card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, var(--card-overlay) 0%, rgba(26, 29, 33, 0.5) 100%);
|
||||
}
|
||||
|
||||
.dashboard-card .card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 1.5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dashboard-card .card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dashboard-card .card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card .card-description {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-card .card-links {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-card .card-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-card .card-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dashboard-card .card-btn {
|
||||
display: inline-block;
|
||||
margin-top: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.dashboard-card .card-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Card Backgrounds (Gradient Fallbacks) */
|
||||
.dashboard-card.card-invoices {
|
||||
background: linear-gradient(135deg, #1a4a4c 0%, #00868b 100%);
|
||||
}
|
||||
|
||||
.dashboard-card.card-bookings {
|
||||
background: linear-gradient(135deg, #2d4a3e 0%, #3d6b5a 100%);
|
||||
}
|
||||
|
||||
.dashboard-card.card-videos {
|
||||
background: linear-gradient(135deg, #3d3526 0%, #5c4d32 100%);
|
||||
}
|
||||
|
||||
.dashboard-card.card-settings {
|
||||
background: linear-gradient(135deg, #2a3441 0%, #4a5568 100%);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Page Headers
|
||||
=========================================== */
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Cards (General)
|
||||
=========================================== */
|
||||
.card {
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Tables
|
||||
=========================================== */
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: var(--text-light);
|
||||
--bs-table-border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Buttons
|
||||
=========================================== */
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Forms
|
||||
=========================================== */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background: #0d0f12;
|
||||
border-color: var(--sidebar-border);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background: #0d0f12;
|
||||
border-color: var(--primary);
|
||||
color: var(--text-light);
|
||||
box-shadow: 0 0 0 0.25rem var(--primary-light);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Form Switches */
|
||||
.form-check-input {
|
||||
background-color: var(--sidebar-border);
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive
|
||||
=========================================== */
|
||||
@media (max-width: 991px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 4rem;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.dashboard-card {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.dashboard-card .card-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
190
customer_portal/web/static/css/style.css
Executable file
190
customer_portal/web/static/css/style.css
Executable file
@@ -0,0 +1,190 @@
|
||||
/* Kundenportal - Custom Styles */
|
||||
|
||||
:root {
|
||||
--primary-dark: #1a1d21;
|
||||
--accent-green: #198754;
|
||||
--text-light: #f8f9fa;
|
||||
--text-muted: #adb5bd;
|
||||
--border-color: #343a40;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #111318;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Typography - ensure white text */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Card Elements */
|
||||
.card-header {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumb-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-content {
|
||||
background: var(--primary-dark);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.modal-header, .modal-footer {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control:focus {
|
||||
border-color: var(--accent-green);
|
||||
box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* OTP Input */
|
||||
.otp-input {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-success {
|
||||
background-color: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #157347;
|
||||
border-color: #146c43;
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
color: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.btn-outline-success:hover {
|
||||
background-color: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-dark {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: var(--text-light);
|
||||
}
|
||||
|
||||
.table-dark td,
|
||||
.table-dark th {
|
||||
color: var(--text-light) !important;
|
||||
}
|
||||
|
||||
.table-dark .text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.card, .btn {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
BIN
customer_portal/web/static/img/logo.png
Executable file
BIN
customer_portal/web/static/img/logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
91
customer_portal/web/templates/admin/admins.html
Executable file
91
customer_portal/web/templates/admin/admins.html
Executable file
@@ -0,0 +1,91 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Admin-Benutzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-shield-lock me-2"></i>Admin-Benutzer</h1>
|
||||
<p class="text-muted">{{ admin_users|length }} Administratoren</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.create_admin') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-lg me-1"></i>
|
||||
Neuer Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Letzter Login</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for admin in admin_users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ admin.username }}</strong>
|
||||
{% if admin.id == g.admin_user.id %}
|
||||
<span class="badge bg-info ms-1">Sie</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ admin.name }}</td>
|
||||
<td>
|
||||
{% if admin.email %}
|
||||
<a href="mailto:{{ admin.email }}" class="text-info text-decoration-none">
|
||||
{{ admin.email }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ admin.created_at.strftime('%d.%m.%Y') if admin.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if admin.last_login_at %}
|
||||
{{ admin.last_login_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">Noch nie</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if admin.is_active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if admin.id != g.admin_user.id %}
|
||||
<form action="{{ url_for('admin.toggle_admin_status', admin_id=admin.id) }}"
|
||||
method="POST" class="d-inline">
|
||||
{% if admin.is_active %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning"
|
||||
title="Deaktivieren"
|
||||
onclick="return confirm('Admin {{ admin.username }} deaktivieren?')">
|
||||
<i class="bi bi-pause-circle"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-success"
|
||||
title="Aktivieren">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
456
customer_portal/web/templates/admin/base.html
Executable file
456
customer_portal/web/templates/admin/base.html
Executable file
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin{% endblock %} - {{ branding.company_name }} Admin</title>
|
||||
{% if branding.favicon_url %}
|
||||
<link rel="icon" href="{{ branding.favicon_url }}" type="image/x-icon">
|
||||
{% endif %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--admin-primary: #dc3545;
|
||||
--admin-primary-hover: #bb2d3b;
|
||||
--sidebar-bg: {{ branding.colors.sidebar_bg }};
|
||||
--sidebar-border: {{ branding.colors.border }};
|
||||
--text-light: {{ branding.colors.text }};
|
||||
--text-muted: {{ branding.colors.muted }};
|
||||
--portal-bg: {{ branding.colors.background }};
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--portal-bg);
|
||||
color: var(--text-light);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 240px;
|
||||
height: 100vh;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.admin-sidebar-header {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.admin-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-light);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-logo i {
|
||||
color: var(--admin-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-nav-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.admin-nav-wrapper::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.admin-nav-wrapper::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-nav-wrapper::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.admin-nav-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
list-style: none;
|
||||
padding: 1rem 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-nav .nav-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link:hover {
|
||||
color: var(--text-light);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-nav .nav-link.active {
|
||||
color: var(--text-light);
|
||||
background: var(--admin-primary);
|
||||
}
|
||||
|
||||
.admin-sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
margin-left: 240px;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--sidebar-bg);
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: var(--text-light);
|
||||
--bs-table-border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
background: #0d0f12;
|
||||
border-color: var(--sidebar-border);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
background: #0d0f12;
|
||||
border-color: var(--admin-primary);
|
||||
color: var(--text-light);
|
||||
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
color: var(--text-light);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.modal-header, .modal-footer {
|
||||
border-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Text colors - ensure visibility on dark background */
|
||||
.text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
p, li, td, th, span, strong, label {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.card .small, .card small {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.card ul {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: rgba(13, 202, 240, 0.15);
|
||||
border-color: rgba(13, 202, 240, 0.3);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
border-color: rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(25, 135, 84, 0.15);
|
||||
border-color: rgba(25, 135, 84, 0.3);
|
||||
}
|
||||
|
||||
code {
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.table-dark {
|
||||
--bs-table-color: var(--text-light);
|
||||
--bs-table-bg: transparent;
|
||||
}
|
||||
|
||||
.table-dark th {
|
||||
color: var(--text-light) !important;
|
||||
}
|
||||
|
||||
.table-dark td {
|
||||
color: var(--text-light) !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--admin-primary);
|
||||
border-color: var(--admin-primary);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--admin-primary-hover);
|
||||
border-color: var(--admin-primary-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.admin-sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.admin-sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.admin-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="admin-sidebar">
|
||||
<div class="admin-sidebar-header">
|
||||
<a href="{{ url_for('admin.index') }}" class="admin-logo">
|
||||
{% if branding.logo_url %}
|
||||
<img src="{{ branding.logo_url }}" alt="{{ branding.company_name }}" style="max-height: 30px;">
|
||||
{% else %}
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
{% endif %}
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-nav-wrapper">
|
||||
<ul class="admin-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.index' %}active{% endif %}"
|
||||
href="{{ url_for('admin.index') }}">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Benutzer -->
|
||||
<li class="nav-item mt-3">
|
||||
<span class="nav-label text-muted small px-3">BENUTZER</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.customers' %}active{% endif %}"
|
||||
href="{{ url_for('admin.customers') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Kunden</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'admins' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('admin.admins') }}">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
<span>Administratoren</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Buchungen -->
|
||||
<li class="nav-item mt-3">
|
||||
<span class="nav-label text-muted small px-3">BUCHUNGEN</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.bookings' %}active{% endif %}"
|
||||
href="{{ url_for('admin.bookings') }}">
|
||||
<i class="bi bi-calendar-check"></i>
|
||||
<span>Buchungen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.bookings_sync' %}active{% endif %}"
|
||||
href="{{ url_for('admin.bookings_sync') }}">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span>Sync</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.bookings_import' %}active{% endif %}"
|
||||
href="{{ url_for('admin.bookings_import') }}">
|
||||
<i class="bi bi-upload"></i>
|
||||
<span>Import</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Einstellungen -->
|
||||
<li class="nav-item mt-3">
|
||||
<span class="nav-label text-muted small px-3">EINSTELLUNGEN</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.field_config' %}active{% endif %}"
|
||||
href="{{ url_for('admin.field_config') }}">
|
||||
<i class="bi bi-sliders"></i>
|
||||
<span>Profil-Felder</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_customer_view' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_customer_view') }}">
|
||||
<i class="bi bi-layout-text-sidebar"></i>
|
||||
<span>Kundenansicht</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_mail' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_mail') }}">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<span>Mail-Server</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_otp' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_otp') }}">
|
||||
<i class="bi bi-key"></i>
|
||||
<span>OTP & Sicherheit</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_wordpress' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_wordpress') }}">
|
||||
<i class="bi bi-wordpress"></i>
|
||||
<span>WordPress</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_csv' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_csv') }}">
|
||||
<i class="bi bi-filetype-csv"></i>
|
||||
<span>CSV Export/Import</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_customer_defaults' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_customer_defaults') }}">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
<span>Kunden-Standards</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_field_mapping' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_field_mapping') }}">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
<span>Feld-Mapping</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.settings_branding' %}active{% endif %}"
|
||||
href="{{ url_for('admin.settings_branding') }}">
|
||||
<i class="bi bi-palette"></i>
|
||||
<span>Branding</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
<div class="d-flex align-items-center gap-2 mb-2 text-muted small">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<span>{{ g.admin_user.name }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.logout') }}" class="btn btn-outline-danger btn-sm w-100">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>
|
||||
Abmelden
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="admin-main">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
200
customer_portal/web/templates/admin/booking_detail.html
Executable file
200
customer_portal/web/templates/admin/booking_detail.html
Executable file
@@ -0,0 +1,200 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Buchung: {{ booking.booking_number }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.bookings') }}" class="text-info">Buchungen</a></li>
|
||||
<li class="breadcrumb-item active">{{ booking.booking_number or 'Details' }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-calendar-check me-2"></i>{{ booking.booking_number or 'Buchung #' ~ booking.id }}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge bg-{{ booking.status_color }} fs-6 me-2">{{ booking.status_display }}</span>
|
||||
<a href="{{ url_for('admin.bookings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurueck
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Buchungsdetails -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>Buchungsdetails
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Buchungsnummer</td>
|
||||
<td><strong>{{ booking.booking_number or '-' }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">WordPress ID</td>
|
||||
<td>#{{ booking.wp_booking_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Status</td>
|
||||
<td><span class="badge bg-{{ booking.status_color }}">{{ booking.status_display }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Gesamtpreis</td>
|
||||
<td><strong>{{ booking.formatted_price }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Ticket-Typ</td>
|
||||
<td>{{ booking.ticket_type or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Anzahl</td>
|
||||
<td>{{ booking.ticket_count or 1 }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kunde -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person me-2"></i>Kunde
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Name</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.customer_detail', customer_id=booking.customer_id) }}" class="text-info">
|
||||
{{ booking.customer.display_name }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">E-Mail</td>
|
||||
<td>
|
||||
<a href="mailto:{{ booking.customer.email }}" class="text-info">{{ booking.customer.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Telefon</td>
|
||||
<td>{{ booking.customer_phone or booking.customer.display_phone or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Name bei Buchung</td>
|
||||
<td>{{ booking.customer_name or '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kurs-Informationen -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-mortarboard me-2"></i>Kurs
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Titel</td>
|
||||
<td><strong>{{ booking.kurs_title or '-' }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">WordPress Kurs-ID</td>
|
||||
<td>#{{ booking.wp_kurs_id or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Datum</td>
|
||||
<td>{{ booking.formatted_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Uhrzeit</td>
|
||||
<td>{{ booking.formatted_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Ort</td>
|
||||
<td>{{ booking.kurs_location or '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- sevDesk Integration -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-receipt me-2"></i>sevDesk Rechnung
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if booking.sevdesk_invoice_id %}
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Rechnungsnummer</td>
|
||||
<td><strong>{{ booking.sevdesk_invoice_number or '-' }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">sevDesk ID</td>
|
||||
<td>#{{ booking.sevdesk_invoice_id }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine Rechnung verknuepft.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock me-2"></i>Zeitstempel
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 160px;">Erstellt (WordPress)</td>
|
||||
<td>{{ booking.wp_created_at.strftime('%d.%m.%Y um %H:%M') if booking.wp_created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Importiert</td>
|
||||
<td>{{ booking.created_at.strftime('%d.%m.%Y um %H:%M') if booking.created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Sync</td>
|
||||
<td>{{ booking.synced_at.strftime('%d.%m.%Y um %H:%M') if booking.synced_at else '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
{% if booking.custom_fields %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list-ul me-2"></i>Zusatzfelder
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless table-sm mb-0">
|
||||
{% for key, value in booking.custom_fields.items() %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 280px; white-space: nowrap;">{{ key }}</td>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
464
customer_portal/web/templates/admin/bookings.html
Executable file
464
customer_portal/web/templates/admin/bookings.html
Executable file
@@ -0,0 +1,464 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Buchungen{% endblock %}
|
||||
|
||||
{% macro sort_header(column, label) %}
|
||||
{% set current_dir = sort_dir if sort_by == column else 'desc' %}
|
||||
{% set next_dir = 'asc' if current_dir == 'desc' else 'desc' %}
|
||||
<a href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=column, dir=next_dir, page=1, per_page=per_page) }}"
|
||||
class="text-decoration-none text-light d-flex align-items-center">
|
||||
{{ label }}
|
||||
{% if sort_by == column %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_dir == 'asc' else 'down' }}-fill ms-1 text-info"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down ms-1 text-muted opacity-50"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-calendar-check me-2"></i>Buchungen</h1>
|
||||
<p class="text-muted mb-0">
|
||||
{% if total_filtered != total_bookings %}
|
||||
{{ total_filtered }} von {{ total_bookings }} Buchungen
|
||||
{% else %}
|
||||
{{ total_bookings }} Buchungen insgesamt
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.bookings_import') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-upload me-1"></i>Import
|
||||
</a>
|
||||
<a href="{{ url_for('admin.bookings_export', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter) }}" class="btn btn-success">
|
||||
<i class="bi bi-download me-1"></i>CSV Export
|
||||
</a>
|
||||
<a href="{{ url_for('admin.bookings_sync') }}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync von WordPress
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards Row 1 -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-calendar-check text-info fs-4 me-2"></i>
|
||||
<h2 class="text-info mb-0">{{ total_bookings }}</h2>
|
||||
</div>
|
||||
<small class="text-muted">Buchungen gesamt</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-check-circle text-success fs-4 me-2"></i>
|
||||
<h2 class="text-success mb-0">{{ confirmed_count }}</h2>
|
||||
</div>
|
||||
<small class="text-muted">Bestaetigt</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-clock text-warning fs-4 me-2"></i>
|
||||
<h2 class="text-warning mb-0">{{ pending_count }}</h2>
|
||||
</div>
|
||||
<small class="text-muted">Ausstehend</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-x-circle text-danger fs-4 me-2"></i>
|
||||
<h2 class="text-danger mb-0">{{ cancelled_count }}</h2>
|
||||
</div>
|
||||
<small class="text-muted">Storniert</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards Row 2 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-currency-euro text-success fs-4 me-2"></i>
|
||||
<h2 class="text-success mb-0">{{ "{:,.2f}".format(total_revenue).replace(",", "X").replace(".", ",").replace("X", ".") }} EUR</h2>
|
||||
</div>
|
||||
<small class="text-muted">Gesamtumsatz (bestaetigt)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-calendar-plus text-primary fs-4 me-2"></i>
|
||||
<h2 class="text-primary mb-0">{{ this_month_bookings }}</h2>
|
||||
</div>
|
||||
<small class="text-muted">Neue Buchungen diesen Monat</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="d-flex align-items-center justify-content-center mb-2">
|
||||
<i class="bi bi-percent text-info fs-4 me-2"></i>
|
||||
<h2 class="text-info mb-0">{{ "%.1f"|format((confirmed_count / total_bookings * 100) if total_bookings > 0 else 0) }}%</h2>
|
||||
</div>
|
||||
<small class="text-muted">Bestaetigungsrate</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header bg-dark border-secondary">
|
||||
<h6 class="mb-0"><i class="bi bi-graph-up me-2"></i>Buchungen pro Monat</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="bookingsChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header bg-dark border-secondary">
|
||||
<h6 class="mb-0"><i class="bi bi-trophy me-2"></i>Top 5 Kurse</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush bg-transparent">
|
||||
{% for course in top_courses %}
|
||||
<li class="list-group-item bg-dark border-secondary d-flex justify-content-between align-items-center">
|
||||
<span class="text-light text-truncate" style="max-width: 200px;" title="{{ course.title }}">{{ course.title }}</span>
|
||||
<span class="badge bg-primary rounded-pill">{{ course.count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if not top_courses %}
|
||||
<li class="list-group-item bg-dark border-secondary text-muted text-center">Keine Kurse</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter und Suche -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<!-- Suche -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Suche</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="q" class="form-control bg-dark border-secondary text-light"
|
||||
placeholder="Buchungsnr., Name, E-Mail..."
|
||||
value="{{ search_query }}">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Jahr Filter -->
|
||||
<div class="col-md-1">
|
||||
<label class="form-label">Jahr</label>
|
||||
<select name="year" class="form-select bg-dark border-secondary text-light">
|
||||
<option value="">Alle</option>
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}" {% if year_filter == year|string %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Monat Filter -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Monat</label>
|
||||
<select name="month" class="form-select bg-dark border-secondary text-light">
|
||||
<option value="">Alle</option>
|
||||
<option value="1" {% if month_filter == '1' %}selected{% endif %}>Januar</option>
|
||||
<option value="2" {% if month_filter == '2' %}selected{% endif %}>Februar</option>
|
||||
<option value="3" {% if month_filter == '3' %}selected{% endif %}>Maerz</option>
|
||||
<option value="4" {% if month_filter == '4' %}selected{% endif %}>April</option>
|
||||
<option value="5" {% if month_filter == '5' %}selected{% endif %}>Mai</option>
|
||||
<option value="6" {% if month_filter == '6' %}selected{% endif %}>Juni</option>
|
||||
<option value="7" {% if month_filter == '7' %}selected{% endif %}>Juli</option>
|
||||
<option value="8" {% if month_filter == '8' %}selected{% endif %}>August</option>
|
||||
<option value="9" {% if month_filter == '9' %}selected{% endif %}>September</option>
|
||||
<option value="10" {% if month_filter == '10' %}selected{% endif %}>Oktober</option>
|
||||
<option value="11" {% if month_filter == '11' %}selected{% endif %}>November</option>
|
||||
<option value="12" {% if month_filter == '12' %}selected{% endif %}>Dezember</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Status Filter -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select bg-dark border-secondary text-light">
|
||||
<option value="">Alle</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Bestaetigt</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ausstehend</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Storniert</option>
|
||||
<option value="cancel_requested" {% if status_filter == 'cancel_requested' %}selected{% endif %}>Storno angefr.</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Kunde Filter -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Kunde</label>
|
||||
<select name="customer_id" class="form-select bg-dark border-secondary text-light">
|
||||
<option value="">Alle Kunden</option>
|
||||
{% for customer in customers_with_bookings %}
|
||||
<option value="{{ customer.id }}" {% if customer_filter == customer.id|string %}selected{% endif %}>{{ customer.display_name|truncate(25) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Kurs Filter -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Kurs</label>
|
||||
<select name="kurs" class="form-select bg-dark border-secondary text-light">
|
||||
<option value="">Alle Kurse</option>
|
||||
{% for kurs in kurs_titles %}
|
||||
<option value="{{ kurs }}" {% if kurs_filter == kurs %}selected{% endif %}>{{ kurs|truncate(30) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<!-- Hidden sort params -->
|
||||
<input type="hidden" name="sort" value="{{ sort_by }}">
|
||||
<input type="hidden" name="dir" value="{{ sort_dir }}">
|
||||
<button type="submit" class="btn btn-primary me-2">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
{% if status_filter or customer_filter or kurs_filter or search_query or year_filter or month_filter %}
|
||||
<a href="{{ url_for('admin.bookings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buchungstabelle -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if bookings %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width: 120px;">{{ sort_header('booking_nr', 'Buchungsnr.') }}</th>
|
||||
<th style="min-width: 180px;">{{ sort_header('customer', 'Kunde') }}</th>
|
||||
<th style="min-width: 200px;">{{ sort_header('kurs', 'Kurs') }}</th>
|
||||
<th style="min-width: 100px;">{{ sort_header('date', 'Datum') }}</th>
|
||||
<th style="min-width: 80px;">{{ sort_header('price', 'Preis') }}</th>
|
||||
<th style="min-width: 100px;">{{ sort_header('status', 'Status') }}</th>
|
||||
<th class="text-end" style="min-width: 80px;">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for booking in bookings %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ booking.booking_number or '-' }}</strong>
|
||||
<br>
|
||||
<small class="text-muted">WP #{{ booking.wp_booking_id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.customer_detail', customer_id=booking.customer_id) }}" class="text-info">
|
||||
{{ booking.customer.display_name }}
|
||||
</a>
|
||||
<br>
|
||||
<small class="text-muted">{{ booking.customer.email }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.kurs_title or '-' }}
|
||||
{% if booking.kurs_location %}
|
||||
<br><small class="text-muted"><i class="bi bi-geo-alt"></i> {{ booking.kurs_location }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.formatted_date }}
|
||||
{% if booking.kurs_time %}
|
||||
<br><small class="text-muted">{{ booking.formatted_time }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ booking.formatted_price }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ booking.status_color }}">
|
||||
{{ booking.status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('admin.booking_detail', booking_id=booking.id) }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="card-footer bg-dark border-secondary">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted">
|
||||
Seite {{ page }} von {{ total_pages }}
|
||||
({{ total_filtered }} Eintraege)
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<!-- First -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link bg-dark border-secondary"
|
||||
href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=1, per_page=per_page) }}">
|
||||
<i class="bi bi-chevron-double-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Previous -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link bg-dark border-secondary"
|
||||
href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=page-1, per_page=per_page) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Page numbers -->
|
||||
{% set start_page = [page - 2, 1]|max %}
|
||||
{% set end_page = [page + 2, total_pages]|min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<li class="page-item disabled"><span class="page-link bg-dark border-secondary">...</span></li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(start_page, end_page + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link {% if p != page %}bg-dark border-secondary{% endif %}"
|
||||
href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=p, per_page=per_page) }}">
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if end_page < total_pages %}
|
||||
<li class="page-item disabled"><span class="page-link bg-dark border-secondary">...</span></li>
|
||||
{% endif %}
|
||||
|
||||
<!-- Next -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link bg-dark border-secondary"
|
||||
href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=page+1, per_page=per_page) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Last -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link bg-dark border-secondary"
|
||||
href="{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=total_pages, per_page=per_page) }}">
|
||||
<i class="bi bi-chevron-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- Per Page -->
|
||||
<div>
|
||||
<select class="form-select form-select-sm bg-dark border-secondary text-light" style="width: auto;"
|
||||
onchange="window.location.href='{{ url_for('admin.bookings', status=status_filter, customer_id=customer_filter, kurs=kurs_filter, q=search_query, year=year_filter, month=month_filter, sort=sort_by, dir=sort_dir, page=1) }}&per_page=' + this.value">
|
||||
<option value="25" {% if per_page == 25 %}selected{% endif %}>25 pro Seite</option>
|
||||
<option value="50" {% if per_page == 50 %}selected{% endif %}>50 pro Seite</option>
|
||||
<option value="100" {% if per_page == 100 %}selected{% endif %}>100 pro Seite</option>
|
||||
<option value="200" {% if per_page == 200 %}selected{% endif %}>200 pro Seite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-3 mb-0">Keine Buchungen gefunden.</p>
|
||||
{% if search_query or status_filter or customer_filter or kurs_filter or year_filter or month_filter %}
|
||||
<a href="{{ url_for('admin.bookings') }}" class="btn btn-outline-secondary mt-3">
|
||||
<i class="bi bi-x-lg me-1"></i>Filter zuruecksetzen
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.bookings_sync') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Buchungen synchronisieren
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('bookingsChart');
|
||||
if (ctx) {
|
||||
const bookingsData = {{ bookings_per_month | tojson | safe }};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: bookingsData.map(d => d.month),
|
||||
datasets: [{
|
||||
label: 'Buchungen',
|
||||
data: bookingsData.map(d => d.count),
|
||||
backgroundColor: 'rgba(13, 202, 240, 0.6)',
|
||||
borderColor: 'rgba(13, 202, 240, 1)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#6c757d',
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(108, 117, 125, 0.2)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#6c757d'
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock extra_js %}
|
||||
221
customer_portal/web/templates/admin/bookings_import.html
Executable file
221
customer_portal/web/templates/admin/bookings_import.html
Executable file
@@ -0,0 +1,221 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Buchungen importieren{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.bookings') }}" class="text-info">Buchungen</a></li>
|
||||
<li class="breadcrumb-item active">Import</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-upload me-2"></i>Buchungen importieren</h1>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.bookings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurueck
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Import Form -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Datei hochladen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label for="import_file" class="form-label">Import-Datei</label>
|
||||
<input type="file" class="form-control bg-dark text-light border-secondary"
|
||||
id="import_file" name="import_file"
|
||||
accept=".csv,.json,.xlsx"
|
||||
required>
|
||||
<div class="form-text text-muted">
|
||||
Unterstuetzte Formate: CSV, JSON (MEC-Format), Excel (.xlsx)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="delimiter" class="form-label">CSV-Trennzeichen</label>
|
||||
<select class="form-select bg-dark text-light border-secondary" id="delimiter" name="delimiter">
|
||||
<option value=";" selected>Semikolon (;) - Standard fuer deutsches Excel</option>
|
||||
<option value=",">Komma (,) - Internationales Format</option>
|
||||
<option value="\t">Tabulator</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="overwrite" name="overwrite">
|
||||
<label class="form-check-label" for="overwrite">
|
||||
<strong>Existierende Buchungen ueberschreiben</strong>
|
||||
</label>
|
||||
<div class="form-text text-muted">
|
||||
Wenn deaktiviert, werden Buchungen uebersprungen, die bereits existieren (empfohlen).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-flex align-items-center">
|
||||
<i class="bi bi-shield-check me-2 fs-5"></i>
|
||||
<div>
|
||||
<strong>Ueberschreibungsschutz:</strong> Standardmaessig werden nur neue Buchungen importiert.
|
||||
Existierende Buchungen (erkannt an WordPress-ID oder Buchungsnummer) werden uebersprungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-upload me-2"></i>Import starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info & Templates -->
|
||||
<div class="col-lg-5">
|
||||
<!-- Stats -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>Aktuelle Daten
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted">Buchungen im System:</span>
|
||||
<span class="badge bg-primary fs-6">{{ total_bookings }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>Import-Vorlagen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Laden Sie eine Vorlage herunter, um das korrekte Format zu sehen:
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.bookings_import_template', format='csv') }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-filetype-csv me-2"></i>CSV-Vorlage herunterladen
|
||||
</a>
|
||||
<a href="{{ url_for('admin.bookings_import_template', format='json') }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-filetype-json me-2"></i>JSON-Vorlage herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Mapping -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list-columns me-2"></i>Unterstuetzte Felder
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Spaltenname</th>
|
||||
<th>Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>email</code></td>
|
||||
<td>Kunden-E-Mail <span class="badge bg-danger">Pflicht</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>buchungsnummer</code></td>
|
||||
<td>Eindeutige Buchungsnummer</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>id</code> / <code>wp_id</code></td>
|
||||
<td>WordPress Buchungs-ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>kurs</code> / <code>kurs_title</code></td>
|
||||
<td>Kurs-Titel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>datum</code> / <code>date</code></td>
|
||||
<td>Kursdatum (YYYY-MM-DD oder DD.MM.YYYY)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>uhrzeit</code> / <code>time</code></td>
|
||||
<td>Kurszeit (HH:MM)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ort</code> / <code>location</code></td>
|
||||
<td>Kursort</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>status</code></td>
|
||||
<td>confirmed/pending/cancelled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>preis</code> / <code>price</code></td>
|
||||
<td>Gesamtpreis (z.B. 150,00 oder 150.00)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>name</code></td>
|
||||
<td>Kundenname bei Buchung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>telefon</code> / <code>phone</code></td>
|
||||
<td>Telefonnummer</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted mt-3 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Nicht erkannte Spalten werden automatisch als Zusatzfelder importiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MEC Format Info -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-wordpress me-2"></i>MEC WordPress Export-Format
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Beim Import aus dem MEC (Modern Events Calendar) System wird folgendes JSON-Format unterstuetzt:
|
||||
</p>
|
||||
<pre class="bg-dark p-3 rounded border border-secondary"><code>{
|
||||
"bookings": [
|
||||
{
|
||||
"id": 1234,
|
||||
"number": "KB-2024-0001",
|
||||
"customer": {
|
||||
"email": "kunde@example.com",
|
||||
"name": "Max Mustermann",
|
||||
"phone": "+43 123 456789"
|
||||
},
|
||||
"kurs_title": "Reitkurs Anfaenger",
|
||||
"kurs_date": "2024-01-15",
|
||||
"kurs_time": "10:00",
|
||||
"kurs_location": "Reiterhof Wien",
|
||||
"status": "confirmed",
|
||||
"price": 150.00,
|
||||
"custom_fields": {
|
||||
"Pferdename": "Blitz",
|
||||
"Reitniveau": "Anfaenger"
|
||||
}
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
134
customer_portal/web/templates/admin/bookings_sync.html
Executable file
134
customer_portal/web/templates/admin/bookings_sync.html
Executable file
@@ -0,0 +1,134 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Buchungen synchronisieren{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.bookings') }}" class="text-info">Buchungen</a></li>
|
||||
<li class="breadcrumb-item active">Synchronisieren</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-arrow-repeat me-2"></i>Buchungen synchronisieren</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.bookings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurueck
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status Info -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>Sync-Status
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted">Buchungen im Portal</td>
|
||||
<td><strong>{{ total_bookings }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Kunden gesamt</td>
|
||||
<td><strong>{{ customers|length }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Sync</td>
|
||||
<td>
|
||||
{% if last_sync_time %}
|
||||
{{ last_sync_time.strftime('%d.%m.%Y um %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">Noch nie synchronisiert</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-question-circle me-2"></i>Hinweis
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">Die Synchronisation ruft alle Buchungen aus WordPress ab und speichert sie im Portal.</p>
|
||||
<ul class="mb-0 small text-muted">
|
||||
<li>Neue Buchungen werden erstellt</li>
|
||||
<li>Bestehende Buchungen werden aktualisiert</li>
|
||||
<li>Buchungen werden per E-Mail zugeordnet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Forms -->
|
||||
<div class="row g-4">
|
||||
<!-- Einzelnen Kunden synchronisieren -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<i class="bi bi-person me-2"></i>Einzelnen Kunden synchronisieren
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Kunde auswaehlen</label>
|
||||
<select name="customer_id" class="form-select bg-dark border-secondary text-light" required>
|
||||
<option value="">-- Kunde waehlen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">
|
||||
{{ customer.display_name }} ({{ customer.email }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-info">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Kunden synchronisieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alle Kunden synchronisieren -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-primary bg-opacity-10">
|
||||
<i class="bi bi-people me-2"></i>Alle Kunden synchronisieren
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Synchronisiert die Buchungen aller {{ customers|length }} Kunden aus WordPress.
|
||||
</p>
|
||||
<div class="alert alert-warning mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Achtung:</strong> Dies kann bei vielen Kunden einige Zeit dauern.
|
||||
</div>
|
||||
<form method="POST">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Alle synchronisieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WordPress-Konfiguration pruefen -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>WordPress-Verbindung
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">Die Synchronisation nutzt die WordPress REST API unter:</p>
|
||||
<code class="d-block p-2 bg-dark rounded mb-3">GET /wp-json/kurs-booking/v1/bookings?email=...</code>
|
||||
<a href="{{ url_for('admin.settings_wordpress') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-gear me-1"></i>WordPress-Einstellungen pruefen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
88
customer_portal/web/templates/admin/create_admin.html
Executable file
88
customer_portal/web/templates/admin/create_admin.html
Executable file
@@ -0,0 +1,88 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Neuer Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.admins') }}" class="text-info">Administratoren</a></li>
|
||||
<li class="breadcrumb-item active">Neuer Admin</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-shield-plus me-2"></i>Neuer Admin</h1>
|
||||
<p class="text-muted">Erstellen Sie einen neuen Administrator-Account</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.create_admin') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Benutzername *</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
required autocomplete="off" pattern="[a-zA-Z0-9_]+"
|
||||
title="Nur Buchstaben, Zahlen und Unterstriche">
|
||||
<div class="form-text">Nur Buchstaben, Zahlen und Unterstriche erlaubt</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Anzeigename *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort *</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
required minlength="8">
|
||||
<div class="form-text">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password_confirm" class="form-label">Passwort bestaetigen *</label>
|
||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
||||
required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Admin erstellen
|
||||
</button>
|
||||
<a href="{{ url_for('admin.admins') }}" class="btn btn-outline-secondary">
|
||||
Abbrechen
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Hinweise
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li class="mb-2">
|
||||
<strong>Benutzername</strong> wird fuer die Anmeldung verwendet und kann spaeter nicht geaendert werden.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Passwort</strong> wird sicher verschluesselt gespeichert.
|
||||
</li>
|
||||
<li>
|
||||
Neue Admins sind sofort <span class="badge bg-success">Aktiv</span> und koennen sich einloggen.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
273
customer_portal/web/templates/admin/customer_detail.html
Executable file
273
customer_portal/web/templates/admin/customer_detail.html
Executable file
@@ -0,0 +1,273 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kunde: {{ customer.display_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.customers') }}" class="text-info">Kunden</a></li>
|
||||
<li class="breadcrumb-item active">{{ customer.display_name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-person me-2"></i>{{ customer.display_name }}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<form method="POST" action="{{ url_for('admin.customer_sync', customer_id=customer.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-info" title="Von WordPress synchronisieren">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Sync
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('admin.customer_edit', customer_id=customer.id) }}" class="btn btn-warning ms-2">
|
||||
<i class="bi bi-pencil me-1"></i>Bearbeiten
|
||||
</a>
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-secondary ms-2">
|
||||
<i class="bi bi-arrow-left me-1"></i>Zurueck
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prepare custom_fields data early for ordering -->
|
||||
{% set custom_fields = customer.get_custom_fields() %}
|
||||
{% set wp_labels = {} %}
|
||||
{% if wp_schema and wp_schema.custom_fields %}
|
||||
{% for field in wp_schema.custom_fields %}
|
||||
{% if field.name %}
|
||||
{% set saved = field_config.wp_fields.get(field.name, {}) %}
|
||||
{% set _ = wp_labels.update({field.name: saved.get('label', field.label)}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Sprint 13: Labels + Hidden aus customer_view_config #}
|
||||
{% set view_labels = customer_view_config.field_labels if customer_view_config else {} %}
|
||||
{% set hidden_fields = customer_view_config.hidden_fields if customer_view_config else [] %}
|
||||
|
||||
{# Skip fields already shown in Kontaktdaten section #}
|
||||
{% set skip_fields = [
|
||||
'mec_field_2', 'mec_field_3', 'mec_field_7', 'mec_field_8',
|
||||
'name', 'vorname', 'nachname', 'first_name', 'last_name',
|
||||
'email', 'e_mail', 'e-mail',
|
||||
'telefon', 'phone', 'mobil', 'mobile', 'tel',
|
||||
'adresse', 'strasse', 'straße', 'street', 'address_street',
|
||||
'plz', 'postleitzahl', 'zip', 'address_zip',
|
||||
'ort', 'stadt', 'city', 'address_city'
|
||||
] %}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- 1. Kontaktdaten -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-vcard me-2"></i>Kontaktdaten
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">E-Mail</td>
|
||||
<td>
|
||||
<a href="mailto:{{ customer.email }}" class="text-info">{{ customer.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Telefon</td>
|
||||
<td>
|
||||
{% if customer.display_phone %}
|
||||
<a href="tel:{{ customer.display_phone }}" class="text-info">{{ customer.display_phone }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Adresse</td>
|
||||
<td>
|
||||
{% set addr = customer.display_address %}
|
||||
{% if addr.street %}
|
||||
{{ addr.street }}<br>
|
||||
{{ addr.zip }} {{ addr.city }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Personendaten (wichtig - direkt nach Kontaktdaten) -->
|
||||
{% if custom_fields %}
|
||||
{% set has_data = namespace(value=false) %}
|
||||
{% for key, value in custom_fields.items() %}
|
||||
{% set saved_field = field_config.wp_fields.get(key, {}) %}
|
||||
{# Sprint 13: Check both field_config.visible AND hidden_fields list #}
|
||||
{% set is_hidden = key in hidden_fields %}
|
||||
{% if key not in skip_fields and saved_field.get('visible', true) and not is_hidden %}
|
||||
{% set has_data.value = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if has_data.value %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-lines-fill me-2"></i>Personendaten
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless table-sm mb-0">
|
||||
{% for key, value in custom_fields.items() %}
|
||||
{% set saved_field = field_config.wp_fields.get(key, {}) %}
|
||||
{% set is_visible = saved_field.get('visible', true) %}
|
||||
{% set is_hidden = key in hidden_fields %}
|
||||
{% if key not in skip_fields and is_visible and not is_hidden %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 180px;">
|
||||
{# Sprint 13: Priority: view_labels > saved_field.label > wp_labels > key #}
|
||||
{{ view_labels.get(key) or saved_field.get('label') or wp_labels.get(key) or key }}
|
||||
</td>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- 3. E-Mail-Einstellungen -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-envelope me-2"></i>E-Mail-Einstellungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if customer.email_notifications %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger me-2"></i>
|
||||
{% endif %}
|
||||
<span>Buchungsbestaetigungen</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if customer.email_reminders %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger me-2"></i>
|
||||
{% endif %}
|
||||
<span>Kurserinnerungen</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if customer.email_invoices %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger me-2"></i>
|
||||
{% endif %}
|
||||
<span>Rechnungen</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if customer.email_marketing %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger me-2"></i>
|
||||
{% endif %}
|
||||
<span>Newsletter & Marketing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Konto-Infos -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>Konto-Informationen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Kunden-ID</td>
|
||||
<td>#{{ customer.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">WP User-ID</td>
|
||||
<td>{{ customer.wp_user_id or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Registriert</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y um %H:%M') if customer.created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Login</td>
|
||||
<td>
|
||||
{% if customer.last_login_at %}
|
||||
{{ customer.last_login_at.strftime('%d.%m.%Y um %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">Noch nie eingeloggt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Aktualisiert</td>
|
||||
<td>{{ customer.updated_at.strftime('%d.%m.%Y um %H:%M') if customer.updated_at else '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gefahrenzone -->
|
||||
<div class="card border-danger mt-4">
|
||||
<div class="card-header bg-danger bg-opacity-25 text-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Gefahrenzone
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Kunden loeschen</strong>
|
||||
<p class="text-muted mb-0 small">
|
||||
Loescht das Kundenkonto und alle zugehoerigen Daten unwiderruflich.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
<i class="bi bi-trash me-1"></i>Kunde loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Kunde loeschen</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Moechten Sie diesen Kunden wirklich loeschen?</p>
|
||||
<p class="mb-0">
|
||||
<strong>{{ customer.display_name }}</strong><br>
|
||||
<span class="text-muted">{{ customer.email }}</span>
|
||||
</p>
|
||||
<div class="alert alert-danger mt-3 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Diese Aktion kann nicht rueckgaengig gemacht werden!
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<form method="POST" action="{{ url_for('admin.customer_delete', customer_id=customer.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash me-1"></i>Endgueltig loeschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
215
customer_portal/web/templates/admin/customer_edit.html
Executable file
215
customer_portal/web/templates/admin/customer_edit.html
Executable file
@@ -0,0 +1,215 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kunde bearbeiten: {{ customer.display_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Sprint 12: All customer data comes from custom_fields #}
|
||||
{% set fields = customer.get_custom_fields() %}
|
||||
{% set addr = customer.display_address %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.customers') }}" class="text-info">Kunden</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.customer_detail', customer_id=customer.id) }}" class="text-info">{{ customer.display_name }}</a></li>
|
||||
<li class="breadcrumb-item active">Bearbeiten</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-pencil me-2"></i>Kunde bearbeiten</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.customer_edit', customer_id=customer.id) }}">
|
||||
<div class="row g-4">
|
||||
<!-- Kontaktdaten -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-vcard me-2"></i>Kontaktdaten
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control bg-dark border-secondary text-light"
|
||||
id="email" value="{{ customer.email }}" disabled>
|
||||
<div class="form-text">E-Mail kann nicht geaendert werden (Identifikator)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control bg-dark border-secondary text-light"
|
||||
id="name" name="name" value="{{ fields.get('name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Telefon</label>
|
||||
<input type="tel" class="form-control bg-dark border-secondary text-light"
|
||||
id="phone" name="phone" value="{{ customer.display_phone }}"
|
||||
placeholder="+43 ...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adresse -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-geo-alt me-2"></i>Adresse
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="address_street" class="form-label">Strasse</label>
|
||||
<input type="text" class="form-control bg-dark border-secondary text-light"
|
||||
id="address_street" name="address_street"
|
||||
value="{{ addr.street }}">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="mb-3">
|
||||
<label for="address_zip" class="form-label">PLZ</label>
|
||||
<input type="text" class="form-control bg-dark border-secondary text-light"
|
||||
id="address_zip" name="address_zip"
|
||||
value="{{ addr.zip }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="mb-3">
|
||||
<label for="address_city" class="form-label">Ort</label>
|
||||
<input type="text" class="form-control bg-dark border-secondary text-light"
|
||||
id="address_city" name="address_city"
|
||||
value="{{ addr.city }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail-Einstellungen -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-envelope me-2"></i>E-Mail-Einstellungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="email_notifications"
|
||||
name="email_notifications"
|
||||
{{ 'checked' if customer.email_notifications else '' }}>
|
||||
<label class="form-check-label" for="email_notifications">
|
||||
Buchungsbestaetigungen
|
||||
</label>
|
||||
<div class="form-text">Erhaelt E-Mails bei neuen Buchungen</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="email_reminders"
|
||||
name="email_reminders"
|
||||
{{ 'checked' if customer.email_reminders else '' }}>
|
||||
<label class="form-check-label" for="email_reminders">
|
||||
Kurserinnerungen
|
||||
</label>
|
||||
<div class="form-text">Erhaelt Erinnerungen vor Kursbeginn</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="email_invoices"
|
||||
name="email_invoices"
|
||||
{{ 'checked' if customer.email_invoices else '' }}>
|
||||
<label class="form-check-label" for="email_invoices">
|
||||
Rechnungen
|
||||
</label>
|
||||
<div class="form-text">Erhaelt Rechnungen per E-Mail</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="email_marketing"
|
||||
name="email_marketing"
|
||||
{{ 'checked' if customer.email_marketing else '' }}>
|
||||
<label class="form-check-label" for="email_marketing">
|
||||
Newsletter & Marketing
|
||||
</label>
|
||||
<div class="form-text">Erhaelt Newsletter und Angebote</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konto-Infos (nur Anzeige) -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>Konto-Informationen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless table-sm mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">Kunden-ID</td>
|
||||
<td>#{{ customer.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">WP User-ID</td>
|
||||
<td>{{ customer.wp_user_id or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Registriert</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y um %H:%M') if customer.created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Login</td>
|
||||
<td>
|
||||
{% if customer.last_login_at %}
|
||||
{{ customer.last_login_at.strftime('%d.%m.%Y um %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">Noch nie eingeloggt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Felder (aus custom_fields, ohne Kernfelder) -->
|
||||
{% set skip_core = ['name', 'phone', 'address_street', 'address_zip', 'address_city', 'email'] %}
|
||||
{% set extra_fields = {} %}
|
||||
{% for key, value in fields.items() if key not in skip_core %}
|
||||
{% set _ = extra_fields.update({key: value}) %}
|
||||
{% endfor %}
|
||||
|
||||
{% if extra_fields %}
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-lines-fill me-2"></i>Weitere Felder
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for key, value in extra_fields.items() %}
|
||||
{% set saved_field = field_config.wp_fields.get(key, {}) %}
|
||||
{% set label = saved_field.get('label') or key %}
|
||||
<div class="mb-3">
|
||||
<label for="custom_{{ key }}" class="form-label">{{ label }}</label>
|
||||
<input type="text" class="form-control bg-dark border-secondary text-light"
|
||||
id="custom_{{ key }}" name="custom_{{ key }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Aktionen -->
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{{ url_for('admin.customer_detail', customer_id=customer.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i>Abbrechen
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Aenderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
157
customer_portal/web/templates/admin/customers.html
Executable file
157
customer_portal/web/templates/admin/customers.html
Executable file
@@ -0,0 +1,157 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kunden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-people me-2"></i>Kunden</h1>
|
||||
<p class="text-muted mb-0">{{ customers|length }} Kunden{% if search %} gefunden fuer "{{ search }}"{% endif %}</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('admin.customers_export') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-download me-1"></i>CSV Export
|
||||
</a>
|
||||
<a href="{{ url_for('admin.customers_import') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-upload me-1"></i>CSV Import
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suchfeld -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('admin.customers') }}" class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control bg-dark border-secondary text-light"
|
||||
placeholder="Name, E-Mail oder Telefon suchen..."
|
||||
value="{{ search or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary me-2">
|
||||
<i class="bi bi-search me-1"></i>Suchen
|
||||
</button>
|
||||
{% if search %}
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i>Filter loeschen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kundentabelle -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Registriert</th>
|
||||
<th>Letzter Login</th>
|
||||
<th class="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.customer_detail', customer_id=customer.id) }}"
|
||||
class="text-light text-decoration-none fw-medium">
|
||||
{{ customer.display_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="mailto:{{ customer.email }}" class="text-info text-decoration-none">
|
||||
{{ customer.email }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ customer.display_phone or '-' }}</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y') if customer.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if customer.last_login_at %}
|
||||
{{ customer.last_login_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">Noch nie</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('admin.customer_detail', customer_id=customer.id) }}"
|
||||
class="btn btn-outline-info" title="Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.customer_edit', customer_id=customer.id) }}"
|
||||
class="btn btn-outline-warning" title="Bearbeiten">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger" title="Loeschen"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal{{ customer.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal{{ customer.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Kunde loeschen</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start">
|
||||
<p>Moechten Sie diesen Kunden wirklich loeschen?</p>
|
||||
<p class="mb-0">
|
||||
<strong>{{ customer.display_name }}</strong><br>
|
||||
<span class="text-muted">{{ customer.email }}</span>
|
||||
</p>
|
||||
<div class="alert alert-danger mt-3 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Diese Aktion kann nicht rueckgaengig gemacht werden!
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<form method="POST" action="{{ url_for('admin.customer_delete', customer_id=customer.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash me-1"></i>Loeschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
{% if search %}
|
||||
Keine Kunden fuer "{{ search }}" gefunden
|
||||
{% else %}
|
||||
Keine Kunden registriert
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if customers|length > 20 %}
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Zeige {{ customers|length }} Kunden. Nutzen Sie die Suche, um die Liste zu filtern.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
112
customer_portal/web/templates/admin/customers_import.html
Executable file
112
customer_portal/web/templates/admin/customers_import.html
Executable file
@@ -0,0 +1,112 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kunden importieren{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.customers') }}" class="text-info">Kunden</a></li>
|
||||
<li class="breadcrumb-item active">Importieren</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1><i class="bi bi-upload me-2"></i>Kunden importieren</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.settings_csv') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-gear me-1"></i>Felder konfigurieren
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>CSV-Datei hochladen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="csv_file" class="form-label">CSV-Datei auswaehlen</label>
|
||||
<input type="file" class="form-control" id="csv_file" name="csv_file"
|
||||
accept=".csv" required>
|
||||
<div class="form-text">
|
||||
Trennzeichen: <strong>{{ csv_config.delimiter if csv_config.delimiter != '\t' else 'Tab' }}</strong>
|
||||
(konfigurierbar unter <a href="{{ url_for('admin.settings_csv') }}" class="text-info">CSV-Einstellungen</a>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-upload me-1"></i>Importieren
|
||||
</button>
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-secondary">
|
||||
Abbrechen
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-info-circle me-2"></i>Erwartete Spalten</span>
|
||||
<span class="badge bg-info">{{ csv_config.export_fields|selectattr('enabled')|list|length }} aktiv</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Die CSV-Datei sollte folgende Spaltennamen verwenden:</p>
|
||||
<table class="table table-dark table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Spaltenname</th>
|
||||
<th>Feld</th>
|
||||
<th class="text-center">Pflicht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for field in csv_config.export_fields %}
|
||||
{% if field.enabled %}
|
||||
<tr>
|
||||
<td><code class="text-warning">{{ field.label }}</code></td>
|
||||
<td class="text-muted small">{{ field.key }}</td>
|
||||
<td class="text-center">
|
||||
{% if field.key == 'email' or field.key == 'name' %}
|
||||
<span class="badge bg-danger">Ja</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Nein</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert alert-info mb-0 mt-3">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
<strong>Tipp:</strong>
|
||||
<a href="{{ url_for('admin.customers_export') }}" class="text-info">Exportieren</a>
|
||||
Sie zuerst die bestehenden Kunden als Vorlage.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Hinweise
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>Bestehende Kunden werden anhand der <strong>E-Mail</strong> erkannt und aktualisiert</li>
|
||||
<li>Neue E-Mail-Adressen werden als neue Kunden angelegt</li>
|
||||
<li>Die <strong>ID</strong>-Spalte wird beim Import ignoriert</li>
|
||||
<li>Ja/Nein-Felder akzeptieren: <code>Ja</code>, <code>1</code>, <code>true</code>, <code>yes</code></li>
|
||||
<li>Encoding: UTF-8 (mit oder ohne BOM)</li>
|
||||
<li>Leere Zellen ueberschreiben keine bestehenden Werte</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
239
customer_portal/web/templates/admin/field_config.html
Executable file
239
customer_portal/web/templates/admin/field_config.html
Executable file
@@ -0,0 +1,239 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Feld-Konfiguration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-sliders me-2"></i>Feld-Konfiguration</h1>
|
||||
<p class="text-muted">Legen Sie fest, welche Felder Kunden sehen und bearbeiten koennen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.field_config') }}">
|
||||
<!-- Sections -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-layout-text-window me-2"></i>
|
||||
Sektionen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Aktivieren oder deaktivieren Sie ganze Bereiche im Kundenprofil.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sektion</th>
|
||||
<th class="text-center" style="width: 100px;">Sichtbar</th>
|
||||
<th style="width: 200px;">Bezeichnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, section in config.sections.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-{{ 'person' if key == 'contact' else 'house' if key == 'address' else 'envelope' if key == 'email_settings' else 'shield-check' }} me-2 text-muted"></i>
|
||||
{{ section.label }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="section_{{ key }}_visible"
|
||||
id="section_{{ key }}_visible"
|
||||
{% if section.visible %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="section_{{ key }}_label"
|
||||
value="{{ section.label }}">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Fields (Standard) -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
Standard-Felder
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Basis-Felder fuer Kontakt und Adresse.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feld</th>
|
||||
<th class="text-center" style="width: 100px;">Sichtbar</th>
|
||||
<th class="text-center" style="width: 100px;">Editierbar</th>
|
||||
<th style="width: 200px;">Bezeichnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, field in config.profile_fields.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
<code class="text-info">{{ key }}</code>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="field_{{ key }}_visible"
|
||||
id="field_{{ key }}_visible"
|
||||
{% if field.visible %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="field_{{ key }}_editable"
|
||||
id="field_{{ key }}_editable"
|
||||
{% if field.editable %}checked{% endif %}
|
||||
{% if key in ['name', 'email'] %}disabled title="Dieses Feld kann nicht editierbar gemacht werden"{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="field_{{ key }}_label"
|
||||
value="{{ field.label }}">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WordPress Booking Fields -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-wordpress me-2"></i>
|
||||
WordPress Buchungsfelder
|
||||
{% if wp_schema and wp_schema.custom_fields %}
|
||||
<span class="badge bg-success ms-2">{{ wp_schema.custom_fields|length }} Felder</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<a href="{{ url_for('admin.field_config') }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if wp_error %}
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>WordPress nicht erreichbar:</strong> {{ wp_error }}
|
||||
<br><small class="text-muted">Bitte <a href="{{ url_for('admin.settings_wordpress') }}" class="text-info">WordPress-Einstellungen</a> pruefen.</small>
|
||||
</div>
|
||||
{% elif wp_schema and wp_schema.custom_fields %}
|
||||
<p class="text-muted small mb-3">
|
||||
Diese Felder sind in WordPress unter <strong>Kurs-Booking → Einstellungen → Buchungsfelder</strong> definiert.
|
||||
</p>
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feldname</th>
|
||||
<th style="width: 80px;">Typ</th>
|
||||
<th style="min-width: 180px;">Bezeichnung</th>
|
||||
<th style="width: 50px;" class="text-center">Aktiv</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for field in wp_schema.custom_fields %}
|
||||
{% set field_id = field.name or field.id or field.label|lower|replace(' ', '_') %}
|
||||
{% set saved = config.wp_fields.get(field_id, {}) %}
|
||||
<tr>
|
||||
<td class="align-middle" style="word-break: break-word; max-width: 250px;">
|
||||
<code class="text-warning" style="white-space: normal; word-break: break-all;">{{ field_id }}</code>
|
||||
{% if field.mandatory or field.required %}
|
||||
<span class="badge bg-danger ms-1">Pflicht</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-secondary">{{ field.type }}</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="wp_{{ field_id }}_label"
|
||||
value="{{ saved.get('label', field.label) }}"
|
||||
placeholder="{{ field.label }}">
|
||||
</td>
|
||||
<td class="text-center align-middle">
|
||||
<div class="form-check form-switch d-inline-block mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="wp_{{ field_id }}_visible"
|
||||
id="wp_{{ field_id }}_visible"
|
||||
{% if saved.get('visible', true) %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif wp_schema %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox fs-1 mb-2"></i>
|
||||
<p class="mb-0">Keine Buchungsfelder in WordPress definiert.</p>
|
||||
<small>Felder koennen in WordPress unter Kurs-Booking → Einstellungen → Buchungsfelder hinzugefuegt werden.</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
WordPress-Verbindung pruefen...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sprint 12: Legacy MEC Fields section removed - all fields now managed via
|
||||
WordPress schema or dynamic custom_fields #}
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear-wide-connected me-2"></i>
|
||||
Erweiterte Optionen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="custom_fields_visible"
|
||||
id="custom_fields_visible"
|
||||
{% if config.custom_fields_visible %}checked{% endif %}>
|
||||
<label class="form-check-label" for="custom_fields_visible">
|
||||
<strong>Custom Fields anzeigen</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Zeigt zusaetzliche Felder aus WordPress-Buchungen im Kundenprofil an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="sync_button_visible"
|
||||
id="sync_button_visible"
|
||||
{% if config.sync_button_visible %}checked{% endif %}>
|
||||
<label class="form-check-label" for="sync_button_visible">
|
||||
<strong>Sync-Button anzeigen</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Erlaubt Kunden, ihre Daten aus der letzten WordPress-Buchung zu synchronisieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
110
customer_portal/web/templates/admin/index.html
Executable file
110
customer_portal/web/templates/admin/index.html
Executable file
@@ -0,0 +1,110 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-speedometer2 me-2"></i>Admin Dashboard</h1>
|
||||
<p class="text-muted">Portal-Einstellungen und Benutzerverwaltung</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-25 rounded p-3 me-3">
|
||||
<i class="bi bi-people fs-3 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">{{ total_customers }}</h3>
|
||||
<small class="text-muted">Kunden gesamt</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-danger bg-opacity-25 rounded p-3 me-3">
|
||||
<i class="bi bi-shield-check fs-3 text-danger"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">{{ admin_count }}</h3>
|
||||
<small class="text-muted">Administratoren</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-25 rounded p-3 me-3">
|
||||
<i class="bi bi-sliders fs-3 text-info"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h5>Feld-Konfiguration</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Legen Sie fest, welche Felder Kunden im Profil sehen und bearbeiten koennen.
|
||||
</p>
|
||||
<a href="{{ url_for('admin.field_config') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-gear me-1"></i>
|
||||
Felder konfigurieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-warning bg-opacity-25 rounded p-3 me-3">
|
||||
<i class="bi bi-people-fill fs-3 text-warning"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h5>Kundenverwaltung</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Alle registrierten Kunden einsehen und verwalten.
|
||||
</p>
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-warning">
|
||||
<i class="bi bi-person-gear me-1"></i>
|
||||
Kunden anzeigen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-danger bg-opacity-25 rounded p-3 me-3">
|
||||
<i class="bi bi-shield-lock fs-3 text-danger"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h5>Administratoren</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Admin-Benutzer verwalten und neue Admins anlegen.
|
||||
</p>
|
||||
<a href="{{ url_for('admin.admins') }}" class="btn btn-outline-danger">
|
||||
<i class="bi bi-shield-plus me-1"></i>
|
||||
Admins verwalten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
118
customer_portal/web/templates/admin/login.html
Executable file
118
customer_portal/web/templates/admin/login.html
Executable file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login - {{ branding.company_name }}</title>
|
||||
{% if branding.favicon_url %}
|
||||
<link rel="icon" href="{{ branding.favicon_url }}" type="image/x-icon">
|
||||
{% endif %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, {{ branding.colors.background }} 0%, {{ branding.colors.border }} 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-card {
|
||||
background: {{ branding.colors.sidebar_bg }};
|
||||
border: 1px solid {{ branding.colors.border }};
|
||||
border-radius: 1rem;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
.form-control {
|
||||
background: #0d0f12;
|
||||
border-color: {{ branding.colors.border }};
|
||||
color: {{ branding.colors.text }};
|
||||
}
|
||||
.form-control:focus {
|
||||
background: #0d0f12;
|
||||
border-color: #dc3545;
|
||||
color: {{ branding.colors.text }};
|
||||
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: #bb2d3b;
|
||||
border-color: #b02a37;
|
||||
}
|
||||
h3 {
|
||||
color: {{ branding.colors.text }};
|
||||
}
|
||||
.text-muted {
|
||||
color: {{ branding.colors.muted }} !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-light">
|
||||
<div class="login-card p-4 shadow-lg">
|
||||
<div class="text-center mb-4">
|
||||
{% if branding.logo_url %}
|
||||
<img src="{{ branding.logo_url }}" alt="{{ branding.company_name }}" style="max-height: 50px; margin-bottom: 1rem;">
|
||||
{% else %}
|
||||
<div class="bg-danger bg-opacity-25 rounded-circle d-inline-flex p-3 mb-3">
|
||||
<i class="bi bi-shield-lock fs-1 text-danger"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3>{{ branding.company_name }} Admin</h3>
|
||||
<p class="text-muted mb-0">Bitte melden Sie sich an</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show py-2">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label text-light">Benutzername</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-light">
|
||||
<i class="bi bi-person"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
required autofocus autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="form-label text-light">Passwort</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-dark border-secondary text-light">
|
||||
<i class="bi bi-key"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
required autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger w-100 py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('main.index') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck zum Kundenportal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
509
customer_portal/web/templates/admin/settings_branding.html
Executable file
509
customer_portal/web/templates/admin/settings_branding.html
Executable file
@@ -0,0 +1,509 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Branding{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid var(--sidebar-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-input-group input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
width: 50px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-input-group input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-input-group input[type="color"]::-webkit-color-swatch {
|
||||
border: 2px solid var(--sidebar-border);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
max-width: 200px;
|
||||
max-height: 60px;
|
||||
background: var(--sidebar-bg);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.index') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Branding</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-palette me-2"></i>Portal-Branding</h1>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="row">
|
||||
<!-- Allgemein -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-building me-2"></i>Allgemein
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="company_name" class="form-label">Firmenname</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
value="{{ config.company_name }}"
|
||||
placeholder="Kundenportal">
|
||||
<div class="form-text">
|
||||
Wird im Header, Login-Seite und E-Mails angezeigt.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Logo</label>
|
||||
{% if config.logo_url %}
|
||||
<div class="mb-2 p-2 rounded" style="background: var(--sidebar-bg); border: 1px solid var(--sidebar-border);">
|
||||
<img src="{{ config.logo_url }}" alt="Logo-Vorschau" class="logo-preview" onerror="this.style.display='none'">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="input-group mb-2">
|
||||
<input type="url"
|
||||
class="form-control"
|
||||
id="logo_url"
|
||||
name="logo_url"
|
||||
value="{{ config.logo_url }}"
|
||||
placeholder="https://example.com/logo.png">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#logoUploadModal">
|
||||
<i class="bi bi-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Empfohlen: 200x60px, PNG/SVG/WebP (max 500KB). Leer = Text-Logo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Favicon</label>
|
||||
{% if config.favicon_url %}
|
||||
<div class="mb-2">
|
||||
<img src="{{ config.favicon_url }}" alt="Favicon-Vorschau" style="max-width: 32px; max-height: 32px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="input-group mb-2">
|
||||
<input type="url"
|
||||
class="form-control"
|
||||
id="favicon_url"
|
||||
name="favicon_url"
|
||||
value="{{ config.favicon_url }}"
|
||||
placeholder="https://example.com/favicon.ico">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#faviconUploadModal">
|
||||
<i class="bi bi-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Browser-Tab Icon. Empfohlen: 32x32px, ICO/PNG/SVG (max 100KB).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farben -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-brush me-2"></i>Farben
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Primary Color -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Primaerfarbe</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_primary"
|
||||
name="color_primary"
|
||||
value="{{ config.colors.primary }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_primary_text"
|
||||
value="{{ config.colors.primary }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Buttons, Links</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Hover -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Primaerfarbe (Hover)</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_primary_hover"
|
||||
name="color_primary_hover"
|
||||
value="{{ config.colors.primary_hover }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_primary_hover_text"
|
||||
value="{{ config.colors.primary_hover }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Hover-States</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Hintergrund</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_background"
|
||||
name="color_background"
|
||||
value="{{ config.colors.background }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_background_text"
|
||||
value="{{ config.colors.background }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Seiten-Hintergrund</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header BG -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Header-Hintergrund</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_header_bg"
|
||||
name="color_header_bg"
|
||||
value="{{ config.colors.header_bg }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_header_bg_text"
|
||||
value="{{ config.colors.header_bg }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Topbar/Navigation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar BG -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Sidebar-Hintergrund</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_sidebar_bg"
|
||||
name="color_sidebar_bg"
|
||||
value="{{ config.colors.sidebar_bg }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_sidebar_bg_text"
|
||||
value="{{ config.colors.sidebar_bg }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Admin-Sidebar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Color -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Textfarbe</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_text"
|
||||
name="color_text"
|
||||
value="{{ config.colors.text }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_text_text"
|
||||
value="{{ config.colors.text }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Haupttext</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Muted Color -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gedaempfte Farbe</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_muted"
|
||||
name="color_muted"
|
||||
value="{{ config.colors.muted }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_muted_text"
|
||||
value="{{ config.colors.muted }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Sekundaerer Text</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Border Color -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rahmenfarbe</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color"
|
||||
id="color_border"
|
||||
name="color_border"
|
||||
value="{{ config.colors.border }}">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="color_border_text"
|
||||
value="{{ config.colors.border }}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
style="max-width: 120px;">
|
||||
<span class="text-muted small">Rahmen/Trennlinien</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vorschau -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-eye me-2"></i>Vorschau
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-box" id="previewBox">
|
||||
<div class="preview-header" id="previewHeader">
|
||||
<span id="previewCompanyName">{{ config.company_name }}</span>
|
||||
</div>
|
||||
<p id="previewText" style="margin-bottom: 0.5rem;">Beispieltext in der Hauptfarbe</p>
|
||||
<p id="previewMuted" class="small" style="margin-bottom: 1rem;">Gedaempfter Beispieltext</p>
|
||||
<button type="button" class="preview-button" id="previewButton">Beispiel-Button</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktionen -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#resetModal">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>
|
||||
Auf Standard zuruecksetzen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Reset Modal -->
|
||||
<div class="modal fade" id="resetModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Branding zuruecksetzen?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Moechten Sie alle Branding-Einstellungen auf die Standardwerte zuruecksetzen?</p>
|
||||
<p class="text-muted small mb-0">Diese Aktion kann nicht rueckgaengig gemacht werden.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<form method="post" action="{{ url_for('admin.settings_branding_reset') }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">Zuruecksetzen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Upload Modal -->
|
||||
<div class="modal fade" id="logoUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-upload me-2"></i>Logo hochladen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.settings_branding_upload_logo') }}" enctype="multipart/form-data">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="logo_file" class="form-label">Logo-Datei auswaehlen</label>
|
||||
<input type="file"
|
||||
class="form-control"
|
||||
id="logo_file"
|
||||
name="logo_file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp"
|
||||
required>
|
||||
<div class="form-text">
|
||||
Erlaubte Formate: PNG, JPG, GIF, SVG, WebP<br>
|
||||
Maximale Groesse: 500KB<br>
|
||||
Empfohlene Abmessungen: 200x60 Pixel
|
||||
</div>
|
||||
</div>
|
||||
<div id="logoPreviewContainer" class="text-center d-none">
|
||||
<p class="text-muted small mb-2">Vorschau:</p>
|
||||
<img id="logoPreviewImg" src="" alt="Vorschau" style="max-width: 200px; max-height: 80px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-upload me-1"></i>Hochladen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favicon Upload Modal -->
|
||||
<div class="modal fade" id="faviconUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-upload me-2"></i>Favicon hochladen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.settings_branding_upload_favicon') }}" enctype="multipart/form-data">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="favicon_file" class="form-label">Favicon-Datei auswaehlen</label>
|
||||
<input type="file"
|
||||
class="form-control"
|
||||
id="favicon_file"
|
||||
name="favicon_file"
|
||||
accept=".ico,.png,.svg"
|
||||
required>
|
||||
<div class="form-text">
|
||||
Erlaubte Formate: ICO, PNG, SVG<br>
|
||||
Maximale Groesse: 100KB<br>
|
||||
Empfohlene Abmessungen: 32x32 Pixel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-upload me-1"></i>Hochladen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Sync color picker with text input
|
||||
document.querySelectorAll('input[type="color"]').forEach(colorInput => {
|
||||
const textInputId = colorInput.id + '_text';
|
||||
const textInput = document.getElementById(textInputId);
|
||||
|
||||
if (textInput) {
|
||||
// Color picker -> Text input
|
||||
colorInput.addEventListener('input', () => {
|
||||
textInput.value = colorInput.value;
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// Text input -> Color picker
|
||||
textInput.addEventListener('input', () => {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(textInput.value)) {
|
||||
colorInput.value = textInput.value;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Company name preview
|
||||
document.getElementById('company_name').addEventListener('input', function() {
|
||||
document.getElementById('previewCompanyName').textContent = this.value || 'Kundenportal';
|
||||
});
|
||||
|
||||
// Update preview with current colors
|
||||
function updatePreview() {
|
||||
const box = document.getElementById('previewBox');
|
||||
const header = document.getElementById('previewHeader');
|
||||
const text = document.getElementById('previewText');
|
||||
const muted = document.getElementById('previewMuted');
|
||||
const button = document.getElementById('previewButton');
|
||||
|
||||
box.style.background = document.getElementById('color_background').value;
|
||||
box.style.borderColor = document.getElementById('color_border').value;
|
||||
|
||||
header.style.background = document.getElementById('color_header_bg').value;
|
||||
header.style.color = document.getElementById('color_text').value;
|
||||
|
||||
text.style.color = document.getElementById('color_text').value;
|
||||
muted.style.color = document.getElementById('color_muted').value;
|
||||
|
||||
button.style.background = document.getElementById('color_primary').value;
|
||||
}
|
||||
|
||||
// Initial preview update
|
||||
updatePreview();
|
||||
|
||||
// Logo file preview
|
||||
document.getElementById('logo_file').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
const container = document.getElementById('logoPreviewContainer');
|
||||
const img = document.getElementById('logoPreviewImg');
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
img.src = e.target.result;
|
||||
container.classList.remove('d-none');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
container.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
141
customer_portal/web/templates/admin/settings_csv.html
Executable file
141
customer_portal/web/templates/admin/settings_csv.html
Executable file
@@ -0,0 +1,141 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}CSV Export/Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-filetype-csv me-2"></i>CSV Export/Import</h1>
|
||||
<p class="text-muted">Konfigurieren Sie, welche Felder exportiert und importiert werden</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('admin.customers_export') }}" class="btn btn-success">
|
||||
<i class="bi bi-download me-1"></i>Export testen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.settings_csv') }}">
|
||||
<!-- Export Fields -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list-check me-2"></i>
|
||||
Export-Felder
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Waehlen Sie aus, welche Felder in der CSV-Datei enthalten sein sollen.
|
||||
Spaltenreihenfolge entspricht der Reihenfolge in dieser Liste.
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" style="width: 80px;">Aktiv</th>
|
||||
<th>Feld</th>
|
||||
<th style="width: 250px;">Spaltenname (CSV)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for field in config.export_fields %}
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="enabled_{{ field.key }}"
|
||||
id="enabled_{{ field.key }}"
|
||||
{% if field.enabled %}checked{% endif %}
|
||||
{% if field.key == 'email' %}disabled title="E-Mail ist Pflichtfeld"{% endif %}>
|
||||
</div>
|
||||
{% if field.key == 'email' %}
|
||||
<input type="hidden" name="enabled_{{ field.key }}" value="on">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-info">{{ field.key }}</code>
|
||||
{% if field.key == 'email' %}
|
||||
<span class="badge bg-danger ms-2">Pflicht</span>
|
||||
{% endif %}
|
||||
{% if field.key in ['created_at', 'updated_at'] %}
|
||||
<span class="badge bg-secondary ms-2">System</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="label_{{ field.key }}"
|
||||
value="{{ field.label }}"
|
||||
placeholder="{{ field.label }}">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
Erweiterte Optionen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="delimiter" class="form-label">Trennzeichen</label>
|
||||
<select class="form-select" name="delimiter" id="delimiter">
|
||||
<option value=";" {% if config.delimiter == ';' %}selected{% endif %}>Semikolon (;) - Excel-kompatibel</option>
|
||||
<option value="," {% if config.delimiter == ',' %}selected{% endif %}>Komma (,) - Standard CSV</option>
|
||||
<option value="\t" {% if config.delimiter == '\t' %}selected{% endif %}>Tabulator - TSV</option>
|
||||
</select>
|
||||
<div class="form-text">Semikolon wird fuer deutsche Excel-Versionen empfohlen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="include_custom_fields"
|
||||
id="include_custom_fields"
|
||||
{% if config.include_custom_fields %}checked{% endif %}>
|
||||
<label class="form-check-label" for="include_custom_fields">
|
||||
<strong>Zusatzfelder exportieren</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Fuegt eine JSON-Spalte mit MEC/WordPress Zusatzfeldern hinzu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Info -->
|
||||
<div class="card mb-4 border-info">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Import-Hinweise
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>Der Import erkennt Spalten automatisch anhand der <strong>Spaltennamen</strong> (erste Zeile)</li>
|
||||
<li><strong>E-Mail</strong> ist das Schluesselfeld - bestehende Kunden werden aktualisiert</li>
|
||||
<li>Werte fuer Ja/Nein-Felder: <code>Ja</code>, <code>1</code>, <code>true</code> = aktiviert</li>
|
||||
<li>Leere Zellen werden beim Import uebersprungen (bestehende Werte bleiben)</li>
|
||||
<li>Die <strong>ID</strong>-Spalte wird beim Import ignoriert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-secondary">
|
||||
Zurueck zur Kundenliste
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
176
customer_portal/web/templates/admin/settings_customer_defaults.html
Executable file
176
customer_portal/web/templates/admin/settings_customer_defaults.html
Executable file
@@ -0,0 +1,176 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kunden-Standardwerte{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-person-plus me-2"></i>Kunden-Standardwerte</h1>
|
||||
<p class="text-muted">Standard E-Mail-Einstellungen fuer neue Kunden</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form method="POST" action="{{ url_for('admin.settings_customer_defaults') }}">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-envelope-check me-2"></i>
|
||||
Standard E-Mail-Einstellungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-4">
|
||||
Diese Einstellungen werden bei der Erstellung neuer Kunden als Standardwerte verwendet.
|
||||
Kunden koennen ihre Praeferenzen spaeter im Portal aendern.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_notifications" name="email_notifications" value="1"
|
||||
{% if config.email_notifications %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_notifications">
|
||||
<strong>Buchungsbestaetigungen</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ms-4">
|
||||
E-Mail bei erfolgreicher Buchung
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_reminders" name="email_reminders" value="1"
|
||||
{% if config.email_reminders %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_reminders">
|
||||
<strong>Kurserinnerungen</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ms-4">
|
||||
Automatische Erinnerung vor Kursbeginn
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_invoices" name="email_invoices" value="1"
|
||||
{% if config.email_invoices %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_invoices">
|
||||
<strong>Rechnungen</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ms-4">
|
||||
Rechnungen und Zahlungsinformationen per E-Mail
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_marketing" name="email_marketing" value="1"
|
||||
{% if config.email_marketing %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_marketing">
|
||||
<strong>Newsletter & Marketing</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ms-4">
|
||||
Informationen zu neuen Kursen und Angeboten
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Hinweise
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<p class="mb-2"><strong>Wann werden diese Werte angewendet?</strong></p>
|
||||
<p class="text-muted mb-3">
|
||||
Bei jeder Neuerstellung eines Kunden - egal ob durch Registrierung,
|
||||
Buchung, CSV-Import oder Webhook.
|
||||
</p>
|
||||
|
||||
<p class="mb-2"><strong>DSGVO-Hinweis:</strong></p>
|
||||
<p class="text-muted mb-3">
|
||||
Marketing-Einstellungen sollten standardmaessig deaktiviert sein (Opt-in).
|
||||
Nur bei expliziter Zustimmung aktivieren.
|
||||
</p>
|
||||
|
||||
<p class="mb-2"><strong>Bestehende Kunden:</strong></p>
|
||||
<p class="text-muted mb-0">
|
||||
Beim Speichern werden Kunden mit <strong>fehlenden</strong> Werten automatisch ergaenzt.
|
||||
Explizit gesetzte Einstellungen bleiben unveraendert.
|
||||
</p>
|
||||
{% if null_count > 0 %}
|
||||
<div class="alert alert-warning mt-3 mb-0 py-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>{{ null_count }}</strong> Kunden haben fehlende Werte
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Values -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
Aktuelle Standardwerte
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<tr>
|
||||
<td class="text-muted">Buchungsbestaetigungen</td>
|
||||
<td class="text-end">
|
||||
{% if config.email_notifications %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Kurserinnerungen</td>
|
||||
<td class="text-end">
|
||||
{% if config.email_reminders %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Rechnungen</td>
|
||||
<td class="text-end">
|
||||
{% if config.email_invoices %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Newsletter & Marketing</td>
|
||||
<td class="text-end">
|
||||
{% if config.email_marketing %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
130
customer_portal/web/templates/admin/settings_customer_view.html
Executable file
130
customer_portal/web/templates/admin/settings_customer_view.html
Executable file
@@ -0,0 +1,130 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Kundenansicht{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.index') }}">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Kundenansicht</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-layout-text-sidebar me-2"></i>Kundenansicht konfigurieren</h1>
|
||||
<p class="text-muted mb-0">Definieren Sie Labels und Sichtbarkeit fuer Kundenfelder im Admin-Bereich.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<!-- Info-Box -->
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>{{ discovered_fields|length }} Felder</strong> wurden automatisch aus bestehenden Kundendaten erkannt.
|
||||
Sie koennen fuer jedes Feld ein benutzerdefiniertes Label setzen oder es ausblenden.
|
||||
</div>
|
||||
|
||||
{% if discovered_fields %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-sliders me-2"></i>Feld-Konfiguration</span>
|
||||
<span class="badge bg-secondary">{{ discovered_fields|length }} Felder</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 200px;">Feldname (intern)</th>
|
||||
<th>Anzeige-Label</th>
|
||||
<th style="width: 120px;" class="text-center">Ausblenden</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for field_name in discovered_fields %}
|
||||
{% set current_label = config.field_labels.get(field_name, '') %}
|
||||
{% set default_label = default_config.field_labels.get(field_name, '') %}
|
||||
{% set is_hidden = field_name in config.hidden_fields %}
|
||||
<tr{% if is_hidden %} class="opacity-50"{% endif %}>
|
||||
<td>
|
||||
<code>{{ field_name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
name="label_{{ field_name }}"
|
||||
value="{{ current_label }}"
|
||||
placeholder="{{ default_label or field_name }}"
|
||||
{% if is_hidden %}disabled{% endif %}>
|
||||
{% if default_label and not current_label %}
|
||||
<small class="text-muted">Standard: {{ default_label }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-flex justify-content-center">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="hidden_{{ field_name }}"
|
||||
name="hidden_{{ field_name }}"
|
||||
{% if is_hidden %}checked{% endif %}
|
||||
onchange="toggleFieldRow(this, '{{ field_name }}')">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Keine Kundenfelder gefunden. Importieren Sie zuerst Kundendaten, damit Felder erkannt werden koennen.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Aktionen -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
<a href="{{ url_for('admin.customers') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck zu Kunden
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Hinweise -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-lightbulb me-2"></i>Hinweise
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li><strong>Anzeige-Label:</strong> Wenn leer, wird der interne Feldname oder der Standard verwendet.</li>
|
||||
<li><strong>Ausblenden:</strong> Versteckte Felder werden in der Kundendetailansicht nicht angezeigt.</li>
|
||||
<li><strong>Auto-Discovery:</strong> Neue Felder werden automatisch erkannt, wenn Kundendaten importiert werden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function toggleFieldRow(checkbox, fieldName) {
|
||||
const row = checkbox.closest('tr');
|
||||
const input = row.querySelector('input[type="text"]');
|
||||
|
||||
if (checkbox.checked) {
|
||||
row.classList.add('opacity-50');
|
||||
input.disabled = true;
|
||||
} else {
|
||||
row.classList.remove('opacity-50');
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
343
customer_portal/web/templates/admin/settings_field_mapping.html
Executable file
343
customer_portal/web/templates/admin/settings_field_mapping.html
Executable file
@@ -0,0 +1,343 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Feld-Mapping{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.sortable-row {
|
||||
cursor: grab;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.sortable-row:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.sortable-row.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: rgba(220, 53, 69, 0.2) !important;
|
||||
}
|
||||
.sortable-row.drag-over {
|
||||
border-top: 2px solid #dc3545;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6c757d;
|
||||
}
|
||||
.drag-handle:hover {
|
||||
color: #adb5bd;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-arrow-left-right me-2"></i>Feld-Mapping</h1>
|
||||
<p class="text-muted">Portal-Felder mit WordPress-Feldern verbinden (verschiebbar)</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<form method="POST" action="{{ url_for('admin.settings_field_mapping') }}" id="mappingForm">
|
||||
|
||||
<!-- Datenbank-Felder -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-database me-2"></i>
|
||||
Stammdaten (Datenbank)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 180px;">Portal-Feld</th>
|
||||
<th style="width: 70px;" class="text-center">Sync?</th>
|
||||
<th>WordPress-Feld</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dbFieldsBody" class="sortable-body">
|
||||
{% set db_fields = [
|
||||
{'key': 'db.phone', 'label': 'Telefon'},
|
||||
{'key': 'db.address_street', 'label': 'Strasse'},
|
||||
{'key': 'db.address_zip', 'label': 'PLZ'},
|
||||
{'key': 'db.address_city', 'label': 'Ort'}
|
||||
] %}
|
||||
{% for field in db_fields %}
|
||||
{% set current_wp = config.mappings.get(field.key, '') %}
|
||||
<tr class="sortable-row" draggable="true" data-field="{{ field.key }}">
|
||||
<td class="align-middle text-center drag-handle">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<strong>{{ field.label }}</strong>
|
||||
</td>
|
||||
<td class="text-center align-middle">
|
||||
<div class="form-check form-switch d-inline-block mb-0">
|
||||
<input class="form-check-input sync-toggle" type="checkbox"
|
||||
data-field="{{ field.key }}"
|
||||
id="sync_{{ field.key }}"
|
||||
{% if current_wp %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<select name="map_{{ field.key }}"
|
||||
id="select_{{ field.key }}"
|
||||
class="form-select form-select-sm bg-dark text-light border-secondary"
|
||||
{% if not current_wp %}disabled{% endif %}>
|
||||
<option value="">-- Waehlen --</option>
|
||||
{% if wp_schema and wp_schema.custom_fields %}
|
||||
{% for wp_field in wp_schema.custom_fields %}
|
||||
{% set wp_id = wp_field.name or wp_field.id %}
|
||||
<option value="{{ wp_id }}" {% if current_wp == wp_id %}selected{% endif %}>
|
||||
{{ wp_field.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zusatzfelder -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
Zusatzfelder (Custom)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 180px;">Portal-Feld</th>
|
||||
<th style="width: 70px;" class="text-center">Sync?</th>
|
||||
<th>WordPress-Feld</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customFieldsBody" class="sortable-body">
|
||||
{% set custom_fields = [
|
||||
{'key': 'custom.vorname', 'label': 'Vorname'},
|
||||
{'key': 'custom.nachname', 'label': 'Nachname'},
|
||||
{'key': 'custom.geburtsdatum', 'label': 'Geburtsdatum'},
|
||||
{'key': 'custom.pferdename', 'label': 'Pferdename'},
|
||||
{'key': 'custom.geschlecht_pferd', 'label': 'Geschlecht Pferd'}
|
||||
] %}
|
||||
{% for field in custom_fields %}
|
||||
{% set current_wp = config.mappings.get(field.key, '') %}
|
||||
<tr class="sortable-row" draggable="true" data-field="{{ field.key }}">
|
||||
<td class="align-middle text-center drag-handle">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<strong>{{ field.label }}</strong>
|
||||
</td>
|
||||
<td class="text-center align-middle">
|
||||
<div class="form-check form-switch d-inline-block mb-0">
|
||||
<input class="form-check-input sync-toggle" type="checkbox"
|
||||
data-field="{{ field.key }}"
|
||||
id="sync_{{ field.key }}"
|
||||
{% if current_wp %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<select name="map_{{ field.key }}"
|
||||
id="select_{{ field.key }}"
|
||||
class="form-select form-select-sm bg-dark text-light border-secondary"
|
||||
{% if not current_wp %}disabled{% endif %}>
|
||||
<option value="">-- Waehlen --</option>
|
||||
{% if wp_schema and wp_schema.custom_fields %}
|
||||
{% for wp_field in wp_schema.custom_fields %}
|
||||
{% set wp_id = wp_field.name or wp_field.id %}
|
||||
<option value="{{ wp_id }}" {% if current_wp == wp_id %}selected{% endif %}>
|
||||
{{ wp_field.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
Optionen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="auto_sync_on_booking" name="auto_sync_on_booking"
|
||||
{% if config.auto_sync_on_booking %}checked{% endif %}>
|
||||
<label class="form-check-label" for="auto_sync_on_booking">
|
||||
<strong>Auto-Sync bei neuer Buchung</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Synchronisiert automatisch, wenn ein Kunde bucht.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for order -->
|
||||
<input type="hidden" name="field_order" id="fieldOrder" value="">
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<!-- WordPress Status -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-wordpress me-2"></i>
|
||||
WordPress
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if wp_error %}
|
||||
<div class="text-danger small">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
{{ wp_error }}
|
||||
</div>
|
||||
{% elif wp_schema and wp_schema.custom_fields %}
|
||||
<div class="text-success small mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Verbunden
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
{{ wp_schema.custom_fields|length }} Felder verfuegbar
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Keine Felder
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Sync -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>
|
||||
Synchronisation
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.customers_sync_all') }}"
|
||||
onsubmit="return confirm('Alle Kunden synchronisieren?');">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm w-100">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>
|
||||
Alle synchronisieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Anleitung
|
||||
</div>
|
||||
<div class="card-body small text-muted">
|
||||
<p class="mb-2">
|
||||
<i class="bi bi-grip-vertical me-1"></i>
|
||||
<strong>Verschieben:</strong> Zeilen ziehen
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="bi bi-toggle-on me-1"></i>
|
||||
<strong>Sync = An:</strong> Feld wird synchronisiert
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Nur leere Felder werden befuellt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle select when checkbox changes
|
||||
document.querySelectorAll('.sync-toggle').forEach(function(checkbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
var fieldKey = this.dataset.field;
|
||||
var select = document.getElementById('select_' + fieldKey);
|
||||
if (select) {
|
||||
select.disabled = !this.checked;
|
||||
if (!this.checked) {
|
||||
select.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Drag and Drop
|
||||
document.querySelectorAll('.sortable-body').forEach(function(tbody) {
|
||||
var draggedRow = null;
|
||||
|
||||
tbody.addEventListener('dragstart', function(e) {
|
||||
if (e.target.classList.contains('sortable-row')) {
|
||||
draggedRow = e.target;
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener('dragend', function(e) {
|
||||
if (e.target.classList.contains('sortable-row')) {
|
||||
e.target.classList.remove('dragging');
|
||||
tbody.querySelectorAll('.sortable-row').forEach(function(row) {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
var targetRow = e.target.closest('.sortable-row');
|
||||
if (targetRow && targetRow !== draggedRow) {
|
||||
tbody.querySelectorAll('.sortable-row').forEach(function(row) {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
targetRow.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
var targetRow = e.target.closest('.sortable-row');
|
||||
if (targetRow && draggedRow && targetRow !== draggedRow) {
|
||||
var rows = Array.from(tbody.querySelectorAll('.sortable-row'));
|
||||
var draggedIndex = rows.indexOf(draggedRow);
|
||||
var targetIndex = rows.indexOf(targetRow);
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
targetRow.parentNode.insertBefore(draggedRow, targetRow.nextSibling);
|
||||
} else {
|
||||
targetRow.parentNode.insertBefore(draggedRow, targetRow);
|
||||
}
|
||||
}
|
||||
tbody.querySelectorAll('.sortable-row').forEach(function(row) {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Save order before submit
|
||||
document.getElementById('mappingForm').addEventListener('submit', function() {
|
||||
var order = [];
|
||||
document.querySelectorAll('.sortable-row').forEach(function(row) {
|
||||
order.push(row.dataset.field);
|
||||
});
|
||||
document.getElementById('fieldOrder').value = JSON.stringify(order);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
147
customer_portal/web/templates/admin/settings_mail.html
Executable file
147
customer_portal/web/templates/admin/settings_mail.html
Executable file
@@ -0,0 +1,147 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Mail-Konfiguration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-envelope me-2"></i>Mail-Server Konfiguration</h1>
|
||||
<p class="text-muted">SMTP-Einstellungen fuer E-Mail-Versand</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form method="POST" action="{{ url_for('admin.settings_mail') }}">
|
||||
<!-- SMTP Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-server me-2"></i>
|
||||
SMTP-Einstellungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="mail_server" class="form-label">SMTP-Server</label>
|
||||
<input type="text" class="form-control" id="mail_server" name="mail_server"
|
||||
value="{{ config.mail_server }}" placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="mail_port" class="form-label">Port</label>
|
||||
<input type="number" class="form-control" id="mail_port" name="mail_port"
|
||||
value="{{ config.mail_port }}" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="mail_username" class="form-label">Benutzername</label>
|
||||
<input type="text" class="form-control" id="mail_username" name="mail_username"
|
||||
value="{{ config.mail_username }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="mail_password" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="mail_password" name="mail_password"
|
||||
placeholder="{% if config.mail_password %}(gespeichert){% else %}Passwort eingeben{% endif %}"
|
||||
autocomplete="new-password">
|
||||
<div class="form-text">Leer lassen um bestehendes Passwort beizubehalten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="mail_use_tls"
|
||||
id="mail_use_tls" {% if config.mail_use_tls %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mail_use_tls">TLS verwenden (STARTTLS)</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="mail_use_ssl"
|
||||
id="mail_use_ssl" {% if config.mail_use_ssl %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mail_use_ssl">SSL verwenden</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sender Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-person-badge me-2"></i>
|
||||
Absender
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="mail_default_sender" class="form-label">Absender E-Mail</label>
|
||||
<input type="email" class="form-control" id="mail_default_sender" name="mail_default_sender"
|
||||
value="{{ config.mail_default_sender }}" placeholder="portal@example.com">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="mail_default_sender_name" class="form-label">Absender Name</label>
|
||||
<input type="text" class="form-control" id="mail_default_sender_name" name="mail_default_sender_name"
|
||||
value="{{ config.mail_default_sender_name }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Test Email -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-send me-2"></i>
|
||||
Test-E-Mail
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.settings_mail_test') }}">
|
||||
<div class="mb-3">
|
||||
<label for="test_email" class="form-label">Empfaenger</label>
|
||||
<input type="email" class="form-control" id="test_email" name="test_email"
|
||||
placeholder="test@example.com" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-info w-100"
|
||||
{% if not config.mail_server %}disabled{% endif %}>
|
||||
<i class="bi bi-envelope-arrow-up me-1"></i>
|
||||
Test senden
|
||||
</button>
|
||||
{% if not config.mail_server %}
|
||||
<div class="form-text text-warning mt-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Zuerst SMTP-Server konfigurieren
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common SMTP Settings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Haeufige Einstellungen
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<p class="mb-2"><strong>Gmail:</strong></p>
|
||||
<ul class="mb-3">
|
||||
<li>Server: smtp.gmail.com</li>
|
||||
<li>Port: 587 (TLS) oder 465 (SSL)</li>
|
||||
</ul>
|
||||
<p class="mb-2"><strong>Office 365:</strong></p>
|
||||
<ul class="mb-3">
|
||||
<li>Server: smtp.office365.com</li>
|
||||
<li>Port: 587 (TLS)</li>
|
||||
</ul>
|
||||
<p class="mb-2"><strong>All-Inkl:</strong></p>
|
||||
<ul class="mb-0">
|
||||
<li>Server: smtp.all-inkl.de</li>
|
||||
<li>Port: 587 (TLS)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
129
customer_portal/web/templates/admin/settings_otp.html
Executable file
129
customer_portal/web/templates/admin/settings_otp.html
Executable file
@@ -0,0 +1,129 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}OTP & Sicherheit{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-shield-lock me-2"></i>OTP & Sicherheit</h1>
|
||||
<p class="text-muted">Einstellungen fuer Einmalpasswoerter und Authentifizierung</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form method="POST" action="{{ url_for('admin.settings_otp') }}">
|
||||
<!-- OTP Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Einmalpasswort (OTP)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="otp_expiry_minutes" class="form-label">Gueltigkeit (Minuten)</label>
|
||||
<input type="number" class="form-control" id="otp_expiry_minutes" name="otp_expiry_minutes"
|
||||
value="{{ config.otp_expiry_minutes }}" min="1" max="60">
|
||||
<div class="form-text">Wie lange ist ein OTP-Code gueltig?</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="otp_length" class="form-label">Code-Laenge (Ziffern)</label>
|
||||
<input type="number" class="form-control" id="otp_length" name="otp_length"
|
||||
value="{{ config.otp_length }}" min="4" max="8">
|
||||
<div class="form-text">Anzahl der Ziffern im Code</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="otp_max_attempts" class="form-label">Max. Fehlversuche</label>
|
||||
<input type="number" class="form-control" id="otp_max_attempts" name="otp_max_attempts"
|
||||
value="{{ config.otp_max_attempts }}" min="1" max="10">
|
||||
<div class="form-text">Bevor Code ungueltig wird</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prefill Token Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-link-45deg me-2"></i>
|
||||
Prefill-Token (WordPress-Integration)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="prefill_token_expiry" class="form-label">Token-Gueltigkeit (Sekunden)</label>
|
||||
<input type="number" class="form-control" id="prefill_token_expiry" name="prefill_token_expiry"
|
||||
value="{{ config.prefill_token_expiry }}" min="60" max="3600">
|
||||
<div class="form-text">
|
||||
Standard: 300 (5 Minuten). Maximaler Wert: 3600 (1 Stunde)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Prefill-Tokens werden von WordPress generiert, um Kundendaten automatisch
|
||||
im Login-Formular vorzufuellen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Info Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
Empfehlungen
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<p class="mb-2"><strong>OTP-Gueltigkeit:</strong></p>
|
||||
<p class="text-muted mb-3">
|
||||
10 Minuten sind ein guter Kompromiss zwischen Sicherheit und Benutzerfreundlichkeit.
|
||||
</p>
|
||||
|
||||
<p class="mb-2"><strong>Code-Laenge:</strong></p>
|
||||
<p class="text-muted mb-3">
|
||||
6 Ziffern bieten ausreichende Sicherheit (1 Million Kombinationen).
|
||||
</p>
|
||||
|
||||
<p class="mb-2"><strong>Fehlversuche:</strong></p>
|
||||
<p class="text-muted mb-0">
|
||||
3 Versuche schuetzen vor Brute-Force-Angriffen, ohne legitime Nutzer zu sehr einzuschraenken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Values -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
Aktuelle Werte
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<tr>
|
||||
<td class="text-muted">OTP gueltig</td>
|
||||
<td class="text-end">{{ config.otp_expiry_minutes }} Min.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Code-Laenge</td>
|
||||
<td class="text-end">{{ config.otp_length }} Ziffern</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Max. Versuche</td>
|
||||
<td class="text-end">{{ config.otp_max_attempts }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Token-Gueltigkeit</td>
|
||||
<td class="text-end">{{ config.prefill_token_expiry }} Sek.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
185
customer_portal/web/templates/admin/settings_wordpress.html
Executable file
185
customer_portal/web/templates/admin/settings_wordpress.html
Executable file
@@ -0,0 +1,185 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}WordPress-Integration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-wordpress me-2"></i>WordPress-Integration</h1>
|
||||
<p class="text-muted">Verbindungseinstellungen zum WordPress Kurs-Booking System</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<form method="POST" action="{{ url_for('admin.settings_wordpress') }}">
|
||||
<!-- API Connection -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plug me-2"></i>
|
||||
API-Verbindung
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="wp_api_url" class="form-label">WordPress REST API URL</label>
|
||||
<input type="url" class="form-control" id="wp_api_url" name="wp_api_url"
|
||||
value="{{ config.wp_api_url }}"
|
||||
placeholder="http://192.168.100.93:8300/wp-json/kurs-booking/v1">
|
||||
<div class="form-text">
|
||||
Vollstaendige URL zur kurs-booking REST API (ohne abschliessenden Slash)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="wp_api_secret" class="form-label">API Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="wp_api_secret" name="wp_api_secret"
|
||||
value="{{ config.wp_api_secret }}">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="toggleSecret()">
|
||||
<i class="bi bi-eye" id="toggleIcon"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="generateSecret()">
|
||||
Generieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Muss identisch sein mit der WordPress-Einstellung "Portal API Secret" im Kurs-Booking Plugin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label for="wp_booking_page_url" class="form-label">Buchungsseite URL (optional)</label>
|
||||
<input type="url" class="form-control" id="wp_booking_page_url" name="wp_booking_page_url"
|
||||
value="{{ config.wp_booking_page_url }}"
|
||||
placeholder="http://192.168.100.93:8300/buchung/">
|
||||
<div class="form-text">
|
||||
Link zur Buchungsseite fuer "Neue Buchung" Button
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Connection Test -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-broadcast me-2"></i>
|
||||
Verbindungstest
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.settings_wordpress_test') }}">
|
||||
<p class="small text-muted mb-3">
|
||||
Testet die Verbindung zur WordPress REST API mit den gespeicherten Einstellungen.
|
||||
</p>
|
||||
<button type="submit" class="btn btn-outline-info w-100"
|
||||
{% if not config.wp_api_url %}disabled{% endif %}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>
|
||||
Verbindung testen
|
||||
</button>
|
||||
{% if not config.wp_api_url %}
|
||||
<div class="form-text text-warning mt-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Zuerst API URL konfigurieren
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Konfigurationsstatus
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<tr>
|
||||
<td class="text-muted">API URL</td>
|
||||
<td class="text-end">
|
||||
{% if config.wp_api_url %}
|
||||
<span class="badge bg-success">Gesetzt</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Nicht gesetzt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">API Secret</td>
|
||||
<td class="text-end">
|
||||
{% if config.wp_api_secret %}
|
||||
<span class="badge bg-success">Gesetzt</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Nicht gesetzt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Buchungsseite</td>
|
||||
<td class="text-end">
|
||||
{% if config.wp_booking_page_url %}
|
||||
<span class="badge bg-success">Gesetzt</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Optional</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Endpoints Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-diagram-3 me-2"></i>
|
||||
Verfuegbare Endpunkte
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<code class="d-block mb-2">/bookings</code>
|
||||
<p class="text-muted mb-2">Buchungen eines Kunden abrufen</p>
|
||||
|
||||
<code class="d-block mb-2">/invoices</code>
|
||||
<p class="text-muted mb-2">Rechnungen via sevDesk abrufen</p>
|
||||
|
||||
<code class="d-block mb-2">/videos</code>
|
||||
<p class="text-muted mb-2">Video-Zugang pruefen</p>
|
||||
|
||||
<code class="d-block mb-2">/test</code>
|
||||
<p class="text-muted mb-0">Verbindungstest</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleSecret() {
|
||||
const input = document.getElementById('wp_api_secret');
|
||||
const icon = document.getElementById('toggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecret() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let secret = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
secret += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
document.getElementById('wp_api_secret').value = secret;
|
||||
document.getElementById('wp_api_secret').type = 'text';
|
||||
document.getElementById('toggleIcon').classList.remove('bi-eye');
|
||||
document.getElementById('toggleIcon').classList.add('bi-eye-slash');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
56
customer_portal/web/templates/auth/login.html
Executable file
56
customer_portal/web/templates/auth/login.html
Executable file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Anmelden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
{% if branding.logo_url %}
|
||||
<img src="{{ branding.logo_url }}" alt="{{ branding.company_name }}" style="max-height: 60px; margin-bottom: 1rem;">
|
||||
{% else %}
|
||||
<i class="bi bi-person-circle display-4 text-success"></i>
|
||||
{% endif %}
|
||||
<h2 class="mt-3">Anmelden</h2>
|
||||
<p class="text-muted">{{ branding.company_name }}</p>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-4">
|
||||
<label for="email" class="form-label">E-Mail-Adresse</label>
|
||||
<input type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary"
|
||||
placeholder="ihre@email.at"
|
||||
required
|
||||
autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-send me-2"></i>
|
||||
Code anfordern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-muted mb-0">
|
||||
Noch kein Konto?
|
||||
<a href="{{ url_for('auth.register') }}" class="text-success">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
Sie erhalten einen 6-stelligen Code per E-Mail
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
66
customer_portal/web/templates/auth/register.html
Executable file
66
customer_portal/web/templates/auth/register.html
Executable file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Registrieren{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-person-plus display-4 text-success"></i>
|
||||
<h2 class="mt-3">Registrieren</h2>
|
||||
<p class="text-muted">Erstellen Sie Ihr Kundenkonto</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div class="d-flex justify-content-center mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success rounded-circle">1</span>
|
||||
<span class="mx-2 text-success">E-Mail</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-secondary rounded-circle">2</span>
|
||||
<span class="mx-2 text-muted">Code</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-secondary rounded-circle">3</span>
|
||||
<span class="mx-2 text-muted">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-4">
|
||||
<label for="email" class="form-label">E-Mail-Adresse</label>
|
||||
<input type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary"
|
||||
placeholder="ihre@email.at"
|
||||
required
|
||||
autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-send me-2"></i>
|
||||
Weiter
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-muted mb-0">
|
||||
Bereits registriert?
|
||||
<a href="{{ url_for('auth.login') }}" class="text-success">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
Sie erhalten einen Bestaetigungscode per E-Mail
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
75
customer_portal/web/templates/auth/register_profile.html
Executable file
75
customer_portal/web/templates/auth/register_profile.html
Executable file
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profil anlegen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-person-badge display-4 text-success"></i>
|
||||
<h2 class="mt-3">Profil anlegen</h2>
|
||||
<p class="text-muted">
|
||||
Fast geschafft! Bitte geben Sie Ihre Daten ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div class="d-flex justify-content-center mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success rounded-circle"><i class="bi bi-check"></i></span>
|
||||
<span class="mx-2 text-success">E-Mail</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-success rounded-circle"><i class="bi bi-check"></i></span>
|
||||
<span class="mx-2 text-success">Code</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-success rounded-circle">3</span>
|
||||
<span class="mx-2 text-success">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary border-secondary mb-4">
|
||||
<small>
|
||||
<i class="bi bi-envelope me-1"></i>
|
||||
E-Mail: <strong>{{ email }}</strong>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary"
|
||||
placeholder="Vor- und Nachname"
|
||||
required
|
||||
autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="phone" class="form-label">Telefon <span class="text-muted">(optional)</span></label>
|
||||
<input type="tel"
|
||||
name="phone"
|
||||
id="phone"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary"
|
||||
placeholder="+43 ...">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
Registrierung abschliessen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
Ihre Daten werden vertraulich behandelt
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
customer_portal/web/templates/auth/register_verify.html
Executable file
91
customer_portal/web/templates/auth/register_verify.html
Executable file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Code bestaetigen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock display-4 text-success"></i>
|
||||
<h2 class="mt-3">Code eingeben</h2>
|
||||
<p class="text-muted">
|
||||
Wir haben einen 6-stelligen Code an<br>
|
||||
<strong>{{ email }}</strong> gesendet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div class="d-flex justify-content-center mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success rounded-circle"><i class="bi bi-check"></i></span>
|
||||
<span class="mx-2 text-success">E-Mail</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-success rounded-circle">2</span>
|
||||
<span class="mx-2 text-success">Code</span>
|
||||
<span class="text-muted mx-2">—</span>
|
||||
<span class="badge bg-secondary rounded-circle">3</span>
|
||||
<span class="mx-2 text-muted">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-4">
|
||||
<label for="code" class="form-label">Sicherheitscode</label>
|
||||
<input type="text"
|
||||
name="code"
|
||||
id="code"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary text-center"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
required
|
||||
autofocus
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem;">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
Bestaetigen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck
|
||||
</a>
|
||||
<form action="{{ url_for('auth.register_resend') }}" method="post" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
Neuen Code senden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
Der Code ist 10 Minuten gueltig
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Auto-focus and only allow numbers
|
||||
document.getElementById('code').addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
this.form.submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
customer_portal/web/templates/auth/verify.html
Executable file
77
customer_portal/web/templates/auth/verify.html
Executable file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Code eingeben{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock display-4 text-success"></i>
|
||||
<h2 class="mt-3">Code eingeben</h2>
|
||||
<p class="text-muted">
|
||||
Wir haben einen 6-stelligen Code an<br>
|
||||
<strong>{{ email }}</strong> gesendet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="mb-4">
|
||||
<label for="code" class="form-label">Sicherheitscode</label>
|
||||
<input type="text"
|
||||
name="code"
|
||||
id="code"
|
||||
class="form-control form-control-lg bg-dark text-light border-secondary text-center"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
required
|
||||
autofocus
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem;">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
Bestaetigen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck
|
||||
</a>
|
||||
<form action="{{ url_for('auth.resend') }}" method="post" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
Neuen Code senden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
Der Code ist 10 Minuten gueltig
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Auto-focus and only allow numbers
|
||||
document.getElementById('code').addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
this.form.submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
154
customer_portal/web/templates/base.html
Executable file
154
customer_portal/web/templates/base.html
Executable file
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ branding.company_name }}{% endblock %}</title>
|
||||
{% if branding.favicon_url %}
|
||||
<link rel="icon" href="{{ branding.favicon_url }}" type="image/x-icon">
|
||||
{% endif %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--portal-primary: {{ branding.colors.primary }};
|
||||
--portal-primary-hover: {{ branding.colors.primary_hover }};
|
||||
--portal-bg: {{ branding.colors.background }};
|
||||
--portal-header-bg: {{ branding.colors.header_bg }};
|
||||
--portal-text: {{ branding.colors.text }};
|
||||
--portal-muted: {{ branding.colors.muted }};
|
||||
--portal-border: {{ branding.colors.border }};
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--portal-bg) !important;
|
||||
color: var(--portal-text) !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--portal-header-bg) !important;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--portal-primary) !important;
|
||||
border-color: var(--portal-primary) !important;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: var(--portal-primary-hover) !important;
|
||||
border-color: var(--portal-primary-hover) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--portal-primary) !important;
|
||||
}
|
||||
|
||||
a.text-success:hover {
|
||||
color: var(--portal-primary-hover) !important;
|
||||
}
|
||||
|
||||
.border-secondary {
|
||||
border-color: var(--portal-border) !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--portal-muted) !important;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-dark text-light">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">
|
||||
{% if branding.logo_url %}
|
||||
<img src="{{ branding.logo_url }}" alt="{{ branding.company_name }}" style="max-height: 40px;">
|
||||
{% else %}
|
||||
<i class="bi bi-calendar-event me-2"></i>
|
||||
{{ branding.company_name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if g.customer %}
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-house me-1"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('bookings.list_bookings') }}">
|
||||
<i class="bi bi-calendar-check me-1"></i> Buchungen
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('invoices.list_invoices') }}">
|
||||
<i class="bi bi-receipt me-1"></i> Rechnungen
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('videos.list_videos') }}">
|
||||
<i class="bi bi-play-circle me-1"></i> Videos
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
{{ g.customer.display_name }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('profile.show') }}">
|
||||
<i class="bi bi-person me-2"></i>Mein Profil
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('profile.settings') }}">
|
||||
<i class="bi bi-gear me-2"></i>Einstellungen
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>Abmelden
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer mt-auto py-3 bg-dark border-top border-secondary">
|
||||
<div class="container text-center text-muted">
|
||||
<small>© {{ now().year if now else '2025' }} {{ branding.company_name }}</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
58
customer_portal/web/templates/base_sidebar.html
Executable file
58
customer_portal/web/templates/base_sidebar.html
Executable file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Kundenportal{% endblock %} - Webwerkstatt</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/sidebar.css') }}" rel="stylesheet">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
{% if g.customer %}
|
||||
{% include "components/sidebar.html" %}
|
||||
{% endif %}
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Top Bar (Mobile) -->
|
||||
<header class="topbar d-lg-none">
|
||||
<span class="topbar-title">{% block topbar_title %}Kundenportal{% endblock %}</span>
|
||||
</header>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-container">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="page-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Close sidebar on mobile when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const toggle = document.querySelector('.sidebar-toggle');
|
||||
if (sidebar && sidebar.classList.contains('show') &&
|
||||
!sidebar.contains(e.target) && !toggle.contains(e.target)) {
|
||||
sidebar.classList.remove('show');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
74
customer_portal/web/templates/bookings/cancel.html
Executable file
74
customer_portal/web/templates/bookings/cancel.html
Executable file
@@ -0,0 +1,74 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Stornierung - {{ booking.number or booking.id }}{% endblock %}
|
||||
{% block topbar_title %}Stornierung{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck zur Buchung
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card bg-dark border-danger">
|
||||
<div class="card-header border-danger bg-danger bg-opacity-10">
|
||||
<h4 class="mb-0 text-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Stornierung beantragen
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-secondary border-secondary">
|
||||
<h5 class="mb-2">{{ booking.kurs_title }}</h5>
|
||||
<p class="mb-1">
|
||||
<i class="bi bi-calendar me-1"></i>
|
||||
{{ booking.kurs_date or 'Datum nicht angegeben' }}
|
||||
{% if booking.kurs_time %} um {{ booking.kurs_time }}{% endif %}
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Buchungsnummer:</strong> {{ booking.number or booking.id }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning border-warning">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Wichtiger Hinweis
|
||||
</h6>
|
||||
<p class="mb-0 small">
|
||||
Nach dem Absenden wird Ihre Stornierungsanfrage geprueft.
|
||||
Je nach Stornierungszeitpunkt koennen Gebuehren anfallen.
|
||||
Sie erhalten eine Bestaetigung per E-Mail.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-4">
|
||||
<label for="reason" class="form-label">
|
||||
Grund der Stornierung <span class="text-muted">(optional)</span>
|
||||
</label>
|
||||
<textarea name="reason"
|
||||
id="reason"
|
||||
class="form-control bg-dark text-light border-secondary"
|
||||
rows="4"
|
||||
placeholder="Bitte teilen Sie uns den Grund mit..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}"
|
||||
class="btn btn-outline-secondary">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Stornierung beantragen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
226
customer_portal/web/templates/bookings/detail.html
Executable file
226
customer_portal/web/templates/bookings/detail.html
Executable file
@@ -0,0 +1,226 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{# Sprint 14: Support both local DB model and WordPress API dict #}
|
||||
{% if use_local_db %}
|
||||
{% block title %}Buchung {{ booking.booking_number or booking.id }}{% endblock %}
|
||||
{% else %}
|
||||
{% block title %}Buchung {{ booking.number or booking.id }}{% endblock %}
|
||||
{% endif %}
|
||||
{% block topbar_title %}Buchung Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('bookings.list_bookings') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck zur Liste
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-ticket-detailed me-2"></i>
|
||||
{% if use_local_db %}
|
||||
Buchung {{ booking.booking_number or ('#' ~ booking.id) }}
|
||||
{% else %}
|
||||
Buchung {{ booking.number or booking.id }}
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if use_local_db %}
|
||||
<span class="badge bg-{{ booking.status_color }} fs-6">
|
||||
{{ booking.status_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge {{ booking.status|status_badge }} fs-6">
|
||||
{{ booking.status|status_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5 class="text-success">Kurs</h5>
|
||||
<p class="fs-5 mb-1">{{ booking.kurs_title or '-' }}</p>
|
||||
{% if use_local_db %}
|
||||
{% if booking.kurs_date %}
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-calendar me-1"></i>
|
||||
{{ booking.formatted_date }}
|
||||
{% if booking.kurs_time %} um {{ booking.formatted_time }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if booking.kurs_date %}
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-calendar me-1"></i>
|
||||
{{ booking.kurs_date|date }}
|
||||
{% if booking.kurs_time %} um {{ booking.kurs_time }}{% endif %}
|
||||
{% if booking.kurs_end_time %} - {{ booking.kurs_end_time }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if booking.kurs_location %}
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-geo-alt me-1"></i>
|
||||
{{ booking.kurs_location }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5 class="text-success">Buchungsdetails</h5>
|
||||
<table class="table table-sm table-dark mb-0">
|
||||
{% if booking.ticket_type %}
|
||||
<tr>
|
||||
<td class="text-muted">Ticket</td>
|
||||
<td>{{ booking.ticket_type }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if booking.ticket_count %}
|
||||
<tr>
|
||||
<td class="text-muted">Anzahl</td>
|
||||
<td>{{ booking.ticket_count }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="text-muted">Preis</td>
|
||||
{% if use_local_db %}
|
||||
<td class="fs-5">{{ booking.formatted_price }}</td>
|
||||
{% else %}
|
||||
<td class="fs-5">{{ booking.price|format_price }} EUR</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Gebucht am</td>
|
||||
{% if use_local_db %}
|
||||
<td>{{ booking.wp_created_at.strftime('%d.%m.%Y') if booking.wp_created_at else '-' }}</td>
|
||||
{% else %}
|
||||
<td>{{ booking.created_at|date if booking.created_at else '-' }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Customer info - for local DB model we have customer_name/email/phone directly #}
|
||||
{% if use_local_db %}
|
||||
{% if booking.customer_name or booking.customer_email %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
Kontaktdaten bei Buchung
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Name</small>
|
||||
<p class="mb-0">{{ booking.customer_name or '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">E-Mail</small>
|
||||
<p class="mb-0">{{ booking.customer_email or '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Telefon</small>
|
||||
<p class="mb-0">{{ booking.customer_phone or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if booking.customer %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
Kontaktdaten
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Name</small>
|
||||
<p class="mb-0">{{ booking.customer.name or '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">E-Mail</small>
|
||||
<p class="mb-0">{{ booking.customer.email or '-' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Telefon</small>
|
||||
<p class="mb-0">{{ booking.customer.phone or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Invoice info #}
|
||||
{% if use_local_db %}
|
||||
{% if booking.sevdesk_invoice_id %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-receipt me-2"></i>
|
||||
Rechnung
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<strong>Rechnungsnummer:</strong> {{ booking.sevdesk_invoice_number or booking.sevdesk_invoice_id }}
|
||||
</p>
|
||||
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-success btn-sm">
|
||||
<i class="bi bi-download me-1"></i>
|
||||
Zur Rechnungsuebersicht
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if booking.sevdesk and booking.sevdesk.invoice_id %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-receipt me-2"></i>
|
||||
Rechnung
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<strong>Rechnungsnummer:</strong> {{ booking.sevdesk.invoice_number or booking.sevdesk.invoice_id }}
|
||||
</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-success btn-sm">
|
||||
<i class="bi bi-download me-1"></i>
|
||||
Zur Rechnungsuebersicht
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Cancellation option #}
|
||||
{% if booking.status == 'confirmed' %}
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-x-circle me-2"></i>
|
||||
Stornierung
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Moechten Sie diese Buchung stornieren? Bitte beachten Sie unsere Stornierungsbedingungen.
|
||||
</p>
|
||||
<a href="{{ url_for('bookings.cancel', booking_id=booking.id) }}"
|
||||
class="btn btn-outline-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Stornierung beantragen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
194
customer_portal/web/templates/bookings/list.html
Executable file
194
customer_portal/web/templates/bookings/list.html
Executable file
@@ -0,0 +1,194 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Meine Buchungen{% endblock %}
|
||||
{% block topbar_title %}Buchungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-calendar-check"></i>Meine Buchungen</h1>
|
||||
<p>Uebersicht aller Kurse und Veranstaltungen</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||
<select class="form-select bg-dark text-light border-secondary" id="statusFilter">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="confirmed">Bestaetigt</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
<option value="cancelled">Storniert</option>
|
||||
<option value="cancel_requested">Storno beantragt</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="yearFilter" class="form-label small text-muted">Jahr</label>
|
||||
<select class="form-select bg-dark text-light border-secondary" id="yearFilter">
|
||||
<option value="">Alle Jahre</option>
|
||||
<option value="2025">2025</option>
|
||||
<option value="2024">2024</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-outline-secondary" id="resetFilters">
|
||||
<i class="bi bi-x-circle me-1"></i>Filter zuruecksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if bookings %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover" id="bookingsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Buchungsnr.</th>
|
||||
<th>Kurs</th>
|
||||
<th>Datum</th>
|
||||
<th>Status</th>
|
||||
<th>Preis</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for booking in bookings %}
|
||||
{# Sprint 14: Support both local DB model and WordPress API dict #}
|
||||
{% if use_local_db %}
|
||||
<tr data-status="{{ booking.status }}" data-year="{{ booking.kurs_date.strftime('%Y') if booking.kurs_date else '' }}">
|
||||
<td>
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}" class="text-success">
|
||||
{{ booking.booking_number or ('#' ~ booking.id) }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ booking.kurs_title or '-' }}</td>
|
||||
<td>
|
||||
{% if booking.kurs_date %}
|
||||
{{ booking.formatted_date }}
|
||||
{% if booking.kurs_time %} - {{ booking.formatted_time }}{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ booking.status_color }}">
|
||||
{{ booking.status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ booking.formatted_price }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{# Legacy: WordPress API response #}
|
||||
<tr data-status="{{ booking.status }}" data-year="{{ booking.kurs_date[:4] if booking.kurs_date else '' }}">
|
||||
<td>
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}" class="text-success">
|
||||
{{ booking.number or booking.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ booking.kurs_title }}</td>
|
||||
<td>
|
||||
{% if booking.kurs_date %}
|
||||
{{ booking.kurs_date|date }}
|
||||
{% if booking.kurs_time %} - {{ booking.kurs_time }}{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {{ booking.status|status_badge }}">
|
||||
{{ booking.status|status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ booking.price|format_price }} EUR</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('bookings.detail', booking_id=booking.id) }}"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-4 d-none" id="noResults">
|
||||
<i class="bi bi-search display-4 text-muted"></i>
|
||||
<p class="mt-2 text-muted">Keine Buchungen fuer diesen Filter gefunden.</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x display-1 text-muted"></i>
|
||||
<h3 class="mt-3 text-muted">Keine Buchungen vorhanden</h3>
|
||||
<p class="text-muted">
|
||||
Sobald Sie einen Kurs buchen, erscheint er hier.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const yearFilter = document.getElementById('yearFilter');
|
||||
const resetBtn = document.getElementById('resetFilters');
|
||||
const table = document.getElementById('bookingsTable');
|
||||
const noResults = document.getElementById('noResults');
|
||||
|
||||
if (!table) return;
|
||||
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
function applyFilters() {
|
||||
const status = statusFilter.value;
|
||||
const year = yearFilter.value;
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const rowStatus = row.dataset.status || '';
|
||||
const rowYear = row.dataset.year || '';
|
||||
|
||||
const statusMatch = !status || rowStatus === status;
|
||||
const yearMatch = !year || rowYear === year;
|
||||
|
||||
if (statusMatch && yearMatch) {
|
||||
row.classList.remove('d-none');
|
||||
visibleCount++;
|
||||
} else {
|
||||
row.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide no results message
|
||||
if (noResults) {
|
||||
if (visibleCount === 0 && rows.length > 0) {
|
||||
noResults.classList.remove('d-none');
|
||||
table.classList.add('d-none');
|
||||
} else {
|
||||
noResults.classList.add('d-none');
|
||||
table.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusFilter.addEventListener('change', applyFilters);
|
||||
yearFilter.addEventListener('change', applyFilters);
|
||||
|
||||
resetBtn.addEventListener('click', function() {
|
||||
statusFilter.value = '';
|
||||
yearFilter.value = '';
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
74
customer_portal/web/templates/components/sidebar.html
Executable file
74
customer_portal/web/templates/components/sidebar.html
Executable file
@@ -0,0 +1,74 @@
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="sidebar-logo">
|
||||
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Webwerkstatt" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<span class="logo-fallback" style="display: none;">
|
||||
<i class="bi bi-calendar-event"></i>
|
||||
<span>Kundenportal</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-house"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'profile' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('profile.show') }}">
|
||||
<i class="bi bi-person"></i>
|
||||
<span>Mein Profil</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'bookings' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('bookings.list_bookings') }}">
|
||||
<i class="bi bi-calendar-check"></i>
|
||||
<span>Buchungen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'invoices' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('invoices.list_invoices') }}">
|
||||
<i class="bi bi-receipt"></i>
|
||||
<span>Rechnungen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'videos' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('videos.list_videos') }}">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<span>Videos</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if g.customer and g.customer.is_admin %}
|
||||
<li class="nav-item mt-3">
|
||||
<a class="nav-link {% if request.endpoint and 'admin' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('admin.index') }}">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<span>{{ g.customer.display_name if g.customer else 'Gast' }}</span>
|
||||
</div>
|
||||
<a class="nav-link logout-link" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
<span>Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Toggle Button -->
|
||||
<button class="sidebar-toggle d-lg-none" type="button" onclick="document.querySelector('.sidebar').classList.toggle('show')">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
84
customer_portal/web/templates/dashboard.html
Executable file
84
customer_portal/web/templates/dashboard.html
Executable file
@@ -0,0 +1,84 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block topbar_title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-welcome">
|
||||
<h1>Willkommen zurueck, {{ customer.display_name.split()[0] if customer.display_name else 'Kunde' }}!</h1>
|
||||
<p>Verwalten Sie Ihre Buchungen, Rechnungen und Video-Kurse.</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Rechnungen & Belege -->
|
||||
<div class="dashboard-card card-invoices">
|
||||
<div class="card-overlay"></div>
|
||||
<div class="card-content">
|
||||
<i class="bi bi-receipt card-icon"></i>
|
||||
<h3 class="card-title">Rechnungen & Belege</h3>
|
||||
<p class="card-description">PDF herunterladen, Zahlungsstatus pruefen</p>
|
||||
<div class="card-links">
|
||||
<a href="{{ url_for('invoices.list_invoices') }}" class="card-link">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i> PDF-Rechnungen herunterladen
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.list_invoices') }}" class="card-link">
|
||||
<i class="bi bi-clock-history me-1"></i> Vergangene Transaktionen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buchungen verwalten -->
|
||||
<div class="dashboard-card card-bookings">
|
||||
<div class="card-overlay"></div>
|
||||
<div class="card-content">
|
||||
<i class="bi bi-calendar-check card-icon"></i>
|
||||
<h3 class="card-title">Buchungen verwalten</h3>
|
||||
<p class="card-description">Stornierung online beantragen</p>
|
||||
<div class="card-links">
|
||||
<a href="{{ url_for('bookings.list_bookings') }}" class="card-link">
|
||||
<i class="bi bi-x-circle me-1"></i> Stornierung beantragen
|
||||
</a>
|
||||
<a href="{{ url_for('bookings.list_bookings') }}" class="card-link">
|
||||
<i class="bi bi-pencil me-1"></i> Buchungen bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video-Bibliothek -->
|
||||
<div class="dashboard-card card-videos">
|
||||
<div class="card-overlay"></div>
|
||||
<div class="card-content">
|
||||
<i class="bi bi-play-circle card-icon"></i>
|
||||
<h3 class="card-title">Video-Bibliothek</h3>
|
||||
<p class="card-description">Zugang zu Ihren gekauften Video-Kursen</p>
|
||||
<div class="card-links">
|
||||
<a href="{{ url_for('videos.list_videos') }}" class="card-link">
|
||||
<i class="bi bi-collection-play me-1"></i> Videos ansehen
|
||||
</a>
|
||||
<a href="{{ url_for('videos.list_videos') }}" class="card-link">
|
||||
<i class="bi bi-book me-1"></i> Tutorials & Aufzeichnungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Einstellungen -->
|
||||
<div class="dashboard-card card-settings">
|
||||
<div class="card-overlay"></div>
|
||||
<div class="card-content">
|
||||
<i class="bi bi-gear card-icon"></i>
|
||||
<h3 class="card-title">Einstellungen & Profil</h3>
|
||||
<p class="card-description">E-Mail-Benachrichtigungen, Kontodaten</p>
|
||||
<div class="card-links">
|
||||
<a href="{{ url_for('profile.settings') }}" class="card-link">
|
||||
<i class="bi bi-bell me-1"></i> E-Mail-Benachrichtigungen
|
||||
</a>
|
||||
<a href="{{ url_for('profile.show') }}" class="card-link">
|
||||
<i class="bi bi-person me-1"></i> Profil bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
customer_portal/web/templates/emails/otp.html
Executable file
52
customer_portal/web/templates/emails/otp.html
Executable file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; background-color: #1a1d21; color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 500px; margin: 0 auto; background-color: #212529; border-radius: 8px; padding: 30px;">
|
||||
<h1 style="color: #198754; margin-bottom: 20px;">
|
||||
{% if purpose == 'login' %}
|
||||
Ihr Login-Code
|
||||
{% elif purpose == 'register' %}
|
||||
Willkommen!
|
||||
{% else %}
|
||||
Ihr Sicherheitscode
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<p style="color: #adb5bd; margin-bottom: 20px;">
|
||||
{% if purpose == 'login' %}
|
||||
Verwenden Sie den folgenden Code, um sich anzumelden:
|
||||
{% elif purpose == 'register' %}
|
||||
Verwenden Sie den folgenden Code, um Ihre Registrierung abzuschliessen:
|
||||
{% else %}
|
||||
Hier ist Ihr Sicherheitscode:
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div style="background-color: #343a40; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #198754;">
|
||||
{{ code }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style="color: #6c757d; font-size: 14px; margin-bottom: 20px;">
|
||||
Dieser Code ist <strong>10 Minuten</strong> gueltig und kann nur einmal verwendet werden.
|
||||
</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #343a40; margin: 30px 0;">
|
||||
|
||||
<p style="color: #6c757d; font-size: 12px; margin: 0;">
|
||||
Falls Sie diesen Code nicht angefordert haben, koennen Sie diese E-Mail ignorieren.
|
||||
Ihr Konto bleibt sicher.
|
||||
</p>
|
||||
|
||||
<p style="color: #6c757d; font-size: 12px; margin-top: 20px;">
|
||||
Mit freundlichen Gruessen,<br>
|
||||
<strong>Webwerkstatt</strong>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
87
customer_portal/web/templates/emails/welcome.html
Executable file
87
customer_portal/web/templates/emails/welcome.html
Executable file
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Willkommen im Kundenportal</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #1a1d21; color: #f8f9fa;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #212529; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; color: #198754; font-size: 24px;">
|
||||
Willkommen, {{ name }}!
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #2d3238;">
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.5;">
|
||||
Ihr Kundenkonto wurde erfolgreich erstellt.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; font-size: 16px; line-height: 1.5;">
|
||||
Im Kundenportal koennen Sie:
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #3d4248;">
|
||||
<span style="color: #198754; margin-right: 10px;">✓</span>
|
||||
Ihre Buchungen einsehen
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #3d4248;">
|
||||
<span style="color: #198754; margin-right: 10px;">✓</span>
|
||||
Rechnungen herunterladen
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; border-bottom: 1px solid #3d4248;">
|
||||
<span style="color: #198754; margin-right: 10px;">✓</span>
|
||||
Videos ansehen
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0;">
|
||||
<span style="color: #198754; margin-right: 10px;">✓</span>
|
||||
Stornierungen beantragen
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" style="width: 100%;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ portal_url }}"
|
||||
style="display: inline-block; padding: 16px 32px; background-color: #198754; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold;">
|
||||
Zum Kundenportal
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #212529; border-radius: 0 0 8px 8px; text-align: center;">
|
||||
<p style="margin: 0; color: #6c757d; font-size: 12px;">
|
||||
© {{ year }} Webwerkstatt
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
141
customer_portal/web/templates/invoices/list.html
Executable file
141
customer_portal/web/templates/invoices/list.html
Executable file
@@ -0,0 +1,141 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Meine Rechnungen{% endblock %}
|
||||
{% block topbar_title %}Rechnungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-receipt"></i>Meine Rechnungen</h1>
|
||||
<p>PDF herunterladen und Zahlungsstatus pruefen</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('invoices.list_invoices') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||
<select class="form-select bg-dark text-light border-secondary" id="statusFilter" name="status">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Bezahlt</option>
|
||||
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>Offen</option>
|
||||
<option value="overdue" {% if status_filter == 'overdue' %}selected{% endif %}>Ueberfaellig</option>
|
||||
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>Entwurf</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="yearFilter" class="form-label small text-muted">Jahr</label>
|
||||
<select class="form-select bg-dark text-light border-secondary" id="yearFilter" name="year">
|
||||
<option value="">Alle Jahre</option>
|
||||
{% for year in years %}
|
||||
<option value="{{ year }}" {% if year_filter == year %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end gap-2">
|
||||
<button type="submit" class="btn btn-outline-info">
|
||||
<i class="bi bi-funnel me-1"></i>Filtern
|
||||
</button>
|
||||
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>Zuruecksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoices %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover" id="invoicesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rechnungsnr.</th>
|
||||
<th>Datum</th>
|
||||
<th>Kurs/Veranstaltung</th>
|
||||
<th>Betrag</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-info">{{ invoice.number or invoice.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ invoice.created_at|date }}
|
||||
</td>
|
||||
<td>{{ invoice.kurs_title or '-' }}</td>
|
||||
<td>{{ invoice.amount|format_price }} EUR</td>
|
||||
<td>
|
||||
<span class="badge {{ invoice.status|invoice_status_badge }}">
|
||||
{{ invoice.status|invoice_status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('invoices.download_pdf', invoice_id=invoice.id) }}"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
title="PDF herunterladen">
|
||||
<i class="bi bi-file-pdf"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-muted">Angezeigte Rechnungen:</span>
|
||||
<span>{{ invoices|length }}</span>
|
||||
</div>
|
||||
{% set total = invoices|sum(attribute='amount') %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-muted">Gesamtsumme:</span>
|
||||
<span class="text-info">{{ total|format_price }} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-receipt display-1 text-muted"></i>
|
||||
{% if status_filter or year_filter %}
|
||||
<h3 class="mt-3 text-muted">Keine Rechnungen fuer diesen Filter gefunden</h3>
|
||||
<p class="text-muted">
|
||||
Versuchen Sie einen anderen Filter oder setzen Sie die Filter zurueck.
|
||||
</p>
|
||||
<a href="{{ url_for('invoices.list_invoices') }}" class="btn btn-outline-secondary mt-2">
|
||||
<i class="bi bi-x-circle me-1"></i>Filter zuruecksetzen
|
||||
</a>
|
||||
{% else %}
|
||||
<h3 class="mt-3 text-muted">Keine Rechnungen vorhanden</h3>
|
||||
<p class="text-muted">
|
||||
Sobald Sie eine Buchung abschliessen, erscheint die zugehoerige Rechnung hier.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-dark border-secondary mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="bi bi-info-circle me-2"></i>Hinweis
|
||||
</h6>
|
||||
<p class="card-text text-muted small mb-0">
|
||||
Rechnungen werden automatisch nach Buchungsbestaetigung erstellt.
|
||||
Bei Fragen zu einer Rechnung kontaktieren Sie uns bitte direkt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
226
customer_portal/web/templates/profile/edit.html
Executable file
226
customer_portal/web/templates/profile/edit.html
Executable file
@@ -0,0 +1,226 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Profil bearbeiten{% endblock %}
|
||||
{% block topbar_title %}Profil bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Sprint 12: All customer data comes from custom_fields #}
|
||||
{% set addr = customer.display_address %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-pencil"></i>Profil bearbeiten</h1>
|
||||
<p>Aktualisieren Sie Ihre persoenlichen Daten</p>
|
||||
</div>
|
||||
|
||||
{% set sections = field_config.sections %}
|
||||
{% set fields = field_config.profile_fields %}
|
||||
|
||||
<form method="POST" action="{{ url_for('profile.edit') }}">
|
||||
<div class="row g-4">
|
||||
<!-- Kontaktdaten -->
|
||||
{% if sections.contact.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
{{ sections.contact.label }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if fields.name.visible %}
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">{{ fields.name.label }}</label>
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="name" name="name" value="{{ customer.display_name }}" disabled>
|
||||
<small class="text-muted">Name kann nicht geaendert werden</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if fields.email.visible %}
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">{{ fields.email.label }}</label>
|
||||
<input type="email" class="form-control bg-dark text-light border-secondary"
|
||||
id="email" name="email" value="{{ customer.email }}" disabled>
|
||||
<small class="text-muted">E-Mail kann nicht geaendert werden</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if fields.phone.visible and fields.phone.editable %}
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">{{ fields.phone.label }}</label>
|
||||
<input type="tel" class="form-control bg-dark text-light border-secondary"
|
||||
id="phone" name="phone" value="{{ customer.display_phone }}"
|
||||
placeholder="+43 ...">
|
||||
</div>
|
||||
{% elif fields.phone.visible %}
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">{{ fields.phone.label }}</label>
|
||||
<input type="tel" class="form-control bg-dark text-light border-secondary"
|
||||
id="phone" value="{{ customer.display_phone }}" disabled>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Adresse -->
|
||||
{% if sections.address.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-house me-2"></i>
|
||||
{{ sections.address.label }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if fields.address_street.visible and fields.address_street.editable %}
|
||||
<div class="mb-3">
|
||||
<label for="address_street" class="form-label">{{ fields.address_street.label }}</label>
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_street" name="address_street"
|
||||
value="{{ addr.street }}">
|
||||
</div>
|
||||
{% elif fields.address_street.visible %}
|
||||
<div class="mb-3">
|
||||
<label for="address_street" class="form-label">{{ fields.address_street.label }}</label>
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_street" value="{{ addr.street }}" disabled>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{% if fields.address_zip.visible %}
|
||||
<div class="col-4 mb-3">
|
||||
<label for="address_zip" class="form-label">{{ fields.address_zip.label }}</label>
|
||||
{% if fields.address_zip.editable %}
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_zip" name="address_zip"
|
||||
value="{{ addr.zip }}">
|
||||
{% else %}
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_zip" value="{{ addr.zip }}" disabled>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fields.address_city.visible %}
|
||||
<div class="col-8 mb-3">
|
||||
<label for="address_city" class="form-label">{{ fields.address_city.label }}</label>
|
||||
{% if fields.address_city.editable %}
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_city" name="address_city"
|
||||
value="{{ addr.city }}">
|
||||
{% else %}
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="address_city" value="{{ addr.city }}" disabled>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if field_config.custom_fields_visible and schema.custom_fields %}
|
||||
<!-- Dynamic Custom Fields from WordPress -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-sliders me-2"></i>
|
||||
Zusaetzliche Angaben
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for field in schema.custom_fields %}
|
||||
<div class="col-md-6 mb-3">
|
||||
{% set field_name = field.name %}
|
||||
{% set field_value = custom_fields.get(field_name, '') %}
|
||||
{% set field_type = field.type|default('text') %}
|
||||
{% set field_label = field.label|default(field_name) %}
|
||||
{% set is_required = field.required|default(false) %}
|
||||
|
||||
<label for="custom_{{ field_name }}" class="form-label">
|
||||
{{ field_label }}
|
||||
{% if is_required %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
|
||||
{% if field_type == 'textarea' %}
|
||||
<textarea class="form-control bg-dark text-light border-secondary"
|
||||
id="custom_{{ field_name }}"
|
||||
name="custom_{{ field_name }}"
|
||||
rows="3"
|
||||
{% if is_required %}required{% endif %}>{{ field_value }}</textarea>
|
||||
|
||||
{% elif field_type == 'select' %}
|
||||
<select class="form-select bg-dark text-light border-secondary"
|
||||
id="custom_{{ field_name }}"
|
||||
name="custom_{{ field_name }}"
|
||||
{% if is_required %}required{% endif %}>
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{% for option in field.options|default([]) %}
|
||||
{% if option is mapping %}
|
||||
<option value="{{ option.value }}"
|
||||
{% if option.value == field_value %}selected{% endif %}>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
{% else %}
|
||||
<option value="{{ option }}"
|
||||
{% if option == field_value %}selected{% endif %}>
|
||||
{{ option }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{% elif field_type == 'checkbox' %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="custom_{{ field_name }}"
|
||||
name="custom_{{ field_name }}"
|
||||
{% if field_value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="custom_{{ field_name }}">
|
||||
{{ field.description|default('Ja') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{% elif field_type == 'date' %}
|
||||
<input type="date" class="form-control bg-dark text-light border-secondary"
|
||||
id="custom_{{ field_name }}"
|
||||
name="custom_{{ field_name }}"
|
||||
value="{{ field_value }}"
|
||||
{% if is_required %}required{% endif %}>
|
||||
|
||||
{% else %}
|
||||
<input type="text" class="form-control bg-dark text-light border-secondary"
|
||||
id="custom_{{ field_name }}"
|
||||
name="custom_{{ field_name }}"
|
||||
value="{{ field_value }}"
|
||||
{% if field.placeholder %}placeholder="{{ field.placeholder }}"{% endif %}
|
||||
{% if is_required %}required{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
{% if field.description and field_type != 'checkbox' %}
|
||||
<small class="text-muted">{{ field.description }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Speichern
|
||||
</button>
|
||||
<a href="{{ url_for('profile.show') }}" class="btn btn-outline-secondary ms-2">
|
||||
Abbrechen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
167
customer_portal/web/templates/profile/settings.html
Executable file
167
customer_portal/web/templates/profile/settings.html
Executable file
@@ -0,0 +1,167 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block topbar_title %}Einstellungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-gear"></i>Einstellungen</h1>
|
||||
<p>Verwalten Sie Ihre Benachrichtigungen und Kommunikationspraeferenzen</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('profile.settings') }}">
|
||||
<div class="row g-4">
|
||||
<!-- E-Mail Benachrichtigungen -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
E-Mail Benachrichtigungen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_notifications" name="email_notifications"
|
||||
{% if customer.email_notifications %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_notifications">
|
||||
<strong>Buchungsbestaetigungen</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
E-Mails bei neuen Buchungen, Bestaetigungen und Stornierungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_reminders" name="email_reminders"
|
||||
{% if customer.email_reminders %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_reminders">
|
||||
<strong>Kurserinnerungen</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Erinnerungen vor Ihren gebuchten Terminen (z.B. 1 Tag vorher).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_invoices" name="email_invoices"
|
||||
{% if customer.email_invoices %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_invoices">
|
||||
<strong>Rechnungen</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Benachrichtigungen bei neuen Rechnungen und Zahlungsbestaetigungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-3">
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="email_marketing" name="email_marketing"
|
||||
{% if customer.email_marketing %}checked{% endif %}>
|
||||
<label class="form-check-label" for="email_marketing">
|
||||
<strong>Newsletter & Angebote</strong>
|
||||
</label>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Gelegentliche Neuigkeiten, Kursangebote und Aktionen. Sie koennen sich jederzeit abmelden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheit -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
Anmeldung & Sicherheit
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="bg-success bg-opacity-25 rounded p-2 me-3">
|
||||
<i class="bi bi-key text-success fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Passwortfreie Anmeldung</strong>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Ihr Konto verwendet eine sichere, passwortfreie Anmeldung.
|
||||
Bei jedem Login senden wir einen 6-stelligen Code an Ihre E-Mail-Adresse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-3">
|
||||
|
||||
<h6 class="text-muted mb-3">
|
||||
<i class="bi bi-question-circle me-1"></i>
|
||||
Warum kein Passwort?
|
||||
</h6>
|
||||
|
||||
<div class="small">
|
||||
<div class="d-flex mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2 mt-1"></i>
|
||||
<span><strong>Sicherer:</strong> Keine Passwoerter, die gestohlen werden koennen</span>
|
||||
</div>
|
||||
<div class="d-flex mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2 mt-1"></i>
|
||||
<span><strong>Einfacher:</strong> Kein Passwort merken oder zuruecksetzen</span>
|
||||
</div>
|
||||
<div class="d-flex mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2 mt-1"></i>
|
||||
<span><strong>Modern:</strong> Wie bei grossen Diensten (Slack, Medium, etc.)</span>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<i class="bi bi-check-circle text-success me-2 mt-1"></i>
|
||||
<span><strong>Aktuell:</strong> Codes sind nur 10 Minuten gueltig</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konto-Info -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-person-badge me-2"></i>
|
||||
Konto-Informationen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0 small">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 140px;">E-Mail</td>
|
||||
<td>{{ customer.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Registriert</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y') if customer.created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Login</td>
|
||||
<td>{{ customer.last_login_at.strftime('%d.%m.%Y um %H:%M') if customer.last_login_at else '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
Einstellungen speichern
|
||||
</button>
|
||||
<a href="{{ url_for('profile.show') }}" class="btn btn-outline-secondary ms-2">
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
Zurueck zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
216
customer_portal/web/templates/profile/show.html
Executable file
216
customer_portal/web/templates/profile/show.html
Executable file
@@ -0,0 +1,216 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Mein Profil{% endblock %}
|
||||
{% block topbar_title %}Profil{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Sprint 12: All customer data comes from custom_fields #}
|
||||
{% set addr = customer.display_address %}
|
||||
<div class="page-header d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h1><i class="bi bi-person-circle"></i>Mein Profil</h1>
|
||||
<p>Ihre persoenlichen Daten und Einstellungen</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('profile.settings') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-gear me-1"></i>
|
||||
Einstellungen
|
||||
</a>
|
||||
<a href="{{ url_for('profile.edit') }}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil me-1"></i>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% set sections = field_config.sections %}
|
||||
{% set fields = field_config.profile_fields %}
|
||||
|
||||
<!-- Kontaktdaten -->
|
||||
{% if sections.contact.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
{{ sections.contact.label }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
{% if fields.name.visible %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 150px;">{{ fields.name.label }}</td>
|
||||
<td>{{ customer.display_name or '-' }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if fields.email.visible %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ fields.email.label }}</td>
|
||||
<td>{{ customer.email }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if fields.phone.visible %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ fields.phone.label }}</td>
|
||||
<td>{{ customer.display_phone or '-' }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Adresse -->
|
||||
{% if sections.address.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-house me-2"></i>
|
||||
{{ sections.address.label }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
{% if fields.address_street.visible %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 150px;">{{ fields.address_street.label }}</td>
|
||||
<td>{{ addr.street or '-' }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if fields.address_zip.visible %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ fields.address_zip.label }}</td>
|
||||
<td>{{ addr.zip or '-' }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if fields.address_city.visible %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ fields.address_city.label }}</td>
|
||||
<td>{{ addr.city or '-' }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if field_config.custom_fields_visible and schema.custom_fields %}
|
||||
<!-- Dynamic Custom Fields from WordPress -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-sliders me-2"></i>
|
||||
Zusaetzliche Angaben
|
||||
</span>
|
||||
{% if field_config.sync_button_visible %}
|
||||
<form action="{{ url_for('profile.sync_from_wordpress') }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info" title="Aus letzter Buchung synchronisieren">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>
|
||||
Sync
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
{% for field in schema.custom_fields %}
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 200px;">{{ field.label or field.name }}</td>
|
||||
<td>
|
||||
{% set value = custom_fields.get(field.name, '') %}
|
||||
{% if field.type == 'checkbox' %}
|
||||
{% if value %}
|
||||
<i class="bi bi-check-circle text-success"></i> Ja
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i> Nein
|
||||
{% endif %}
|
||||
{% elif field.type == 'select' and field.options %}
|
||||
{% set option_labels = {} %}
|
||||
{% for opt in field.options %}
|
||||
{% if opt is mapping %}
|
||||
{% set _ = option_labels.update({opt.value: opt.label}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ option_labels.get(value, value) or '-' }}
|
||||
{% else %}
|
||||
{{ value or '-' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- E-Mail Einstellungen + Account Info -->
|
||||
<div class="row mt-4 g-4">
|
||||
{% if sections.email_settings.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
{{ sections.email_settings.label }}
|
||||
</span>
|
||||
<a href="{{ url_for('profile.settings') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 180px;">Buchungs-E-Mails</td>
|
||||
<td>
|
||||
{% if customer.email_notifications %}
|
||||
<i class="bi bi-check-circle text-success"></i> Aktiv
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i> Deaktiviert
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Erinnerungen</td>
|
||||
<td>
|
||||
{% if customer.email_reminders %}
|
||||
<i class="bi bi-check-circle text-success"></i> Aktiv
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-muted"></i> Deaktiviert
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sections.account_info.visible %}
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
{{ sections.account_info.label }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width: 150px;">Registriert am</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y') if customer.created_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Letzter Login</td>
|
||||
<td>{{ customer.last_login_at.strftime('%d.%m.%Y %H:%M') if customer.last_login_at else '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
173
customer_portal/web/templates/videos/list.html
Executable file
173
customer_portal/web/templates/videos/list.html
Executable file
@@ -0,0 +1,173 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}Meine Videos{% endblock %}
|
||||
{% block topbar_title %}Videos{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-play-circle"></i>Video-Bibliothek</h1>
|
||||
<p>
|
||||
{% if total_videos > 0 %}
|
||||
{{ total_videos }} Video{% if total_videos != 1 %}s{% endif %} verfuegbar
|
||||
{% else %}
|
||||
Keine Videos verfuegbar
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not courses and not bundles %}
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-film display-1 text-muted mb-3 d-block"></i>
|
||||
<h5>Keine Videos verfuegbar</h5>
|
||||
<p class="text-muted mb-0">
|
||||
Sie haben noch keine Video-Kurse gebucht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# Video-Pakete (Bundles) #}
|
||||
{% if bundles %}
|
||||
{% for bundle_name, bundle_data in bundles.items() %}
|
||||
<div class="card bg-dark border-primary mb-4">
|
||||
<div class="card-header border-primary d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-collection-play me-2"></i>
|
||||
{{ bundle_name }}
|
||||
<span class="badge bg-primary ms-2">Paket</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for kurs_title, course_data in bundle_data.courses.items() %}
|
||||
<div class="mb-4">
|
||||
<h6 class="text-secondary mb-3 d-flex align-items-center">
|
||||
<i class="bi bi-folder me-2"></i>
|
||||
{{ kurs_title }}
|
||||
<span class="badge bg-secondary ms-2">
|
||||
{{ course_data.videos|length }} Video{% if course_data.videos|length != 1 %}s{% endif %}
|
||||
</span>
|
||||
</h6>
|
||||
<div class="row g-3">
|
||||
{% for video in course_data.videos %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
||||
<div class="video-card h-100">
|
||||
<a href="{{ url_for('videos.watch', video_id=video.id) }}"
|
||||
class="text-decoration-none">
|
||||
<div class="video-thumbnail position-relative mb-2">
|
||||
{% if video.thumbnail %}
|
||||
<img src="{{ video.thumbnail }}"
|
||||
class="img-fluid rounded"
|
||||
alt="{{ video.title }}"
|
||||
style="aspect-ratio: 16/9; object-fit: cover; width: 100%;">
|
||||
{% else %}
|
||||
<div class="video-placeholder rounded bg-secondary d-flex align-items-center justify-content-center"
|
||||
style="aspect-ratio: 16/9;">
|
||||
<i class="bi bi-play-circle display-4 text-light"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="play-overlay position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center rounded"
|
||||
style="background: rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.2s;">
|
||||
<i class="bi bi-play-circle-fill display-3 text-white"></i>
|
||||
</div>
|
||||
{% if video.duration %}
|
||||
<span class="video-duration badge bg-dark position-absolute bottom-0 end-0 m-2">
|
||||
{{ video.duration }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h6 class="text-light mb-1">{{ video.title }}</h6>
|
||||
{% if video.description %}
|
||||
<p class="text-muted small mb-0" style="line-height: 1.3;">
|
||||
{{ video.description[:80] }}{% if video.description|length > 80 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Einzelne Video-Kurse #}
|
||||
{% for kurs_title, course_data in courses.items() %}
|
||||
<div class="card bg-dark border-secondary mb-4">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-folder me-2"></i>
|
||||
{{ kurs_title }}
|
||||
</h5>
|
||||
<span class="badge bg-secondary">
|
||||
{{ course_data.videos|length }} Video{% if course_data.videos|length != 1 %}s{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
{% for video in course_data.videos %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
||||
<div class="video-card h-100">
|
||||
<a href="{{ url_for('videos.watch', video_id=video.id) }}"
|
||||
class="text-decoration-none">
|
||||
<div class="video-thumbnail position-relative mb-2">
|
||||
{% if video.thumbnail %}
|
||||
<img src="{{ video.thumbnail }}"
|
||||
class="img-fluid rounded"
|
||||
alt="{{ video.title }}"
|
||||
style="aspect-ratio: 16/9; object-fit: cover; width: 100%;">
|
||||
{% else %}
|
||||
<div class="video-placeholder rounded bg-secondary d-flex align-items-center justify-content-center"
|
||||
style="aspect-ratio: 16/9;">
|
||||
<i class="bi bi-play-circle display-4 text-light"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Play overlay -->
|
||||
<div class="play-overlay position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center rounded"
|
||||
style="background: rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.2s;">
|
||||
<i class="bi bi-play-circle-fill display-3 text-white"></i>
|
||||
</div>
|
||||
|
||||
{% if video.duration %}
|
||||
<span class="video-duration badge bg-dark position-absolute bottom-0 end-0 m-2">
|
||||
{{ video.duration }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h6 class="text-light mb-1">{{ video.title }}</h6>
|
||||
{% if video.description %}
|
||||
<p class="text-muted small mb-0" style="line-height: 1.3;">
|
||||
{{ video.description[:80] }}{% if video.description|length > 80 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.video-card:hover .play-overlay {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.video-card:hover .video-thumbnail img,
|
||||
.video-card:hover .video-placeholder {
|
||||
transform: scale(1.02);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.video-thumbnail {
|
||||
overflow: hidden;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
249
customer_portal/web/templates/videos/watch.html
Executable file
249
customer_portal/web/templates/videos/watch.html
Executable file
@@ -0,0 +1,249 @@
|
||||
{% extends "base_sidebar.html" %}
|
||||
{% block title %}{{ video.title }}{% endblock %}
|
||||
{% block topbar_title %}Video{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- Video.js CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/video.js@8.10.0/dist/video-js.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.video-js {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
.video-js .vjs-big-play-button {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
line-height: 78px;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.video-js .vjs-big-play-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.related-video-item {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.related-video-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-0">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ url_for('videos.list_videos') }}" class="text-decoration-none">
|
||||
<i class="bi bi-play-circle me-1"></i>Videos
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ video.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<!-- Video Player Column -->
|
||||
<div class="col-lg-8 col-xl-9 mb-4">
|
||||
<!-- Video Player -->
|
||||
<div class="video-player-wrapper rounded overflow-hidden mb-3">
|
||||
<video id="videoPlayer"
|
||||
class="video-js vjs-theme-city vjs-big-play-centered"
|
||||
controls
|
||||
preload="auto"
|
||||
data-video-id="{{ video.id }}"
|
||||
poster="{{ video.thumbnail if video.thumbnail else '' }}">
|
||||
<source src="{{ stream_url }}" type="application/x-mpegURL">
|
||||
<p class="vjs-no-js">
|
||||
Zum Ansehen dieses Videos aktivieren Sie bitte JavaScript
|
||||
oder verwenden Sie einen Browser mit HTML5-Video-Unterstuetzung.
|
||||
</p>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Video Info Card -->
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-2">{{ video.title }}</h4>
|
||||
<div class="d-flex flex-wrap gap-3 text-muted mb-3">
|
||||
{% if video.kurs_title %}
|
||||
<span>
|
||||
<i class="bi bi-folder me-1"></i>
|
||||
{{ video.kurs_title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if video.duration %}
|
||||
<span>
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
{{ video.duration }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if video.description %}
|
||||
<hr class="border-secondary">
|
||||
<p class="mb-0">{{ video.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar - Related Videos -->
|
||||
<div class="col-lg-4 col-xl-3">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-collection-play me-2"></i>
|
||||
Weitere Videos
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if related_videos %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for related in related_videos %}
|
||||
<a href="{{ url_for('videos.watch', video_id=related.id) }}"
|
||||
class="list-group-item list-group-item-action bg-dark text-light border-secondary related-video-item">
|
||||
<div class="d-flex">
|
||||
<!-- Thumbnail -->
|
||||
<div class="flex-shrink-0 me-3" style="width: 100px;">
|
||||
{% if related.thumbnail %}
|
||||
<img src="{{ related.thumbnail }}"
|
||||
class="rounded"
|
||||
alt="{{ related.title }}"
|
||||
style="width: 100%; aspect-ratio: 16/9; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="bg-secondary rounded d-flex align-items-center justify-content-center"
|
||||
style="width: 100%; aspect-ratio: 16/9;">
|
||||
<i class="bi bi-play-circle text-light"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
<h6 class="mb-1 text-truncate">{{ related.title }}</h6>
|
||||
{% if related.duration %}
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock me-1"></i>{{ related.duration }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-3 text-center text-muted">
|
||||
<small>Keine weiteren Videos in diesem Kurs</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if related_videos %}
|
||||
<div class="card-footer border-secondary bg-dark">
|
||||
<a href="{{ url_for('videos.list_videos') }}" class="btn btn-outline-secondary btn-sm w-100">
|
||||
<i class="bi bi-grid me-1"></i>
|
||||
Alle Videos anzeigen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Video.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/video.js@8.10.0/dist/video.min.js"></script>
|
||||
<!-- Video.js Quality Selector -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/videojs-contrib-quality-levels@4.1.0/dist/videojs-contrib-quality-levels.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/videojs-http-source-selector@1.1.6/dist/videojs-http-source-selector.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const player = videojs('videoPlayer', {
|
||||
fluid: true,
|
||||
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||
html5: {
|
||||
vhs: {
|
||||
overrideNative: true,
|
||||
enableLowInitialPlaylist: true
|
||||
},
|
||||
nativeAudioTracks: false,
|
||||
nativeVideoTracks: false
|
||||
},
|
||||
controlBar: {
|
||||
children: [
|
||||
'playToggle',
|
||||
'volumePanel',
|
||||
'currentTimeDisplay',
|
||||
'timeDivider',
|
||||
'durationDisplay',
|
||||
'progressControl',
|
||||
'playbackRateMenuButton',
|
||||
'qualitySelector',
|
||||
'fullscreenToggle'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize quality selector if available
|
||||
if (typeof player.qualityLevels === 'function') {
|
||||
player.qualityLevels();
|
||||
if (typeof player.httpSourceSelector === 'function') {
|
||||
player.httpSourceSelector({ default: 'auto' });
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling
|
||||
player.on('error', function() {
|
||||
const error = player.error();
|
||||
console.error('Video player error:', error);
|
||||
});
|
||||
|
||||
// Remember playback position
|
||||
const videoId = document.getElementById('videoPlayer').dataset.videoId;
|
||||
const storageKey = 'video_position_' + videoId;
|
||||
|
||||
// Restore position on load
|
||||
player.on('loadedmetadata', function() {
|
||||
const savedPosition = localStorage.getItem(storageKey);
|
||||
if (savedPosition && parseFloat(savedPosition) > 0) {
|
||||
const position = parseFloat(savedPosition);
|
||||
// Only restore if not near the end
|
||||
if (position < player.duration() - 10) {
|
||||
player.currentTime(position);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save position periodically
|
||||
let lastSavedTime = 0;
|
||||
player.on('timeupdate', function() {
|
||||
const currentTime = player.currentTime();
|
||||
// Save every 5 seconds
|
||||
if (Math.abs(currentTime - lastSavedTime) > 5) {
|
||||
localStorage.setItem(storageKey, currentTime.toString());
|
||||
lastSavedTime = currentTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear saved position when video ends
|
||||
player.on('ended', function() {
|
||||
localStorage.removeItem(storageKey);
|
||||
});
|
||||
|
||||
// Auto-play on load (muted to comply with browser policies)
|
||||
player.ready(function() {
|
||||
// Unmute if user interacts
|
||||
document.addEventListener('click', function unmute() {
|
||||
player.muted(false);
|
||||
document.removeEventListener('click', unmute);
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user