Initial commit: Schmankerl v2 - Multi-tenant FastAPI Brotzeitportal
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*~
|
||||||
@@ -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
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
@@ -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>© 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>
|
||||||
@@ -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> |
|
||||||
|
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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user