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
+339 -86
View File
@@ -1,7 +1,7 @@
import os import os
import secrets import secrets
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from typing import Optional, Dict from typing import Optional, Dict, List
from decimal import Decimal from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status 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={}) return templates.TemplateResponse(request, "not_found_tenant.html", context={})
current_user = get_current_user(request, db) 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() now = datetime.now()
# Aktuelle offene Runde suchen # Aktuelle offene Runde suchen
@@ -119,6 +116,11 @@ def index(request: Request, db: Session = Depends(get_db)):
if not event: if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first() 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 # Fristprüfung & Countdown
is_closed = True is_closed = True
seconds_remaining = 0 seconds_remaining = 0
@@ -136,7 +138,7 @@ def index(request: Request, db: Session = Depends(get_db)):
# Bestehende Bestellung des Users in diesem Event # Bestehende Bestellung des Users in diesem Event
existing_order = None existing_order = None
user_items: Dict[int, int] = {} user_items: Dict[int, int] = {}
if event: if event and current_user:
existing_order = db.query(Order).filter( existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id, Order.tenant_id == tenant.id,
Order.event_id == event.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: if current_user:
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) 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={ return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant, "tenant": tenant,
"error": error, "error": error,
"success": success, "success": success,
"allow_guests": allow_guests,
"current_user": None "current_user": None
}) })
@@ -198,10 +209,10 @@ def login(
return templates.TemplateResponse(request, "login.html", context={ return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant, "tenant": tenant,
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.", "error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
"current_user": None "current_user": None,
"allow_guests": False
}, status_code=400) }, status_code=400)
# Session setzen
request.session["user_id"] = user.id request.session["user_id"] = user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) 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 error = None
if not invitation: if not invitation:
error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team." 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." 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." error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link."
return templates.TemplateResponse(request, "join.html", context={ return templates.TemplateResponse(request, "join.html", context={
@@ -281,8 +292,14 @@ def process_join(
Invitation.tenant_id == tenant.id Invitation.tenant_id == tenant.id
).first() ).first()
if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now(): if not invitation:
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink") 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() clean_email = email.strip().lower()
existing_user = db.query(User).filter( existing_user = db.query(User).filter(
@@ -299,7 +316,6 @@ def process_join(
"current_user": None "current_user": None
}, status_code=400) }, status_code=400)
# Neuen Benutzer anlegen
username = clean_email.split("@")[0] username = clean_email.split("@")[0]
new_user = User( new_user = User(
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -312,12 +328,13 @@ def process_join(
) )
db.add(new_user) db.add(new_user)
# Einladung als verwendet markieren # Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
if not invitation.is_reusable:
invitation.used_at = datetime.now() invitation.used_at = datetime.now()
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
# Direkt einloggen
request.session["user_id"] = new_user.id request.session["user_id"] = new_user.id
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@@ -328,12 +345,13 @@ def process_join(
async def place_order( async def place_order(
request: Request, request: Request,
event_id: int = Form(...), 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) db: Session = Depends(get_db)
): ):
tenant = resolve_tenant(request, db) tenant = resolve_tenant(request, db)
current_user = get_current_user(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() event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event or event.status != "open": if not event or event.status != "open":
@@ -342,6 +360,33 @@ async def place_order(
if event.deadline < datetime.now(): if event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!") 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() form_data = await request.form()
order_items = [] order_items = []
total_price = Decimal("0.00") total_price = Decimal("0.00")
@@ -468,12 +513,17 @@ def admin_dashboard(
# Teammitglieder & Einladungen laden # Teammitglieder & Einladungen laden
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all() 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( pending_invitations = db.query(Invitation).filter(
Invitation.tenant_id == tenant.id, Invitation.tenant_id == tenant.id,
Invitation.used_at == None, (Invitation.expires_at == None) | (Invitation.expires_at > now)
Invitation.expires_at > datetime.now()
).order_by(Invitation.created_at.desc()).all() ).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={ return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant, "tenant": tenant,
"current_user": current_user, "current_user": current_user,
@@ -485,119 +535,90 @@ def admin_dashboard(
"total_paid": total_paid, "total_paid": total_paid,
"team_members": team_members, "team_members": team_members,
"pending_invitations": pending_invitations, "pending_invitations": pending_invitations,
"products": products,
"active_tab": tab, "active_tab": tab,
"now": datetime.now() "now": now
}) })
@app.post("/admin/events/new") # --- ADMIN USER MANAGEMENT ---
def admin_create_event(
@app.post("/admin/users/{user_id}/reset-password")
def admin_reset_password(
user_id: int,
request: Request, request: Request,
title: str = Form(...), new_password: str = Form(...),
event_date: str = Form(...),
deadline_date: str = Form(...),
deadline_time: str = Form(...),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
tenant = resolve_tenant(request, db) tenant = resolve_tenant(request, db)
require_admin(request, db) require_admin(request, db)
try: user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() if not user:
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
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( if not new_password or len(new_password.strip()) < 4:
tenant_id=tenant.id, raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein")
title=title.strip(),
event_date=ev_date,
deadline=deadline_dt,
status="open"
)
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) user.password_hash = hash_password(new_password.strip())
@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
db.commit() 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") @app.post("/admin/users/{user_id}/delete")
def admin_toggle_event_status( def admin_delete_user(
event_id: int, user_id: int,
request: Request, request: Request,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
tenant = resolve_tenant(request, 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 admin.id == user_id:
if not event: raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen")
raise HTTPException(status_code=404, detail="Event nicht gefunden")
event.status = "closed" if event.status == "open" else "open" user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if user:
db.delete(user)
db.commit() 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") @app.post("/admin/invitations/create")
def admin_create_invitation( def admin_create_invitation(
request: Request, request: Request,
email: Optional[str] = Form(None), email: Optional[str] = Form(None),
role: str = Form("user"), role: str = Form("user"),
valid_days: int = Form(14), valid_days: str = Form("14"),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
tenant = resolve_tenant(request, db) tenant = resolve_tenant(request, db)
admin = require_admin(request, db) admin = require_admin(request, db)
token = secrets.token_urlsafe(20) 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 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( inv = Invitation(
tenant_id=tenant.id, tenant_id=tenant.id,
token=token, token=token,
email=clean_email, email=clean_email,
role=role, role=role,
created_by=admin.id, created_by=admin.id,
expires_at=expires_at expires_at=expires_at,
is_reusable=is_reusable
) )
db.add(inv) db.add(inv)
db.commit() db.commit()
@@ -622,6 +643,238 @@ def admin_revoke_invitation(
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) 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}") @app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)): def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, 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="") password_hash = Column(String(255), default="")
first_name = Column(String(100), nullable=False) first_name = Column(String(100), nullable=False)
last_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) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="users") 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): class Invitation(Base):
@@ -47,8 +47,9 @@ class Invitation(Base):
email = Column(String(191), nullable=True) email = Column(String(191), nullable=True)
role = Column(String(20), default="user", nullable=False) role = Column(String(20), default="user", nullable=False)
created_by = Column(Integer, nullable=True) created_by = Column(Integer, nullable=True)
expires_at = Column(DateTime, nullable=False) expires_at = Column(DateTime, nullable=True)
used_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()) created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="invitations") tenant = relationship("Tenant", back_populates="invitations")
@@ -78,6 +79,8 @@ class Event(Base):
event_date = Column(Date, nullable=False) event_date = Column(Date, nullable=False)
deadline = Column(DateTime, nullable=False) deadline = Column(DateTime, nullable=False)
status = Column(String(20), default="open") # 'open', 'closed', 'completed' 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()) created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="events") tenant = relationship("Tenant", back_populates="events")
+138
View File
@@ -234,6 +234,18 @@ body {
margin-bottom: 12px; 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 { .banner-stats {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
@@ -380,6 +392,14 @@ body {
margin-bottom: 16px; 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 { .input-group {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -407,12 +427,38 @@ body {
border-color: var(--accent); 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 { .form-row-2 {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 12px;
} }
.form-row-3 {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
gap: 12px;
}
.readonly-input { .readonly-input {
background: rgba(255, 255, 255, 0.05) !important; background: rgba(255, 255, 255, 0.05) !important;
color: var(--text-muted) !important; color: var(--text-muted) !important;
@@ -677,6 +723,27 @@ body {
font-size: 0.9rem; 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 { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -758,6 +825,11 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
} }
.row-inactive {
opacity: 0.5;
background: rgba(0, 0, 0, 0.2);
}
.mini-items-list { .mini-items-list {
list-style: none; list-style: none;
font-size: 0.85rem; font-size: 0.85rem;
@@ -805,6 +877,11 @@ body {
color: var(--accent-gold); color: var(--accent-gold);
} }
.badge-role.guest {
background: rgba(168, 85, 247, 0.15);
color: #c084fc;
}
/* Invitations List */ /* Invitations List */
.invitations-list { .invitations-list {
display: flex; display: flex;
@@ -884,6 +961,60 @@ body {
background: rgba(255, 255, 255, 0.15); 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 */
.footer { .footer {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -951,6 +1082,13 @@ body {
margin-bottom: 6px; margin-bottom: 6px;
} }
.print-sponsor-box {
margin-top: 8px;
padding: 6px;
border: 1px dashed #000;
font-size: 10pt;
}
.print-section h3 { .print-section h3 {
font-size: 13pt; font-size: 13pt;
margin-bottom: 8px; margin-bottom: 8px;
+302 -23
View File
@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="container"> <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-only">
<div class="print-header"> <div class="print-header">
<h1>🥨 Schmankerl – Brotzeit-Verteilliste</h1> <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>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} &nbsp;|&nbsp;
<strong>Team:</strong> {{ tenant.name }} <strong>Team:</strong> {{ tenant.name }}
</p> </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>
<div class="print-section"> <div class="print-section">
@@ -50,14 +55,31 @@
{% for order in orders %} {% for order in orders %}
<tr> <tr>
<td style="text-align: center; font-size: 1.2rem;">☐</td> <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> <td>
{% for item in order.items %} {% for item in order.items %}
{{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %} {{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td style="text-align: right; font-weight: bold;">{{ "%.2f"|format(order.total_price) }} €</td> <td style="text-align: right; font-weight: bold;">
<td style="text-align: center;">{% if order.is_paid %}Bezahlt ✓{% else %}Offen{% endif %}</td> {% 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> </tr>
{% endfor %} {% endfor %}
</tbody> </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 %}"> <a href="?tab=event{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'event' %}active{% endif %}">
📅 Event- & Frist-Planung 📅 Event- & Frist-Planung
</a> </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 %}"> <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 }}) 👥 Team & Einladungen ({{ team_members|length }})
</a> </a>
@@ -109,6 +134,17 @@
{% endif %} {% endif %}
{% if event %} {% 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 --> <!-- Stats Cards -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
@@ -117,15 +153,19 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Bereits bezahlt</div> <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>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Noch offen</div> <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>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Teilnehmer</div> <div class="stat-label">Teilnehmer</div>
<div class="stat-value">{{ orders|length }} Kollegen</div> <div class="stat-value">{{ orders|length }} Personen</div>
</div> </div>
</div> </div>
@@ -133,7 +173,7 @@
<!-- Aggregated Shopping List --> <!-- Aggregated Shopping List -->
<div class="admin-card"> <div class="admin-card">
<h2>🛒 Sammelliste für Metzger & Bäcker</h2> <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"> <div class="shopping-table-wrapper">
<table class="data-table"> <table class="data-table">
@@ -162,7 +202,7 @@
<!-- Individual Orders & Payment Checklist --> <!-- Individual Orders & Payment Checklist -->
<div class="admin-card"> <div class="admin-card">
<h2>👥 Wer hat bestellt? (Strichliste)</h2> <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"> <div class="orders-table-wrapper">
<table class="data-table"> <table class="data-table">
@@ -178,7 +218,11 @@
{% for order in orders %} {% for order in orders %}
<tr> <tr>
<td> <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> <small class="text-muted">{{ order.user.email }}</small>
</td> </td>
<td> <td>
@@ -188,13 +232,20 @@
{% endfor %} {% endfor %}
</ul> </ul>
</td> </td>
<td><strong>{{ "%.2f"|format(order.total_price) }} €</strong></td>
<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;"> <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 %}"> <button type="submit" class="badge-btn {% if order.is_paid %}paid{% else %}unpaid{% endif %}">
{% if order.is_paid %}✓ Bezahlt{% else %}⏳ Offen{% endif %} {% if order.is_paid %}✓ Bezahlt{% else %}⏳ Offen{% endif %}
</button> </button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}
@@ -220,7 +271,7 @@
{% if event %} {% if event %}
<div class="admin-card"> <div class="admin-card">
<h2>✏️ Aktives Event bearbeiten</h2> <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"> <form action="/admin/events/{{ event.id }}/edit" method="POST" class="admin-form">
<div class="input-group"> <div class="input-group">
@@ -242,6 +293,17 @@
</div> </div>
</div> </div>
<div class="input-group"> <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> <label for="edit_status">Status der Runde</label>
<select id="edit_status" name="status_val"> <select id="edit_status" name="status_val">
<option value="open" {% if event.status == 'open' %}selected{% endif %}>🟢 Geöffnet (Bestellungen möglich)</option> <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> <input type="time" id="new_deadline_time" name="deadline_time" value="08:30" required>
</div> </div>
</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> <button type="submit" class="btn-primary" style="margin-top: 1rem;">Runde jetzt starten</button>
</form> </form>
</div> </div>
</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' %} {% elif active_tab == 'team' %}
<div class="admin-columns"> <div class="admin-columns">
<!-- Generate invite link --> <!-- Generate invite link -->
<div class="admin-card"> <div class="admin-card">
<h2>✉️ Teammitglied einladen</h2> <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"> <form action="/admin/invitations/create" method="POST" class="admin-form">
<div class="input-group"> <div class="input-group">
@@ -307,8 +506,9 @@
<div class="input-group"> <div class="input-group">
<label for="inv_valid">Gültigkeit</label> <label for="inv_valid">Gültigkeit</label>
<select id="inv_valid" name="valid_days"> <select id="inv_valid" name="valid_days">
<option value="unlimited" selected>∞ Unbegrenzt (Dauerhafter Team-Link)</option>
<option value="7">7 Tage</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> <option value="30">30 Tage</option>
</select> </select>
</div> </div>
@@ -317,15 +517,20 @@
</form> </form>
{% if pending_invitations %} {% if pending_invitations %}
<h3 style="margin-top: 2rem;">Offene Einladungslinks</h3> <h3 style="margin-top: 2rem;">Aktive Einladungslinks</h3>
<div class="invitations-list"> <div class="invitations-list">
{% for inv in pending_invitations %} {% for inv in pending_invitations %}
<div class="invite-item"> <div class="invite-item">
<div class="invite-info"> <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> <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> <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>
<div class="invite-actions"> <div class="invite-actions">
<button type="button" class="btn-copy-small" onclick="copyInviteUrl('{{ inv.token }}')">📋 Kopieren</button> <button type="button" class="btn-copy-small" onclick="copyInviteUrl('{{ inv.token }}')">📋 Kopieren</button>
@@ -348,23 +553,37 @@
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name & E-Mail</th>
<th>E-Mail</th>
<th>Rolle</th> <th>Rolle</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for member in team_members %} {% for member in team_members %}
<tr> <tr>
<td><strong>{{ member.first_name }} {{ member.last_name }}</strong></td> <td>
<td><small class="text-muted">{{ member.email }}</small></td> <strong>{{ member.first_name }} {{ member.last_name }}</strong><br>
<small class="text-muted">{{ member.email }}</small>
</td>
<td> <td>
{% if member.role == 'admin' %} {% if member.role == 'admin' %}
<span class="badge-role admin">Admin</span> <span class="badge-role admin">Admin</span>
{% elif member.role == 'guest' %}
<span class="badge-role guest">Gast</span>
{% else %} {% else %}
<span class="badge-role">Mitarbeiter</span> <span class="badge-role">Mitarbeiter</span>
{% endif %} {% endif %}
</td> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -372,6 +591,27 @@
</div> </div>
</div> </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 %} {% endif %}
</div> </div>
@@ -394,8 +634,47 @@
const origin = window.location.origin; const origin = window.location.origin;
const url = origin + '/join?token=' + token; const url = origin + '/join?token=' + token;
navigator.clipboard.writeText(url).then(() => { 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> </script>
{% endblock %} {% endblock %}
+46 -5
View File
@@ -22,6 +22,12 @@
Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong> Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong>
</p> </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"> <div class="banner-stats">
<span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen von Kollegen eingegangen</span> <span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen von Kollegen eingegangen</span>
</div> </div>
@@ -48,7 +54,14 @@
<div class="product-info"> <div class="product-info">
<h3>{{ product.name }}</h3> <h3>{{ product.name }}</h3>
<p class="product-desc">{{ product.description }}</p> <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>
<div class="quantity-control"> <div class="quantity-control">
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)" {% if is_closed %}disabled{% endif %}>-</button> <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 --> <!-- Right: User Details & Summary -->
<div class="summary-section"> <div class="summary-section">
<div class="summary-card"> <div class="summary-card">
<h2>2. Zusammenfassung</h2> <h2>2. Bestellung & Angaben</h2>
{% if current_user %}
<div class="logged-in-badge"> <div class="logged-in-badge">
<div class="user-avatar">👤</div> <div class="user-avatar">👤</div>
<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> <small class="text-muted">{{ current_user.email }}</small>
</div> </div>
</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 %} {% if existing_order %}
<div class="notice-box"> <div class="notice-box">
@@ -87,6 +123,12 @@
<div class="total-amount" id="totalAmount">0,00 €</div> <div class="total-amount" id="totalAmount">0,00 €</div>
</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 %}> <button type="submit" class="btn-submit" id="submitBtn" {% if is_closed %}disabled{% endif %}>
{% if is_closed %} {% if is_closed %}
🔒 Bestellschluss vorüber 🔒 Bestellschluss vorüber
@@ -112,7 +154,7 @@
<div class="empty-icon">🏖️</div> <div class="empty-icon">🏖️</div>
<h2>Aktuell keine offene Bestellrunde</h2> <h2>Aktuell keine offene Bestellrunde</h2>
<p>Für dieses Team ist im Moment keine Brotzeit geplant.</p> <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> <a href="/admin?tab=event" class="btn-primary" style="margin-top: 1rem;">Neue Bestellrunde im Cockpit anlegen</a>
{% endif %} {% endif %}
</div> </div>
@@ -163,7 +205,6 @@
if (containerEl) { if (containerEl) {
containerEl.classList.add('expired'); containerEl.classList.add('expired');
} }
// Formular sperren
if (submitBtn) { if (submitBtn) {
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.innerText = "🔒 Bestellschluss vorüber"; submitBtn.innerText = "🔒 Bestellschluss vorüber";
+9
View File
@@ -62,6 +62,15 @@
<button type="submit" class="btn-submit">Anmelden</button> <button type="submit" class="btn-submit">Anmelden</button>
</form> </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-notice-card">
<div class="invite-icon">✉️</div> <div class="invite-icon">✉️</div>
<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 %}