Initial commit: Schmankerl v2 - Multi-tenant FastAPI Brotzeitportal

This commit is contained in:
2026-09-21 10:05:43 +02:00
commit 8200857754
11 changed files with 1294 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
venv/
__pycache__/
*.pyc
.env
*.swp
*.bak
*~
View File
+29
View File
@@ -0,0 +1,29 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DB_USER = os.getenv("DB_USER", "v2_usr_wops")
DB_PASS = os.getenv("DB_PASS", "Wops_Pass_2026_Secure!")
DB_HOST = os.getenv("DB_HOST", "db.waiswuaschd.de")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME", "v2_db_wops")
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
pool_pre_ping=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+233
View File
@@ -0,0 +1,233 @@
import os
from datetime import datetime
from typing import Optional
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from .database import get_db
from .models import Tenant, User, Product, Event, Order, OrderItem
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]:
"""
Ermittelt den Mandanten anhand des Host-Headers (Subdomain)
z.B. demo.dev.waiswuaschd.de -> slug='demo'
"""
host = request.headers.get("host", "").split(":")[0].lower()
parts = host.split(".")
slug = "demo"
if len(parts) >= 4 and parts[-3:] == ["waiswuaschd", "de", ""]:
slug = parts[0]
elif len(parts) >= 3 and parts[1] == "dev" and parts[2] == "waiswuaschd":
slug = parts[0]
elif parts and parts[0] not in ["localhost", "127", "dev"]:
slug = parts[0]
tenant = db.query(Tenant).filter(Tenant.slug == slug, Tenant.is_active == True).first()
if not tenant:
tenant = db.query(Tenant).filter(Tenant.slug == "demo").first()
return tenant
@app.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
# Aktuelle offene Runde suchen
event = db.query(Event).filter(
Event.tenant_id == tenant.id,
Event.status == "open"
).order_by(Event.deadline.asc()).first()
# Alle aktiven Produkte des Mandanten
products = db.query(Product).filter(
Product.tenant_id == tenant.id,
Product.is_active == True
).order_by(Product.sort_order.asc(), Product.id.asc()).all()
# Bestehende Bestellungen für die Event-Runde zählen
order_count = 0
if event:
order_count = db.query(Order).filter(Order.event_id == event.id).count()
return templates.TemplateResponse(request, "index.html", context={
"tenant": tenant,
"event": event,
"products": products,
"order_count": order_count,
"now": datetime.now()
})
@app.post("/order")
async def place_order(
request: Request,
first_name: str = Form(...),
last_name: str = Form(...),
email: str = Form(...),
event_id: int = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
if not tenant:
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event or event.status != "open":
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist bereits geschlossen")
if event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Bestellfrist ist leider abgelaufen!")
# User ermitteln oder anlegen
clean_email = email.strip().lower()
user = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
if not user:
username = clean_email.split("@")[0]
user = User(
tenant_id=tenant.id,
email=clean_email,
username=username,
first_name=first_name.strip(),
last_name=last_name.strip(),
role="user"
)
db.add(user)
db.flush()
# Formulardaten auslesen (Mengen je Produkt)
form_data = await request.form()
order_items = []
total_price = Decimal("0.00")
products = db.query(Product).filter(Product.tenant_id == tenant.id, Product.is_active == True).all()
for prod in products:
qty_str = form_data.get(f"qty_{prod.id}", "0")
try:
qty = int(qty_str)
except ValueError:
qty = 0
if qty > 0:
item_total = prod.price * qty
total_price += item_total
order_items.append(OrderItem(
product_id=prod.id,
quantity=qty,
unit_price=prod.price
))
if not order_items:
raise HTTPException(status_code=400, detail="Bitte wähle mindestens ein Produkt aus!")
# Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen
existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
Order.user_id == user.id
).first()
if existing_order:
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
existing_order.total_price = total_price
for item in order_items:
item.order_id = existing_order.id
db.add(item)
db.commit()
order_id = existing_order.id
else:
new_order = Order(
tenant_id=tenant.id,
event_id=event.id,
user_id=user.id,
total_price=total_price,
is_paid=False
)
db.add(new_order)
db.flush()
for item in order_items:
item.order_id = new_order.id
db.add(item)
db.commit()
order_id = new_order.id
return RedirectResponse(url=f"/order/{order_id}/success", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/order/{order_id}/success", response_class=HTMLResponse)
def order_success(order_id: int, request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
return templates.TemplateResponse(request, "order_success.html", context={
"tenant": tenant,
"order": order
})
@app.get("/admin", response_class=HTMLResponse)
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
# Neuestes Event finden
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
orders = []
item_sums = {}
total_revenue = Decimal("0.00")
total_paid = Decimal("0.00")
if event:
orders = db.query(Order).filter(Order.event_id == event.id).order_by(Order.created_at.asc()).all()
for ord_obj in orders:
total_revenue += ord_obj.total_price
if ord_obj.is_paid:
total_paid += ord_obj.total_price
for it in ord_obj.items:
pname = it.product.name if it.product else "Artikel"
unit = it.product.unit if it.product else "Stk"
if pname not in item_sums:
item_sums[pname] = {"quantity": 0, "unit": unit}
item_sums[pname]["quantity"] += it.quantity
return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant,
"event": event,
"orders": orders,
"item_sums": item_sums,
"total_revenue": total_revenue,
"total_paid": total_paid
})
@app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.is_paid = not order.is_paid
db.commit()
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/health")
def health():
return {"status": "ok", "app": "Schmankerl"}
+97
View File
@@ -0,0 +1,97 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Numeric, ForeignKey, func
from sqlalchemy.orm import relationship
from .database import Base
class Tenant(Base):
__tablename__ = "tenants"
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(64), unique=True, index=True, nullable=False)
name = Column(String(150), nullable=False)
subdomain = Column(String(100), unique=True, nullable=False)
contact_email = Column(String(191), default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
products = relationship("Product", back_populates="tenant", cascade="all, delete-orphan")
events = relationship("Event", back_populates="tenant", cascade="all, delete-orphan")
orders = relationship("Order", back_populates="tenant", cascade="all, delete-orphan")
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
email = Column(String(191), nullable=False)
username = Column(String(100), nullable=False)
password_hash = Column(String(255), default="")
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
role = Column(String(20), default="user")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="users")
orders = relationship("Order", back_populates="user")
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
name = Column(String(150), nullable=False)
description = Column(String(255), default="")
price = Column(Numeric(10, 2), nullable=False, default=0.00)
unit = Column(String(50), default="Stück")
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
tenant = relationship("Tenant", back_populates="products")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
title = Column(String(150), nullable=False)
event_date = Column(Date, nullable=False)
deadline = Column(DateTime, nullable=False)
status = Column(String(20), default="open") # 'open', 'closed', 'completed'
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="events")
orders = relationship("Order", back_populates="event", cascade="all, delete-orphan")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False, default=0.00)
is_paid = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="orders")
event = relationship("Event", back_populates="orders")
user = relationship("User", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False)
quantity = Column(Integer, default=1, nullable=False)
unit_price = Column(Numeric(10, 2), nullable=False)
order = relationship("Order", back_populates="items")
product = relationship("Product")
+586
View File
@@ -0,0 +1,586 @@
:root {
--bg-primary: #0b0f19;
--bg-secondary: #131b2e;
--bg-card: rgba(26, 38, 64, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--accent-gold: #fbbf24;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--radius-lg: 16px;
--radius-md: 10px;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--bg-primary);
color: var(--text-main);
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 20px;
width: 100%;
}
.container.narrow {
max-width: 600px;
}
/* Navbar */
.navbar {
background: rgba(11, 15, 25, 0.9);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 50;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-main);
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.brand span {
color: var(--accent);
}
.tenant-badge {
background: rgba(56, 189, 248, 0.12);
color: var(--accent);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid rgba(56, 189, 248, 0.2);
}
.nav-links {
display: flex;
align-items: center;
gap: 20px;
}
.nav-links a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--text-main);
}
.nav-links a.nav-admin {
background: rgba(251, 191, 36, 0.12);
color: var(--accent-gold);
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid rgba(251, 191, 36, 0.25);
font-weight: 600;
}
.nav-links a.nav-admin:hover {
background: rgba(251, 191, 36, 0.2);
}
/* Main */
.main-content {
padding: 36px 0 60px;
flex: 1;
}
/* Banner */
.banner {
background: linear-gradient(135deg, rgba(26, 38, 64, 0.9) 0%, rgba(19, 27, 46, 0.9) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px;
margin-bottom: 32px;
backdrop-filter: blur(12px);
}
.banner-badge {
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--success);
margin-bottom: 8px;
}
.banner h1 {
font-size: 1.8rem;
margin-bottom: 8px;
}
.banner-sub {
color: var(--text-muted);
font-size: 1rem;
margin-bottom: 12px;
}
.banner-stats {
font-size: 0.9rem;
color: var(--accent);
}
/* Form Grid */
.form-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 32px;
}
@media (max-width: 800px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.products-section h2, .summary-section h2 {
font-size: 1.3rem;
margin-bottom: 20px;
}
.product-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(12px);
}
.product-info h3 {
font-size: 1.1rem;
margin-bottom: 4px;
}
.product-desc {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.product-price {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
}
.product-price small {
color: var(--text-muted);
font-weight: normal;
font-size: 0.8rem;
}
/* Quantity Control */
.quantity-control {
display: flex;
align-items: center;
background: rgba(11, 15, 25, 0.8);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.qty-btn {
background: transparent;
border: none;
color: var(--text-main);
width: 38px;
height: 38px;
font-size: 1.2rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.qty-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.qty-input {
width: 45px;
background: transparent;
border: none;
color: var(--text-main);
text-align: center;
font-size: 1rem;
font-weight: 700;
-moz-appearance: textfield;
}
.qty-input::-webkit-outer-spin-button,
.qty-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Summary Card */
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px;
position: sticky;
top: 90px;
backdrop-filter: blur(12px);
}
.input-group {
margin-bottom: 16px;
}
.input-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-muted);
}
.input-group input {
width: 100%;
padding: 10px 14px;
background: rgba(11, 15, 25, 0.8);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-main);
font-size: 0.95rem;
}
.input-group input:focus {
outline: none;
border-color: var(--accent);
}
.divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 20px 0;
}
.order-total-box {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.total-label {
font-size: 1.1rem;
font-weight: 600;
}
.total-amount {
font-size: 1.6rem;
font-weight: 800;
color: var(--accent-gold);
}
.btn-submit {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
color: #0b0f19;
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.btn-submit:hover {
transform: translateY(-1px);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4);
}
.form-hint {
display: block;
text-align: center;
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 12px;
}
/* Success Card */
.success-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 40px;
text-align: center;
backdrop-filter: blur(12px);
}
.success-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.success-sub {
color: var(--text-muted);
font-size: 1.1rem;
margin-bottom: 24px;
}
.order-receipt {
background: rgba(11, 15, 25, 0.8);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 24px;
margin: 24px 0;
text-align: left;
}
.receipt-list {
list-style: none;
margin: 16px 0;
}
.receipt-list li {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.95rem;
}
.receipt-total {
display: flex;
justify-content: space-between;
font-size: 1.2rem;
font-weight: 700;
padding-top: 12px;
color: var(--accent-gold);
}
.success-actions {
display: flex;
gap: 16px;
justify-content: center;
}
/* Admin Dashboard */
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
flex-wrap: wrap;
gap: 16px;
}
.admin-sub {
color: var(--text-muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
}
.stat-label {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.stat-value {
font-size: 1.6rem;
font-weight: 800;
}
.stat-value.success {
color: var(--success);
}
.stat-value.warning {
color: var(--warning);
}
.admin-columns {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 28px;
}
@media (max-width: 900px) {
.admin-columns {
grid-template-columns: 1fr;
}
}
.admin-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px;
}
.admin-card h2 {
font-size: 1.2rem;
margin-bottom: 6px;
}
.card-hint {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 20px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9rem;
text-align: left;
}
.data-table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.8rem;
}
.mini-items-list {
list-style: none;
font-size: 0.85rem;
color: var(--text-muted);
}
.badge-btn {
padding: 6px 12px;
border-radius: 9999px;
border: none;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s;
}
.badge-btn:hover {
transform: scale(1.05);
}
.badge-btn.paid {
background: rgba(34, 197, 94, 0.2);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.4);
}
.badge-btn.unpaid {
background: rgba(245, 158, 11, 0.2);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.4);
}
/* Buttons */
.btn-primary {
display: inline-block;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
color: #0b0f19;
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 700;
cursor: pointer;
border: none;
}
.btn-outline {
display: inline-block;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: var(--text-main);
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 600;
}
/* Footer */
.footer {
border-top: 1px solid var(--border-color);
padding: 24px 0;
background: var(--bg-secondary);
font-size: 0.85rem;
color: var(--text-muted);
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.footer-inner a {
color: var(--text-main);
text-decoration: none;
}
.empty-state {
text-align: center;
padding: 60px 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
}
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="admin-header">
<div>
<h1>👨‍🍳 Einkäufer-Cockpit</h1>
<p class="admin-sub">Mandant: <strong>{{ tenant.name }}</strong> | Aktuelle Brotzeitrunde</p>
</div>
<div class="admin-actions-top">
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
</div>
</div>
{% if event %}
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Gesamtsumme Einkauf</div>
<div class="stat-value">{{ "%.2f"|format(total_revenue) }} €</div>
</div>
<div class="stat-card">
<div class="stat-label">Bereits bezahlt</div>
<div class="stat-value success">{{ "%.2f"|format(total_paid) }} €</div>
</div>
<div class="stat-card">
<div class="stat-label">Noch offen</div>
<div class="stat-value warning">{{ "%.2f"|format(total_revenue - total_paid) }} €</div>
</div>
<div class="stat-card">
<div class="stat-label">Teilnehmer</div>
<div class="stat-value">{{ orders|length }} Personen</div>
</div>
</div>
<div class="admin-columns">
<!-- Aggregated Shopping List -->
<div class="admin-card">
<h2>🛒 Sammelliste für Metzger & Bäcker</h2>
<p class="card-hint">Exakt aufsummierte Gesamtmengen aller Kollegen:</p>
<div class="shopping-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Artikel</th>
<th style="text-align: right;">Gesamtmenge</th>
</tr>
</thead>
<tbody>
{% for name, data in item_sums.items() %}
<tr>
<td><strong>{{ name }}</strong></td>
<td style="text-align: right; font-size: 1.15rem; font-weight: 700; color: var(--accent);">
{{ data.quantity }} {{ data.unit }}
</td>
</tr>
{% else %}
<tr><td colspan="2" class="empty-cell">Noch keine Bestellungen eingegangen.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Individual Orders & Payment Checklist -->
<div class="admin-card">
<h2>👥 Wer hat bestellt? (Strichliste)</h2>
<p class="card-hint">Klicke auf den Status, um den Bezahlstatus zu ändern:</p>
<div class="orders-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Kollege</th>
<th>Bestellung</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong><br>
<small class="text-muted">{{ order.user.email }}</small>
</td>
<td>
<ul class="mini-items-list">
{% for item in order.items %}
<li>{{ item.quantity }}x {{ item.product.name }}</li>
{% endfor %}
</ul>
</td>
<td><strong>{{ "%.2f"|format(order.total_price) }} €</strong></td>
<td>
<form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0;">
<button type="submit" class="badge-btn {% if order.is_paid %}paid{% else %}unpaid{% endif %}">
{% if order.is_paid %}✓ Bezahlt{% else %}⏳ Offen{% endif %}
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="empty-cell">Noch keine Bestellungen vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<h2>Keine offene Bestellrunde gefunden</h2>
<p>Aktuell gibt es kein aktives Event.</p>
</div>
{% endif %}
</div>
<script>
function copyShoppingList() {
let text = "🥨 Einkaufsliste für {{ event.title if event else 'Brotzeit' }}:\n\n";
{% for name, data in item_sums.items() %}
text += "• {{ name }}: {{ data.quantity }} {{ data.unit }}\n";
{% endfor %}
text += "\nGesamtsumme: {{ '%.2f'|format(total_revenue) }} €";
navigator.clipboard.writeText(text).then(() => {
alert("Einkaufsliste in die Zwischenablage kopiert! Kann direkt in WhatsApp oder Slack eingefügt werden.");
});
}
</script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Schmankerl – Das moderne Brotzeit-Portal{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="https://new.dev.waiswuaschd.de/favicon.ico">
</head>
<body>
<header class="navbar">
<div class="container nav-content">
<a href="/" class="brand">
🥨 <span>Schmankerl</span> <small class="tenant-badge">{{ tenant.name if tenant else 'Portal' }}</small>
</a>
<nav class="nav-links">
<a href="/">Brotzeit bestellen</a>
<a href="/admin" class="nav-admin">👨‍🍳 Einkäufer-Cockpit</a>
<a href="https://new.dev.waiswuaschd.de" target="_blank" class="nav-home">Zur Plattform</a>
</nav>
</div>
</header>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="container footer-inner">
<p>&copy; 2026 Schmankerl by <a href="https://new.dev.waiswuaschd.de">Waiswuaschd.de</a></p>
<div class="footer-tenant-info">
Mandant: <strong>{{ tenant.slug if tenant else 'demo' }}</strong> ({{ tenant.subdomain if tenant else '' }})
</div>
</div>
</footer>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<!-- Header Banner -->
{% if event %}
<div class="banner">
<div class="banner-badge">🟢 Runde geöffnet</div>
<h1>{{ event.title }}</h1>
<p class="banner-sub">
Brotzeit-Tag: <strong>{{ event.event_date.strftime('%d.%m.%Y') }}</strong> &nbsp;|&nbsp;
Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong>
</p>
<div class="banner-stats">
<span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen eingegangen</span>
</div>
</div>
<!-- Order Form -->
<form action="/order" method="POST" class="order-form" id="orderForm">
<input type="hidden" name="event_id" value="{{ event.id }}">
<div class="form-grid">
<!-- Left: Products -->
<div class="products-section">
<h2>1. Brotzeit auswählen</h2>
<div class="product-list">
{% for product in products %}
<div class="product-card" data-price="{{ product.price }}" data-id="{{ product.id }}">
<div class="product-info">
<h3>{{ product.name }}</h3>
<p class="product-desc">{{ product.description }}</p>
<div class="product-price">{{ "%.2f"|format(product.price) }} € <small>/ {{ product.unit }}</small></div>
</div>
<div class="quantity-control">
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)">-</button>
<input type="number" id="qty_{{ product.id }}" name="qty_{{ product.id }}" value="0" min="0" max="50" class="qty-input" onchange="updateTotal()">
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, 1)">+</button>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Right: User Details & Summary -->
<div class="summary-section">
<div class="summary-card">
<h2>2. Deine Angaben</h2>
<div class="input-group">
<label for="first_name">Vorname</label>
<input type="text" id="first_name" name="first_name" required placeholder="z. B. Maxi">
</div>
<div class="input-group">
<label for="last_name">Nachname</label>
<input type="text" id="last_name" name="last_name" required placeholder="z. B. Muster">
</div>
<div class="input-group">
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required placeholder="maxi@beispiel.de">
</div>
<hr class="divider">
<div class="order-total-box">
<div class="total-label">Gesamtsumme:</div>
<div class="total-amount" id="totalAmount">0,00 €</div>
</div>
<button type="submit" class="btn-submit" id="submitBtn">
🥨 Bestellung verbindlich abschicken
</button>
<small class="form-hint">Du kannst deine Bestellung bis zum Stichtag jederzeit mit derselben E-Mail-Adresse ändern.</small>
</div>
</div>
</div>
</form>
{% else %}
<div class="empty-state">
<div class="empty-icon">🏖️</div>
<h2>Aktuell keine offene Bestellrunde</h2>
<p>Für dieses Team ist im Moment keine Brotzeit geplant.</p>
<a href="/admin" class="btn-primary">Neue Bestellrunde im Cockpit anlegen</a>
</div>
{% endif %}
</div>
<script>
function changeQty(productId, delta) {
const input = document.getElementById('qty_' + productId);
let val = parseInt(input.value) || 0;
val = Math.max(0, val + delta);
input.value = val;
updateTotal();
}
function updateTotal() {
let total = 0;
const cards = document.querySelectorAll('.product-card');
cards.forEach(card => {
const price = parseFloat(card.dataset.price) || 0;
const id = card.dataset.id;
const qtyInput = document.getElementById('qty_' + id);
const qty = parseInt(qtyInput.value) || 0;
total += price * qty;
});
document.getElementById('totalAmount').innerText = total.toFixed(2).replace('.', ',') + ' €';
const submitBtn = document.getElementById('submitBtn');
if (total > 0) {
submitBtn.removeAttribute('disabled');
submitBtn.style.opacity = '1';
} else {
submitBtn.style.opacity = '0.7';
}
}
document.addEventListener('DOMContentLoaded', updateTotal);
</script>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div class="container narrow">
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h1>Mandant nicht gefunden</h1>
<p>Für diese Subdomain wurde noch kein aktives Brotzeit-Portal eingerichtet.</p>
<a href="https://new.dev.waiswuaschd.de" class="btn-primary">Zur Waiswuaschd-Hauptseite</a>
</div>
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block content %}
<div class="container narrow">
<div class="success-card">
<div class="success-icon">🎉</div>
<h1>Bestellung erfolgreich erfasst!</h1>
<p class="success-sub">Vielen Dank, <strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>!</p>
<p>Deine Brotzeit für <strong>{{ order.event.title }}</strong> ist fest reserviert.</p>
<div class="order-receipt">
<h3>Deine ausgewählten Schmankerl:</h3>
<ul class="receipt-list">
{% for item in order.items %}
<li>
<span><strong>{{ item.quantity }}x</strong> {{ item.product.name }}</span>
<span>{{ "%.2f"|format(item.unit_price * item.quantity) }} €</span>
</li>
{% endfor %}
</ul>
<div class="receipt-total">
<span>Gesamtbetrag:</span>
<span class="total-price">{{ "%.2f"|format(order.total_price) }} €</span>
</div>
</div>
<div class="success-actions">
<a href="/" class="btn-primary">Zurück zur Übersicht</a>
<a href="/admin" class="btn-outline">Zum Einkäufer-Cockpit</a>
</div>
</div>
</div>
{% endblock %}