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:
+339
-86
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 '' }} |
|
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} |
|
||||||
<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 %}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user