feat: Paket 2 - Product catalog management, guest orders, sponsor rounds, password reset, user deletion, unlimited invite links and setup wizard

This commit is contained in:
2026-09-21 11:32:41 +02:00
parent 83393e363e
commit e5ca9c3cbf
7 changed files with 910 additions and 119 deletions
+341 -88
View File
@@ -1,7 +1,7 @@
import os
import secrets
from datetime import datetime, timedelta, date
from typing import Optional, Dict
from typing import Optional, Dict, List
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
@@ -104,9 +104,6 @@ def index(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
now = datetime.now()
# Aktuelle offene Runde suchen
@@ -119,6 +116,11 @@ def index(request: Request, db: Session = Depends(get_db)):
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
if not current_user:
if not event or not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Fristprüfung & Countdown
is_closed = True
seconds_remaining = 0
@@ -136,7 +138,7 @@ def index(request: Request, db: Session = Depends(get_db)):
# Bestehende Bestellung des Users in diesem Event
existing_order = None
user_items: Dict[int, int] = {}
if event:
if event and current_user:
existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
@@ -175,10 +177,19 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
if current_user:
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# Prüfen, ob aktives Event Gäste erlaubt
active_event = db.query(Event).filter(
Event.tenant_id == tenant.id,
Event.status == "open",
Event.deadline > datetime.now()
).first()
allow_guests = (active_event.allow_guests if active_event else False)
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": error,
"success": success,
"allow_guests": allow_guests,
"current_user": None
})
@@ -198,10 +209,10 @@ def login(
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
"current_user": None
"current_user": None,
"allow_guests": False
}, status_code=400)
# Session setzen
request.session["user_id"] = user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@@ -251,9 +262,9 @@ def join_page(request: Request, token: str, db: Session = Depends(get_db)):
error = None
if not invitation:
error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team."
elif invitation.used_at is not None:
elif not invitation.is_reusable and invitation.used_at is not None:
error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an."
elif invitation.expires_at < datetime.now():
elif invitation.expires_at and invitation.expires_at < datetime.now():
error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link."
return templates.TemplateResponse(request, "join.html", context={
@@ -281,8 +292,14 @@ def process_join(
Invitation.tenant_id == tenant.id
).first()
if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink")
if not invitation:
raise HTTPException(status_code=400, detail="Ungültiger Einladungslink")
if not invitation.is_reusable and invitation.used_at is not None:
raise HTTPException(status_code=400, detail="Einladungslink wurde bereits verwendet")
if invitation.expires_at and invitation.expires_at < datetime.now():
raise HTTPException(status_code=400, detail="Einladungslink ist abgelaufen")
clean_email = email.strip().lower()
existing_user = db.query(User).filter(
@@ -299,7 +316,6 @@ def process_join(
"current_user": None
}, status_code=400)
# Neuen Benutzer anlegen
username = clean_email.split("@")[0]
new_user = User(
tenant_id=tenant.id,
@@ -312,12 +328,13 @@ def process_join(
)
db.add(new_user)
# Einladung als verwendet markieren
invitation.used_at = datetime.now()
# Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
if not invitation.is_reusable:
invitation.used_at = datetime.now()
db.commit()
db.refresh(new_user)
# Direkt einloggen
request.session["user_id"] = new_user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@@ -328,12 +345,13 @@ def process_join(
async def place_order(
request: Request,
event_id: int = Form(...),
guest_first_name: Optional[str] = Form(None),
guest_last_name: Optional[str] = Form(None),
guest_email: Optional[str] = Form(None),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event or event.status != "open":
@@ -342,6 +360,33 @@ async def place_order(
if event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
# Falls nicht eingeloggt -> Gast-Bestellung prüfen
if not current_user:
if not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not guest_first_name or not guest_first_name.strip():
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Vornamen an!")
fname = guest_first_name.strip()
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local")
guest_user = User(
tenant_id=tenant.id,
email=clean_email,
username=f"gast_{secrets.token_hex(4)}",
first_name=fname,
last_name=lname,
role="guest",
password_hash=""
)
db.add(guest_user)
db.commit()
db.refresh(guest_user)
current_user = guest_user
request.session["user_id"] = guest_user.id
form_data = await request.form()
order_items = []
total_price = Decimal("0.00")
@@ -468,12 +513,17 @@ def admin_dashboard(
# Teammitglieder & Einladungen laden
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all()
# Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft)
now = datetime.now()
pending_invitations = db.query(Invitation).filter(
Invitation.tenant_id == tenant.id,
Invitation.used_at == None,
Invitation.expires_at > datetime.now()
(Invitation.expires_at == None) | (Invitation.expires_at > now)
).order_by(Invitation.created_at.desc()).all()
# Produkte des Mandanten laden
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant,
"current_user": current_user,
@@ -485,119 +535,90 @@ def admin_dashboard(
"total_paid": total_paid,
"team_members": team_members,
"pending_invitations": pending_invitations,
"products": products,
"active_tab": tab,
"now": datetime.now()
"now": now
})
@app.post("/admin/events/new")
def admin_create_event(
# --- ADMIN USER MANAGEMENT ---
@app.post("/admin/users/{user_id}/reset-password")
def admin_reset_password(
user_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
new_password: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
new_event = Event(
tenant_id=tenant.id,
title=title.strip(),
event_date=ev_date,
deadline=deadline_dt,
status="open"
)
db.add(new_event)
db.commit()
db.refresh(new_event)
if not new_password or len(new_password.strip()) < 4:
raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein")
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/edit")
def admin_edit_event(
event_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
status_val: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
event.title = title.strip()
event.event_date = ev_date
event.deadline = deadline_dt
event.status = status_val
user.password_hash = hash_password(new_password.strip())
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/toggle-status")
def admin_toggle_event_status(
event_id: int,
@app.post("/admin/users/{user_id}/delete")
def admin_delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
admin = require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
if admin.id == user_id:
raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen")
event.status = "closed" if event.status == "open" else "open"
db.commit()
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if user:
db.delete(user)
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS ---
@app.post("/admin/invitations/create")
def admin_create_invitation(
request: Request,
email: Optional[str] = Form(None),
role: str = Form("user"),
valid_days: int = Form(14),
valid_days: str = Form("14"),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
token = secrets.token_urlsafe(20)
expires_at = datetime.now() + timedelta(days=valid_days)
clean_email = email.strip().lower() if email and email.strip() else None
# Unbegrenzt prüfen
if valid_days in ["0", "unlimited", "forever"]:
expires_at = None
is_reusable = True
else:
days = int(valid_days)
expires_at = datetime.now() + timedelta(days=days)
is_reusable = False
inv = Invitation(
tenant_id=tenant.id,
token=token,
email=clean_email,
role=role,
created_by=admin.id,
expires_at=expires_at
expires_at=expires_at,
is_reusable=is_reusable
)
db.add(inv)
db.commit()
@@ -622,6 +643,238 @@ def admin_revoke_invitation(
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN EVENT MANAGEMENT ---
@app.post("/admin/events/new")
def admin_create_event(
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
sponsor_name: Optional[str] = Form(None),
allow_guests: bool = Form(True),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
new_event = Event(
tenant_id=tenant.id,
title=title.strip(),
event_date=ev_date,
deadline=deadline_dt,
status="open",
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
allow_guests=allow_guests
)
db.add(new_event)
db.commit()
db.refresh(new_event)
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/edit")
def admin_edit_event(
event_id: int,
request: Request,
title: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
status_val: str = Form(...),
sponsor_name: Optional[str] = Form(None),
allow_guests: bool = Form(False),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
event.title = title.strip()
event.event_date = ev_date
event.deadline = deadline_dt
event.status = status_val
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
event.allow_guests = allow_guests
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/toggle-status")
def admin_toggle_event_status(
event_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
raise HTTPException(status_code=404, detail="Event nicht gefunden")
event.status = "closed" if event.status == "open" else "open"
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
@app.post("/admin/products/new")
def admin_create_product(
request: Request,
name: str = Form(...),
description: str = Form(""),
price: str = Form(...),
unit: str = Form("Stück"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
try:
clean_price = Decimal(price.strip().replace(",", "."))
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
product = Product(
tenant_id=tenant.id,
name=name.strip(),
description=description.strip(),
price=clean_price,
unit=unit.strip(),
sort_order=sort_order,
is_active=True
)
db.add(product)
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/edit")
def admin_edit_product(
product_id: int,
request: Request,
name: str = Form(...),
description: str = Form(""),
price: str = Form(...),
unit: str = Form("Stück"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if not prod:
raise HTTPException(status_code=404, detail="Produkt nicht gefunden")
try:
clean_price = Decimal(price.strip().replace(",", "."))
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
prod.name = name.strip()
prod.description = description.strip()
prod.price = clean_price
prod.unit = unit.strip()
prod.sort_order = sort_order
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/toggle")
def admin_toggle_product(
product_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if prod:
prod.is_active = not prod.is_active
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
# --- ONBOARDING / SETUP WIZARD (/setup) ---
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
current_user = get_current_user(request, db)
prod_count = db.query(Product).filter(Product.tenant_id == tenant.id).count()
user_count = db.query(User).filter(User.tenant_id == tenant.id).count()
return templates.TemplateResponse(request, "setup.html", context={
"tenant": tenant,
"current_user": current_user,
"prod_count": prod_count,
"user_count": user_count
})
@app.post("/setup/seed-products")
def seed_standard_products(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
require_admin(request, db)
standard_items = [
{"name": "Original Münchner Weißwürste", "desc": "Frisch vom Metzger, traditionell hergestellt", "price": Decimal("1.40"), "unit": "Stück", "order": 1},
{"name": "Frische Laugenbreze", "desc": "Knusprig gebacken, ofenfrisch vom Bäcker", "price": Decimal("1.10"), "unit": "Stück", "order": 2},
{"name": "Händlmaier Hausmachersenf", "desc": "Original bayerischer süßer Hausmachersenf", "price": Decimal("0.40"), "unit": "Portion", "order": 3},
{"name": "Leberkassemmel (warm)", "desc": "Feiner bayerischer Leberkäse in der Kaisersemmel", "price": Decimal("2.80"), "unit": "Semmel", "order": 4},
{"name": "Wiener Würstchen", "desc": "Knackige Wiener Würstchen (Paar)", "price": Decimal("2.60"), "unit": "Paar", "order": 5},
{"name": "Alkoholfreies Weißbier (0,5l)", "desc": "Erfrischendes bayerisches Hefe-Weißbier alkoholfrei", "price": Decimal("1.80"), "unit": "Flasche", "order": 6},
]
for item in standard_items:
exists = db.query(Product).filter(Product.tenant_id == tenant.id, Product.name == item["name"]).first()
if not exists:
db.add(Product(
tenant_id=tenant.id,
name=item["name"],
description=item["desc"],
price=item["price"],
unit=item["unit"],
sort_order=item["order"],
is_active=True
))
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, db)
+6 -3
View File
@@ -30,12 +30,12 @@ class User(Base):
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") # 'admin', 'user'
role = Column(String(20), default="user") # 'admin', 'user', 'guest'
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")
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")
class Invitation(Base):
@@ -47,8 +47,9 @@ class Invitation(Base):
email = Column(String(191), nullable=True)
role = Column(String(20), default="user", nullable=False)
created_by = Column(Integer, nullable=True)
expires_at = Column(DateTime, nullable=False)
expires_at = Column(DateTime, nullable=True)
used_at = Column(DateTime, nullable=True)
is_reusable = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="invitations")
@@ -78,6 +79,8 @@ class Event(Base):
event_date = Column(Date, nullable=False)
deadline = Column(DateTime, nullable=False)
status = Column(String(20), default="open") # 'open', 'closed', 'completed'
allow_guests = Column(Boolean, default=True, nullable=False)
sponsor_name = Column(String(150), nullable=True)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="events")
+138
View File
@@ -234,6 +234,18 @@ body {
margin-bottom: 12px;
}
.sponsor-badge-banner {
display: inline-block;
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.4);
color: var(--accent-gold);
padding: 6px 14px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 12px;
}
.banner-stats {
color: var(--text-muted);
font-size: 0.85rem;
@@ -380,6 +392,14 @@ body {
margin-bottom: 16px;
}
.guest-order-box {
background: rgba(11, 15, 25, 0.6);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 18px;
}
.input-group {
margin-bottom: 16px;
}
@@ -407,12 +427,38 @@ body {
border-color: var(--accent);
}
.input-group-checkbox {
margin: 14px 0;
display: flex;
align-items: center;
}
.input-group-checkbox label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
cursor: pointer;
}
.input-group-checkbox input {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-row-3 {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
gap: 12px;
}
.readonly-input {
background: rgba(255, 255, 255, 0.05) !important;
color: var(--text-muted) !important;
@@ -677,6 +723,27 @@ body {
font-size: 0.9rem;
}
.sponsor-highlight-card {
display: flex;
align-items: center;
gap: 16px;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.35);
border-radius: var(--radius-md);
padding: 18px 22px;
margin-bottom: 24px;
}
.sponsor-icon {
font-size: 2.5rem;
}
.sponsor-highlight-card h3 {
color: var(--accent-gold);
font-size: 1.15rem;
margin-bottom: 4px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -758,6 +825,11 @@ body {
font-size: 0.8rem;
}
.row-inactive {
opacity: 0.5;
background: rgba(0, 0, 0, 0.2);
}
.mini-items-list {
list-style: none;
font-size: 0.85rem;
@@ -805,6 +877,11 @@ body {
color: var(--accent-gold);
}
.badge-role.guest {
background: rgba(168, 85, 247, 0.15);
color: #c084fc;
}
/* Invitations List */
.invitations-list {
display: flex;
@@ -884,6 +961,60 @@ body {
background: rgba(255, 255, 255, 0.15);
}
/* Modal */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(6px);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.modal-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
/* Setup Wizard Steps */
.setup-step-card {
display: flex;
gap: 16px;
background: rgba(11, 15, 25, 0.6);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 18px;
}
.step-num {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: #0b0f19;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-content h3 {
font-size: 1.05rem;
margin-bottom: 6px;
}
/* Footer */
.footer {
border-top: 1px solid var(--border-color);
@@ -951,6 +1082,13 @@ body {
margin-bottom: 6px;
}
.print-sponsor-box {
margin-top: 8px;
padding: 6px;
border: 1px dashed #000;
font-size: 10pt;
}
.print-section h3 {
font-size: 13pt;
margin-bottom: 8px;
+302 -23
View File
@@ -3,7 +3,7 @@
{% block content %}
<div class="container">
<!-- Printable distribution sheet for the kitchen (hidden in screen, visible in print) -->
<!-- Printable distribution sheet for the kitchen (hidden on screen, visible in print) -->
<div class="print-only">
<div class="print-header">
<h1>🥨 Schmankerl – Brotzeit-Verteilliste</h1>
@@ -12,6 +12,11 @@
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} &nbsp;|&nbsp;
<strong>Team:</strong> {{ tenant.name }}
</p>
{% if event and event.sponsor_name %}
<div class="print-sponsor-box">
🎉 <strong>GESPONSERTE RUNDE:</strong> Ausgegeben von <strong>{{ event.sponsor_name }}</strong> – Gesamtrechnung: <strong>{{ "%.2f"|format(total_revenue) }} €</strong>
</div>
{% endif %}
</div>
<div class="print-section">
@@ -50,14 +55,31 @@
{% for order in orders %}
<tr>
<td style="text-align: center; font-size: 1.2rem;">☐</td>
<td><strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong></td>
<td>
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
{% if order.user.role == 'guest' %}<small>(Gast)</small>{% endif %}
</td>
<td>
{% for item in order.items %}
{{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
<td style="text-align: right; font-weight: bold;">{{ "%.2f"|format(order.total_price) }} €</td>
<td style="text-align: center;">{% if order.is_paid %}Bezahlt ✓{% else %}Offen{% endif %}</td>
<td style="text-align: right; font-weight: bold;">
{% if event and event.sponsor_name %}
<span style="color: #666; font-size: 9pt;">Gesponsert</span>
{% else %}
{{ "%.2f"|format(order.total_price) }} €
{% endif %}
</td>
<td style="text-align: center;">
{% if event and event.sponsor_name %}
Übernommen ✓
{% elif order.is_paid %}
Bezahlt ✓
{% else %}
Offen
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -88,6 +110,9 @@
<a href="?tab=event{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'event' %}active{% endif %}">
📅 Event- & Frist-Planung
</a>
<a href="?tab=products{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'products' %}active{% endif %}">
🥨 Produkte & Sortiment ({{ products|length }})
</a>
<a href="?tab=team{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'team' %}active{% endif %}">
👥 Team & Einladungen ({{ team_members|length }})
</a>
@@ -109,6 +134,17 @@
{% endif %}
{% if event %}
{% if event.sponsor_name %}
<div class="sponsor-highlight-card">
<div class="sponsor-icon">🎁</div>
<div>
<h3>Diese Runde wird gesponsert!</h3>
<p>Sponsor / Anlass: <strong>{{ event.sponsor_name }}</strong> übernimmt die Gesamtrechnung von <strong>{{ "%.2f"|format(total_revenue) }} €</strong> für das gesamte Team.</p>
</div>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
@@ -117,15 +153,19 @@
</div>
<div class="stat-card">
<div class="stat-label">Bereits bezahlt</div>
<div class="stat-value success">{{ "%.2f"|format(total_paid) }} €</div>
<div class="stat-value success">
{% if event.sponsor_name %}Gesponsert{% else %}{{ "%.2f"|format(total_paid) }} €{% endif %}
</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 class="stat-value warning">
{% if event.sponsor_name %}0,00 €{% else %}{{ "%.2f"|format(total_revenue - total_paid) }} €{% endif %}
</div>
</div>
<div class="stat-card">
<div class="stat-label">Teilnehmer</div>
<div class="stat-value">{{ orders|length }} Kollegen</div>
<div class="stat-value">{{ orders|length }} Personen</div>
</div>
</div>
@@ -133,7 +173,7 @@
<!-- Aggregated Shopping List -->
<div class="admin-card">
<h2>🛒 Sammelliste für Metzger & Bäcker</h2>
<p class="card-hint">Aufsummierte Mengen für den Einkauf:</p>
<p class="card-hint">Aufsummierte Gesamtmengen für den Einkauf:</p>
<div class="shopping-table-wrapper">
<table class="data-table">
@@ -162,7 +202,7 @@
<!-- Individual Orders & Payment Checklist -->
<div class="admin-card">
<h2>👥 Wer hat bestellt? (Strichliste)</h2>
<p class="card-hint">Klicke auf den Status, um bezahlt/offen umzuschalten:</p>
<p class="card-hint">Einzelübersicht mit Artikeln und Kassenstatus:</p>
<div class="orders-table-wrapper">
<table class="data-table">
@@ -178,7 +218,11 @@
{% for order in orders %}
<tr>
<td>
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong><br>
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
{% if order.user.role == 'guest' %}
<span class="badge-role guest">Gast</span>
{% endif %}
<br>
<small class="text-muted">{{ order.user.email }}</small>
</td>
<td>
@@ -188,13 +232,20 @@
{% endfor %}
</ul>
</td>
<td><strong>{{ "%.2f"|format(order.total_price) }} €</strong></td>
<td>
<strong>{{ "%.2f"|format(order.total_price) }} €</strong>
{% if event.sponsor_name %}<br><small class="text-muted">von Sponsor</small>{% endif %}
</td>
<td>
{% if event.sponsor_name %}
<span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span>
{% else %}
<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>
{% endif %}
</td>
</tr>
{% else %}
@@ -220,7 +271,7 @@
{% if event %}
<div class="admin-card">
<h2>✏️ Aktives Event bearbeiten</h2>
<p class="card-hint">Passe Titel, Termin und die Bestellfrist sekundengenau an:</p>
<p class="card-hint">Passe Titel, Frist, Gast-Erlaubnis und Sponsor an:</p>
<form action="/admin/events/{{ event.id }}/edit" method="POST" class="admin-form">
<div class="input-group">
@@ -242,6 +293,17 @@
</div>
</div>
<div class="input-group">
<label for="edit_sponsor">🎉 Sponsor / Anlass (Optional)</label>
<input type="text" id="edit_sponsor" name="sponsor_name" value="{{ event.sponsor_name or '' }}" placeholder="z. B. Maxi (Geburtstag) oder Firma übernimmt">
<small class="form-hint">Falls ausgefüllt, übernimmt der Sponsor die Gesamtrechnung statt Einzelabrechnung.</small>
</div>
<div class="input-group-checkbox">
<label>
<input type="checkbox" name="allow_guests" value="true" {% if event.allow_guests %}checked{% endif %}>
<strong>Gäste-Bestellungen erlauben</strong> (Mitbestellen ohne Registrierung)
</label>
</div>
<div class="input-group" style="margin-top: 14px;">
<label for="edit_status">Status der Runde</label>
<select id="edit_status" name="status_val">
<option value="open" {% if event.status == 'open' %}selected{% endif %}>🟢 Geöffnet (Bestellungen möglich)</option>
@@ -277,18 +339,155 @@
<input type="time" id="new_deadline_time" name="deadline_time" value="08:30" required>
</div>
</div>
<div class="input-group">
<label for="new_sponsor">🎉 Sponsor / Anlass (Optional)</label>
<input type="text" id="new_sponsor" name="sponsor_name" placeholder="z. B. Geburtstag von Anna">
</div>
<div class="input-group-checkbox">
<label>
<input type="checkbox" name="allow_guests" value="true" checked>
<strong>Gäste-Bestellungen erlauben</strong>
</label>
</div>
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Runde jetzt starten</button>
</form>
</div>
</div>
<!-- TAB 3: TEAM & EINLADUNGEN -->
<!-- TAB 3: PRODUKTE & SORTIMENT -->
{% elif active_tab == 'products' %}
<div class="admin-columns">
<!-- Product List -->
<div class="admin-card" style="grid-column: 1 / -1;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div>
<h2>🥨 Sortiment verwalten ({{ products|length }} Artikel)</h2>
<p class="card-hint">Artikel für die Bestellkarte anpassen, aktivieren oder deaktivieren:</p>
</div>
<a href="/setup" class="btn-secondary" style="font-size: 0.85rem;">⚡ Standard-Bayerisch vorladen</a>
</div>
<div class="orders-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Artikel</th>
<th>Beschreibung</th>
<th>Preis</th>
<th>Einheit</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for prod in products %}
<tr class="{% if not prod.is_active %}row-inactive{% endif %}">
<td><strong>{{ prod.name }}</strong></td>
<td><small class="text-muted">{{ prod.description }}</small></td>
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
<td>{{ prod.unit }}</td>
<td>
{% if prod.is_active %}
<span class="badge-role" style="background: rgba(34, 197, 94, 0.15); color: #86efac;">Aktiv</span>
{% else %}
<span class="badge-role" style="background: rgba(239, 68, 68, 0.15); color: #fca5a5;">Inaktiv</span>
{% endif %}
</td>
<td>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}')">✏️ Bearbeiten</button>
<form action="/admin/products/{{ prod.id }}/toggle" method="POST" style="margin: 0; display: inline;">
<button type="submit" class="btn-revoke-small" style="color: {% if prod.is_active %}#fca5a5{% else %}#86efac{% endif %};">
{% if prod.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty-cell">Noch keine Produkte angelegt. Nutze das Formular unten oder lade das Standard-Sortiment!</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Add new product -->
<div class="admin-card" style="margin-top: 2rem; background: rgba(11, 15, 25, 0.6);">
<h3>➕ Neues Produkt hinzufügen</h3>
<form action="/admin/products/new" method="POST" class="admin-form" style="margin-top: 14px;">
<div class="form-row-2">
<div class="input-group">
<label for="prod_name">Artikelname</label>
<input type="text" id="prod_name" name="name" placeholder="z. B. Wiener Würstchen" required>
</div>
<div class="input-group">
<label for="prod_desc">Beschreibung (optional)</label>
<input type="text" id="prod_desc" name="description" placeholder="z. B. Knackig, vom lokalen Metzger">
</div>
</div>
<div class="form-row-3">
<div class="input-group">
<label for="prod_price">Preis (€)</label>
<input type="text" id="prod_price" name="price" placeholder="2.50" required>
</div>
<div class="input-group">
<label for="prod_unit">Einheit</label>
<input type="text" id="prod_unit" name="unit" value="Stück" placeholder="Stück, Paar, Portion">
</div>
<div class="input-group">
<label for="prod_sort">Sortierung</label>
<input type="number" id="prod_sort" name="sort_order" value="10">
</div>
</div>
<button type="submit" class="btn-primary" style="margin-top: 10px;">Artikel speichern</button>
</form>
</div>
</div>
</div>
<!-- Edit Product Modal (Simple JS) -->
<div id="editProductModal" class="modal-backdrop" style="display: none;">
<div class="modal-card">
<h3>✏️ Produkt bearbeiten</h3>
<form id="editProductForm" method="POST" class="admin-form" style="margin-top: 14px;">
<div class="input-group">
<label for="m_prod_name">Artikelname</label>
<input type="text" id="m_prod_name" name="name" required>
</div>
<div class="input-group">
<label for="m_prod_desc">Beschreibung</label>
<input type="text" id="m_prod_desc" name="description">
</div>
<div class="form-row-3">
<div class="input-group">
<label for="m_prod_price">Preis (€)</label>
<input type="text" id="m_prod_price" name="price" required>
</div>
<div class="input-group">
<label for="m_prod_unit">Einheit</label>
<input type="text" id="m_prod_unit" name="unit" required>
</div>
<div class="input-group">
<label for="m_prod_sort">Sortierung</label>
<input type="number" id="m_prod_sort" name="sort_order">
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 16px;">
<button type="submit" class="btn-primary">Änderungen speichern</button>
<button type="button" class="btn-secondary" onclick="closeEditProduct()">Abbrechen</button>
</div>
</form>
</div>
</div>
<!-- TAB 4: TEAM & EINLADUNGEN -->
{% elif active_tab == 'team' %}
<div class="admin-columns">
<!-- Generate invite link -->
<div class="admin-card">
<h2>✉️ Teammitglied einladen</h2>
<p class="card-hint">Erzeuge einen sicheren Einladungslink zum Teilen per Chat (Slack, Teams, WhatsApp):</p>
<p class="card-hint">Erzeuge einen Einladungslink (einmalig oder unbegrenzt dauerhaft):</p>
<form action="/admin/invitations/create" method="POST" class="admin-form">
<div class="input-group">
@@ -307,8 +506,9 @@
<div class="input-group">
<label for="inv_valid">Gültigkeit</label>
<select id="inv_valid" name="valid_days">
<option value="unlimited" selected>∞ Unbegrenzt (Dauerhafter Team-Link)</option>
<option value="7">7 Tage</option>
<option value="14" selected>14 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</select>
</div>
@@ -317,15 +517,20 @@
</form>
{% if pending_invitations %}
<h3 style="margin-top: 2rem;">Offene Einladungslinks</h3>
<h3 style="margin-top: 2rem;">Aktive Einladungslinks</h3>
<div class="invitations-list">
{% for inv in pending_invitations %}
<div class="invite-item">
<div class="invite-info">
<strong>{% if inv.email %}{{ inv.email }}{% else %}Offener Link{% endif %}</strong>
<strong>{% if inv.email %}{{ inv.email }}{% else %}Offener Team-Link{% endif %}</strong>
<span class="badge-role">{{ inv.role }}</span>
{% if inv.is_reusable %}
<span class="badge-role" style="background: rgba(34, 197, 94, 0.2); color: #86efac;">Dauerhaft wiederverwendbar</span>
{% endif %}
<br>
<small class="text-muted">Gültig bis {{ inv.expires_at.strftime('%d.%m.%Y') }}</small>
<small class="text-muted">
{% if inv.expires_at %}Gültig bis {{ inv.expires_at.strftime('%d.%m.%Y') }}{% else %}Unbegrenzt gültig{% endif %}
</small>
</div>
<div class="invite-actions">
<button type="button" class="btn-copy-small" onclick="copyInviteUrl('{{ inv.token }}')">📋 Kopieren</button>
@@ -348,23 +553,37 @@
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Name & E-Mail</th>
<th>Rolle</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for member in team_members %}
<tr>
<td><strong>{{ member.first_name }} {{ member.last_name }}</strong></td>
<td><small class="text-muted">{{ member.email }}</small></td>
<td>
<strong>{{ member.first_name }} {{ member.last_name }}</strong><br>
<small class="text-muted">{{ member.email }}</small>
</td>
<td>
{% if member.role == 'admin' %}
<span class="badge-role admin">Admin</span>
{% elif member.role == 'guest' %}
<span class="badge-role guest">Gast</span>
{% else %}
<span class="badge-role">Mitarbeiter</span>
{% endif %}
</td>
<td>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn-copy-small" onclick="openResetPassword('{{ member.id }}', '{{ member.first_name|escape }} {{ member.last_name|escape }}')">🔑 Passwort</button>
{% if member.id != current_user.id %}
<form action="/admin/users/{{ member.id }}/delete" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du {{ member.first_name }} {{ member.last_name }} wirklich unwiderruflich löschen?');">
<button type="submit" class="btn-revoke-small" title="Benutzer löschen">🗑️</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
@@ -372,6 +591,27 @@
</div>
</div>
</div>
<!-- Password Reset Modal -->
<div id="resetPasswordModal" class="modal-backdrop" style="display: none;">
<div class="modal-card">
<h3>🔑 Passwort zurücksetzen</h3>
<p class="card-hint" id="resetModalUserHint">Neues Passwort für den Benutzer vergeben:</p>
<form id="resetPasswordForm" method="POST" class="admin-form">
<div class="input-group">
<label for="new_pw_input">Neues Passwort</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="new_pw_input" name="new_password" required minlength="4" placeholder="Mindestens 4 Zeichen">
<button type="button" class="btn-secondary" onclick="generateRandomPw()">🎲 Zufall</button>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 16px;">
<button type="submit" class="btn-primary">Passwort jetzt speichern</button>
<button type="button" class="btn-secondary" onclick="closeResetPassword()">Abbrechen</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
@@ -394,8 +634,47 @@
const origin = window.location.origin;
const url = origin + '/join?token=' + token;
navigator.clipboard.writeText(url).then(() => {
alert("Einladungslink kopiert!\n\n" + url + "\n\nDu kannst ihn jetzt an den Kollegen senden.");
alert("Einladungslink kopiert!\n\n" + url + "\n\nDu kannst ihn jetzt an das Team senden.");
});
}
function openResetPassword(userId, userName) {
const modal = document.getElementById('resetPasswordModal');
const form = document.getElementById('resetPasswordForm');
const hint = document.getElementById('resetModalUserHint');
form.action = '/admin/users/' + userId + '/reset-password';
hint.innerText = 'Neues Passwort für ' + userName + ' vergeben:';
document.getElementById('new_pw_input').value = '';
modal.style.display = 'flex';
}
function closeResetPassword() {
document.getElementById('resetPasswordModal').style.display = 'none';
}
function generateRandomPw() {
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!#';
let pw = '';
for (let i = 0; i < 8; i++) {
pw += chars.charAt(Math.floor(Math.random() * chars.length));
}
document.getElementById('new_pw_input').value = pw;
}
function openEditProduct(id, name, desc, price, unit, sort) {
const modal = document.getElementById('editProductModal');
const form = document.getElementById('editProductForm');
form.action = '/admin/products/' + id + '/edit';
document.getElementById('m_prod_name').value = name;
document.getElementById('m_prod_desc').value = desc;
document.getElementById('m_prod_price').value = price;
document.getElementById('m_prod_unit').value = unit;
document.getElementById('m_prod_sort').value = sort;
modal.style.display = 'flex';
}
function closeEditProduct() {
document.getElementById('editProductModal').style.display = 'none';
}
</script>
{% endblock %}
+46 -5
View File
@@ -22,6 +22,12 @@
Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong>
</p>
{% if event.sponsor_name %}
<div class="sponsor-badge-banner">
🎉 <strong>Runde gesponsert:</strong> Die Brotzeit geht heute auf <strong>{{ event.sponsor_name }}</strong>!
</div>
{% endif %}
<div class="banner-stats">
<span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen von Kollegen eingegangen</span>
</div>
@@ -48,7 +54,14 @@
<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 class="product-price">
{% if event.sponsor_name %}
<span style="text-decoration: line-through; opacity: 0.6; font-size: 0.9rem;">{{ "%.2f"|format(product.price) }} €</span>
<span style="color: var(--accent-gold); font-weight: 700;"> 0,00 € (Gesponsert)</span>
{% else %}
{{ "%.2f"|format(product.price) }} € <small>/ {{ product.unit }}</small>
{% endif %}
</div>
</div>
<div class="quantity-control">
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)" {% if is_closed %}disabled{% endif %}>-</button>
@@ -63,15 +76,38 @@
<!-- Right: User Details & Summary -->
<div class="summary-section">
<div class="summary-card">
<h2>2. Zusammenfassung</h2>
<h2>2. Bestellung & Angaben</h2>
{% if current_user %}
<div class="logged-in-badge">
<div class="user-avatar">👤</div>
<div>
<strong>{{ current_user.first_name }} {{ current_user.last_name }}</strong><br>
<strong>{{ current_user.first_name }} {{ current_user.last_name }}</strong>
{% if current_user.role == 'guest' %}<small>(Gast)</small>{% endif %}
<br>
<small class="text-muted">{{ current_user.email }}</small>
</div>
</div>
{% else %}
<!-- Gast Angaben -->
<div class="guest-order-box">
<div class="privacy-note" style="margin-bottom: 12px;">
👋 <strong>Gast-Bestellung:</strong> Du bestellst ohne dauerhaftes Benutzerkonto für diesen Termin mit.
</div>
<div class="input-group">
<label for="g_first_name">Dein Vorname *</label>
<input type="text" id="g_first_name" name="guest_first_name" required placeholder="z. B. Christian">
</div>
<div class="input-group">
<label for="g_last_name">Nachname (optional)</label>
<input type="text" id="g_last_name" name="guest_last_name" placeholder="z. B. Bauer">
</div>
<div class="input-group">
<label for="g_email">E-Mail (optional für Bestätigung)</label>
<input type="email" id="g_email" name="guest_email" placeholder="name@firma.de">
</div>
</div>
{% endif %}
{% if existing_order %}
<div class="notice-box">
@@ -87,6 +123,12 @@
<div class="total-amount" id="totalAmount">0,00 €</div>
</div>
{% if event.sponsor_name %}
<div style="font-size: 0.85rem; color: var(--accent-gold); margin-bottom: 14px; text-align: center;">
🎁 Die Rechnung wird von <strong>{{ event.sponsor_name }}</strong> übernommen!
</div>
{% endif %}
<button type="submit" class="btn-submit" id="submitBtn" {% if is_closed %}disabled{% endif %}>
{% if is_closed %}
🔒 Bestellschluss vorüber
@@ -112,7 +154,7 @@
<div class="empty-icon">🏖️</div>
<h2>Aktuell keine offene Bestellrunde</h2>
<p>Für dieses Team ist im Moment keine Brotzeit geplant.</p>
{% if current_user.role == 'admin' %}
{% if current_user and current_user.role == 'admin' %}
<a href="/admin?tab=event" class="btn-primary" style="margin-top: 1rem;">Neue Bestellrunde im Cockpit anlegen</a>
{% endif %}
</div>
@@ -163,7 +205,6 @@
if (containerEl) {
containerEl.classList.add('expired');
}
// Formular sperren
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerText = "🔒 Bestellschluss vorüber";
+9
View File
@@ -62,6 +62,15 @@
<button type="submit" class="btn-submit">Anmelden</button>
</form>
{% if allow_guests %}
<div style="text-align: center; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
<p class="card-hint" style="margin-bottom: 8px;">Nur zu Besuch oder für eine Runde eingeladen?</p>
<a href="/" class="btn-secondary" style="width: 100%; text-align: center; display: block;">
🥨 Als Gast mitbestellen (ohne Registrierung)
</a>
</div>
{% endif %}
<div class="invite-notice-card">
<div class="invite-icon">✉️</div>
<div>
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="container narrow">
<div class="login-box" style="max-width: 650px; margin: 0 auto;">
<div class="login-header">
<div class="login-icon">🥨</div>
<h1>Mandanten-Setup & Schnellstart</h1>
<p class="login-sub">Portal: <strong>{{ tenant.name }}</strong> ({{ tenant.slug }})</p>
<div class="privacy-note">
🚀 Willkommen bei Schmankerl! Hier richtest du dein Portal mit 3 einfachen Schritten ein.
</div>
</div>
<!-- Step 1: Sortiment -->
<div class="setup-step-card">
<div class="step-num">1</div>
<div class="step-content">
<h3>Bayerisches Standard-Sortiment laden</h3>
<p class="card-hint" style="margin-bottom: 12px;">
Aktuell im Sortiment: <strong>{{ prod_count }} Artikel</strong>.<br>
Lädt mit einem Klick die Klassiker vor: Original Münchner Weißwürste, frische Laugenbrezen, Händlmaier Hausmachersenf, warme Leberkassemmeln, Wiener Würstchen und alkoholfreies Weißbier.
</p>
<form action="/setup/seed-products" method="POST">
<button type="submit" class="btn-primary" style="font-size: 0.9rem;">
🥨 Standard-Sortiment jetzt laden
</button>
</form>
</div>
</div>
<!-- Step 2: Event -->
<div class="setup-step-card">
<div class="step-num">2</div>
<div class="step-content">
<h3>Erste Brotzeitrunde planen</h3>
<p class="card-hint" style="margin-bottom: 12px;">
Lege Datum, Uhrzeit und Bestellfrist fest, damit Kollegen und Gäste bestellen können.
</p>
<a href="/admin?tab=event" class="btn-secondary" style="font-size: 0.9rem;">
📅 Event-Planung im Cockpit öffnen
</a>
</div>
</div>
<!-- Step 3: Invite -->
<div class="setup-step-card">
<div class="step-num">3</div>
<div class="step-content">
<h3>Team einladen (Dauerhafter Team-Link)</h3>
<p class="card-hint" style="margin-bottom: 12px;">
Erzeuge einen unbegrenzten Einladungslink und pinne ihn in deinen Slack- oder Teams-Kanal.
</p>
<a href="/admin?tab=team" class="btn-secondary" style="font-size: 0.9rem;">
👥 Team-Link im Cockpit generieren
</a>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<a href="/admin" class="btn-primary">Zum Admin-Cockpit</a>
</div>
</div>
</div>
{% endblock %}