Initial commit - Customer Portal for Coolify

This commit is contained in:
2025-12-17 10:08:34 +01:00
commit 9fca32567c
153 changed files with 16432 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Web application package."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
customer_portal/web/app.py Executable file
View 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
View 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")

View File

@@ -0,0 +1 @@
"""Route blueprints."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

502
customer_portal/web/routes/api.py Executable file
View 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})

View 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)

View 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)

View 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}"},
)

View 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,
)

View 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)

View 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,
)

View 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;
}
}

View 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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 &rarr; Einstellungen &rarr; 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 &rarr; Einstellungen &rarr; 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>&copy; {{ 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>

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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>

View 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;">&#10003;</span>
Ihre Buchungen einsehen
</td>
</tr>
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #3d4248;">
<span style="color: #198754; margin-right: 10px;">&#10003;</span>
Rechnungen herunterladen
</td>
</tr>
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #3d4248;">
<span style="color: #198754; margin-right: 10px;">&#10003;</span>
Videos ansehen
</td>
</tr>
<tr>
<td style="padding: 10px 0;">
<span style="color: #198754; margin-right: 10px;">&#10003;</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;">
&copy; {{ year }} Webwerkstatt
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}