feat: Compact 1-page print view, event lifecycle & conflict warnings, bulk email invites, guest pass link and post-event guest cleanup
This commit is contained in:
+168
-32
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
@@ -98,7 +99,7 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request, db: Session = Depends(get_db)):
|
def index(request: Request, event_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
if not tenant:
|
if not tenant:
|
||||||
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
|
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
|
||||||
@@ -106,16 +107,25 @@ def index(request: Request, db: Session = Depends(get_db)):
|
|||||||
current_user = get_current_user(request, db)
|
current_user = get_current_user(request, db)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
# Aktuelle offene Runde suchen
|
# Alle offenen Events laden
|
||||||
event = db.query(Event).filter(
|
open_events = db.query(Event).filter(
|
||||||
Event.tenant_id == tenant.id,
|
Event.tenant_id == tenant.id,
|
||||||
Event.status == "open"
|
Event.status == "open"
|
||||||
).order_by(Event.deadline.asc()).first()
|
).order_by(Event.event_date.asc(), Event.deadline.asc()).all()
|
||||||
|
|
||||||
# Wenn keine offene, das neueste anzeigen
|
# Gewünschtes Event wählen
|
||||||
|
event = None
|
||||||
|
if event_id:
|
||||||
|
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
|
if not event and open_events:
|
||||||
|
event = open_events[0]
|
||||||
if not event:
|
if not event:
|
||||||
|
# Neuestes Event überhaupt anzeigen
|
||||||
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()
|
||||||
|
|
||||||
|
# Gast-Modus
|
||||||
|
is_guest_mode = (request.query_params.get("guest") == "1")
|
||||||
|
|
||||||
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
|
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
|
||||||
if not current_user:
|
if not current_user:
|
||||||
if not event or not event.allow_guests:
|
if not event or not event.allow_guests:
|
||||||
@@ -157,6 +167,8 @@ def index(request: Request, db: Session = Depends(get_db)):
|
|||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"event": event,
|
"event": event,
|
||||||
|
"open_events": open_events,
|
||||||
|
"is_guest_mode": is_guest_mode,
|
||||||
"is_closed": is_closed,
|
"is_closed": is_closed,
|
||||||
"seconds_remaining": seconds_remaining,
|
"seconds_remaining": seconds_remaining,
|
||||||
"products": products,
|
"products": products,
|
||||||
@@ -177,7 +189,6 @@ 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(
|
active_event = db.query(Event).filter(
|
||||||
Event.tenant_id == tenant.id,
|
Event.tenant_id == tenant.id,
|
||||||
Event.status == "open",
|
Event.status == "open",
|
||||||
@@ -190,6 +201,7 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
|
|||||||
"error": error,
|
"error": error,
|
||||||
"success": success,
|
"success": success,
|
||||||
"allow_guests": allow_guests,
|
"allow_guests": allow_guests,
|
||||||
|
"active_event": active_event,
|
||||||
"current_user": None
|
"current_user": None
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -328,7 +340,6 @@ def process_join(
|
|||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
|
|
||||||
# Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
|
|
||||||
if not invitation.is_reusable:
|
if not invitation.is_reusable:
|
||||||
invitation.used_at = datetime.now()
|
invitation.used_at = datetime.now()
|
||||||
|
|
||||||
@@ -360,16 +371,17 @@ 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
|
guest_name_str = None
|
||||||
if not current_user:
|
if not current_user:
|
||||||
if not event.allow_guests:
|
if not event.allow_guests:
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
if not guest_first_name or not guest_first_name.strip():
|
if not guest_first_name or not guest_first_name.strip():
|
||||||
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Vornamen an!")
|
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
|
||||||
|
|
||||||
fname = guest_first_name.strip()
|
fname = guest_first_name.strip()
|
||||||
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
|
lname = guest_last_name.strip() if guest_last_name else "(Gast)"
|
||||||
|
guest_name_str = f"{fname} {lname}"
|
||||||
clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local")
|
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(
|
guest_user = User(
|
||||||
@@ -386,6 +398,8 @@ async def place_order(
|
|||||||
db.refresh(guest_user)
|
db.refresh(guest_user)
|
||||||
current_user = guest_user
|
current_user = guest_user
|
||||||
request.session["user_id"] = guest_user.id
|
request.session["user_id"] = guest_user.id
|
||||||
|
else:
|
||||||
|
guest_name_str = f"{current_user.first_name} {current_user.last_name}"
|
||||||
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
order_items = []
|
order_items = []
|
||||||
@@ -411,7 +425,6 @@ async def place_order(
|
|||||||
if not order_items:
|
if not order_items:
|
||||||
raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!")
|
raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!")
|
||||||
|
|
||||||
# Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen
|
|
||||||
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,
|
||||||
@@ -421,6 +434,7 @@ async def place_order(
|
|||||||
if existing_order:
|
if existing_order:
|
||||||
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
|
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
|
||||||
existing_order.total_price = total_price
|
existing_order.total_price = total_price
|
||||||
|
existing_order.guest_display_name = guest_name_str
|
||||||
for item in order_items:
|
for item in order_items:
|
||||||
item.order_id = existing_order.id
|
item.order_id = existing_order.id
|
||||||
db.add(item)
|
db.add(item)
|
||||||
@@ -431,6 +445,7 @@ async def place_order(
|
|||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
event_id=event.id,
|
event_id=event.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
guest_display_name=guest_name_str,
|
||||||
total_price=total_price,
|
total_price=total_price,
|
||||||
is_paid=False
|
is_paid=False
|
||||||
)
|
)
|
||||||
@@ -470,6 +485,9 @@ def admin_dashboard(
|
|||||||
request: Request,
|
request: Request,
|
||||||
event_id: Optional[int] = None,
|
event_id: Optional[int] = None,
|
||||||
tab: str = "orders",
|
tab: str = "orders",
|
||||||
|
warn_active_id: Optional[int] = None,
|
||||||
|
success: Optional[str] = None,
|
||||||
|
bulk_count: Optional[int] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
@@ -483,14 +501,21 @@ def admin_dashboard(
|
|||||||
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
|
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
|
||||||
|
|
||||||
# Alle Events des Mandanten
|
# Alle Events des Mandanten
|
||||||
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).all()
|
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.event_date.desc(), Event.id.desc()).all()
|
||||||
|
|
||||||
# Ausgewähltes Event bestimmen
|
# Ausgewähltes Event bestimmen: Wenn event_id übergeben, nehmen wir das, sonst das erste offene, sonst das neueste
|
||||||
selected_event = None
|
selected_event = None
|
||||||
if event_id:
|
if event_id:
|
||||||
selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
if not selected_event and all_events:
|
|
||||||
selected_event = all_events[0]
|
if not selected_event:
|
||||||
|
# Bevorzuge aktives offenes Event
|
||||||
|
for ev in all_events:
|
||||||
|
if ev.status == "open":
|
||||||
|
selected_event = ev
|
||||||
|
break
|
||||||
|
if not selected_event and all_events:
|
||||||
|
selected_event = all_events[0]
|
||||||
|
|
||||||
orders = []
|
orders = []
|
||||||
item_sums = {}
|
item_sums = {}
|
||||||
@@ -511,19 +536,33 @@ def admin_dashboard(
|
|||||||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||||||
item_sums[pname]["quantity"] += it.quantity
|
item_sums[pname]["quantity"] += it.quantity
|
||||||
|
|
||||||
# Teammitglieder & Einladungen laden
|
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
|
||||||
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,
|
||||||
# Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft)
|
User.role != "guest"
|
||||||
|
).order_by(User.role.asc(), User.first_name.asc()).all()
|
||||||
|
|
||||||
|
# Aktive Gäste laden
|
||||||
|
active_guests = db.query(User).filter(
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.role == "guest"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Offene Einladungen
|
||||||
now = datetime.now()
|
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.expires_at == None) | (Invitation.expires_at > now)
|
(Invitation.expires_at == None) | (Invitation.expires_at > now)
|
||||||
).order_by(Invitation.created_at.desc()).all()
|
).order_by(Invitation.created_at.desc()).all()
|
||||||
|
|
||||||
# Produkte des Mandanten laden
|
# Produkte laden
|
||||||
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
|
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
|
||||||
|
|
||||||
|
# Warnung bei existierendem aktivem Event
|
||||||
|
active_warning_event = None
|
||||||
|
if warn_active_id:
|
||||||
|
active_warning_event = db.query(Event).filter(Event.id == warn_active_id, Event.tenant_id == tenant.id).first()
|
||||||
|
|
||||||
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,
|
||||||
@@ -534,9 +573,13 @@ def admin_dashboard(
|
|||||||
"total_revenue": total_revenue,
|
"total_revenue": total_revenue,
|
||||||
"total_paid": total_paid,
|
"total_paid": total_paid,
|
||||||
"team_members": team_members,
|
"team_members": team_members,
|
||||||
|
"active_guests": active_guests,
|
||||||
"pending_invitations": pending_invitations,
|
"pending_invitations": pending_invitations,
|
||||||
"products": products,
|
"products": products,
|
||||||
"active_tab": tab,
|
"active_tab": tab,
|
||||||
|
"warn_active_event": active_warning_event,
|
||||||
|
"success": success,
|
||||||
|
"bulk_count": bulk_count,
|
||||||
"now": now
|
"now": now
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -580,20 +623,25 @@ def admin_delete_user(
|
|||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
|
||||||
if user:
|
if user:
|
||||||
|
# Eventuelle Bestellungen von user_id entkoppeln, damit Beträge im Archiv nicht verloren gehen
|
||||||
|
orders = db.query(Order).filter(Order.user_id == user.id).all()
|
||||||
|
for ord_obj in orders:
|
||||||
|
ord_obj.guest_display_name = f"{user.first_name} {user.last_name}"
|
||||||
|
ord_obj.user_id = None
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
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 INVITATIONS ---
|
# --- ADMIN INVITATIONS (EINZELN & BULK) ---
|
||||||
|
|
||||||
@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: str = Form("14"),
|
valid_days: str = Form("unlimited"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
@@ -602,7 +650,6 @@ def admin_create_invitation(
|
|||||||
token = secrets.token_urlsafe(20)
|
token = secrets.token_urlsafe(20)
|
||||||
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"]:
|
if valid_days in ["0", "unlimited", "forever"]:
|
||||||
expires_at = None
|
expires_at = None
|
||||||
is_reusable = True
|
is_reusable = True
|
||||||
@@ -626,6 +673,50 @@ def admin_create_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)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/invitations/bulk")
|
||||||
|
def admin_bulk_invitations(
|
||||||
|
request: Request,
|
||||||
|
emails_text: str = Form(...),
|
||||||
|
role: str = Form("user"),
|
||||||
|
valid_days: str = Form("14"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
admin = require_admin(request, db)
|
||||||
|
|
||||||
|
# E-Mails per Regex extrahieren (unterstützt Komma, Semikolon, Umbrüche etc.)
|
||||||
|
email_pattern = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+'
|
||||||
|
found_emails = list(set([e.lower().strip() for e in re.findall(email_pattern, emails_text)]))
|
||||||
|
|
||||||
|
if not found_emails:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine gültigen E-Mail-Adressen gefunden.")
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
is_reusable = False
|
||||||
|
if valid_days in ["0", "unlimited", "forever"]:
|
||||||
|
is_reusable = True
|
||||||
|
else:
|
||||||
|
expires_at = datetime.now() + timedelta(days=int(valid_days))
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for em in found_emails:
|
||||||
|
token = secrets.token_urlsafe(20)
|
||||||
|
inv = Invitation(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
token=token,
|
||||||
|
email=em,
|
||||||
|
role=role,
|
||||||
|
created_by=admin.id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
is_reusable=is_reusable
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url=f"/admin?tab=team&bulk_count={count}", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/invitations/{inv_id}/revoke")
|
@app.post("/admin/invitations/{inv_id}/revoke")
|
||||||
def admin_revoke_invitation(
|
def admin_revoke_invitation(
|
||||||
inv_id: int,
|
inv_id: int,
|
||||||
@@ -654,6 +745,7 @@ def admin_create_event(
|
|||||||
deadline_time: str = Form(...),
|
deadline_time: str = Form(...),
|
||||||
sponsor_name: Optional[str] = Form(None),
|
sponsor_name: Optional[str] = Form(None),
|
||||||
allow_guests: bool = Form(True),
|
allow_guests: bool = Form(True),
|
||||||
|
force: bool = Form(False),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
@@ -666,6 +758,20 @@ def admin_create_event(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
||||||
|
|
||||||
|
# Prüfung: Gibt es bereits ein aktives offenes Event in der Zukunft?
|
||||||
|
if not force:
|
||||||
|
active_existing = db.query(Event).filter(
|
||||||
|
Event.tenant_id == tenant.id,
|
||||||
|
Event.status == "open",
|
||||||
|
Event.event_date >= date.today()
|
||||||
|
).first()
|
||||||
|
if active_existing:
|
||||||
|
# Weiterleitung mit Warn-Hinweis
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin?tab=event&warn_active_id={active_existing.id}",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
new_event = Event(
|
new_event = Event(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
title=title.strip(),
|
title=title.strip(),
|
||||||
@@ -679,7 +785,7 @@ def admin_create_event(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_event)
|
db.refresh(new_event)
|
||||||
|
|
||||||
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/events/{event_id}/edit")
|
@app.post("/admin/events/{event_id}/edit")
|
||||||
@@ -720,8 +826,40 @@ def admin_edit_event(
|
|||||||
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
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")
|
@app.post("/admin/events/{event_id}/complete-and-cleanup")
|
||||||
def admin_toggle_event_status(
|
def admin_complete_event_and_cleanup_guests(
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Schließt das Event ab und bereinigt temporäre Gast-Konten."""
|
||||||
|
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 = "completed"
|
||||||
|
|
||||||
|
# Gast-Bestellungen sichern und Gast-Konten entfernen
|
||||||
|
orders = db.query(Order).filter(Order.event_id == event.id).all()
|
||||||
|
cleaned_count = 0
|
||||||
|
for ord_obj in orders:
|
||||||
|
if ord_obj.user and ord_obj.user.role == "guest":
|
||||||
|
ord_obj.guest_display_name = f"{ord_obj.user.first_name} {ord_obj.user.last_name}"
|
||||||
|
guest_user = ord_obj.user
|
||||||
|
ord_obj.user_id = None
|
||||||
|
db.delete(guest_user)
|
||||||
|
cleaned_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
msg = f"Event erfolgreich abgeschlossen. {cleaned_count} Gastkonten bereinigt."
|
||||||
|
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success={msg}", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/events/{event_id}/delete")
|
||||||
|
def admin_delete_event(
|
||||||
event_id: int,
|
event_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
@@ -730,13 +868,11 @@ def admin_toggle_event_status(
|
|||||||
require_admin(request, db)
|
require_admin(request, db)
|
||||||
|
|
||||||
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:
|
if event:
|
||||||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
db.delete(event)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
event.status = "closed" if event.status == "open" else "open"
|
return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
|
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
|
||||||
@@ -885,7 +1021,7 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
|
|||||||
if order:
|
if order:
|
||||||
order.is_paid = not order.is_paid
|
order.is_paid = not order.is_paid
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url=f"/admin?event_id={order.event_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
+9
-2
@@ -35,7 +35,7 @@ class User(Base):
|
|||||||
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", cascade="all, delete-orphan")
|
orders = relationship("Order", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
class Invitation(Base):
|
class Invitation(Base):
|
||||||
@@ -93,7 +93,8 @@ class Order(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||||
event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
|
event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
guest_display_name = Column(String(150), nullable=True)
|
||||||
total_price = Column(Numeric(10, 2), nullable=False, default=0.00)
|
total_price = Column(Numeric(10, 2), nullable=False, default=0.00)
|
||||||
is_paid = Column(Boolean, default=False)
|
is_paid = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
@@ -103,6 +104,12 @@ class Order(Base):
|
|||||||
user = relationship("User", back_populates="orders")
|
user = relationship("User", back_populates="orders")
|
||||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
if self.user:
|
||||||
|
return f"{self.user.first_name} {self.user.last_name}"
|
||||||
|
return self.guest_display_name or "Gast"
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(Base):
|
class OrderItem(Base):
|
||||||
__tablename__ = "order_items"
|
__tablename__ = "order_items"
|
||||||
|
|||||||
+269
-86
@@ -139,6 +139,47 @@ body {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-Event Banner on Order Page */
|
||||||
|
.multi-event-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-event-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chip {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0b0f19;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Banner */
|
/* Banner */
|
||||||
.banner {
|
.banner {
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.9) 100%);
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.9) 100%);
|
||||||
@@ -656,6 +697,131 @@ body {
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-warning-card {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning-card h3 {
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Events Ribbon in Admin */
|
||||||
|
.events-ribbon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(11, 15, 25, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-ribbon-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-ribbon-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip.active {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip.completed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-date {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-badge.open {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-badge.closed {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-badge.comp {
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Action Bar */
|
||||||
|
.event-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(11, 15, 25, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success-small {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
|
color: #86efac;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success-small:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Admin Dashboard Tabs */
|
/* Admin Dashboard Tabs */
|
||||||
.admin-header {
|
.admin-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -669,6 +835,7 @@ body {
|
|||||||
.admin-actions-top {
|
.admin-actions-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sub {
|
.admin-sub {
|
||||||
@@ -703,26 +870,6 @@ body {
|
|||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-selector-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: rgba(11, 15, 25, 0.6);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-selector-bar select {
|
|
||||||
background: rgba(11, 15, 25, 0.9);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-main);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsor-highlight-card {
|
.sponsor-highlight-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -882,6 +1029,20 @@ body {
|
|||||||
color: #c084fc;
|
color: #c084fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bulk Invite Box */
|
||||||
|
.bulk-invite-box {
|
||||||
|
background: rgba(11, 15, 25, 0.7);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-invite-box h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Invitations List */
|
/* Invitations List */
|
||||||
.invitations-list {
|
.invitations-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -986,35 +1147,6 @@ body {
|
|||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
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);
|
||||||
@@ -1047,19 +1179,32 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- PRINT MEDIA QUERY --- */
|
/* ================= PRINT MEDIA QUERY (1-PAGE A4) ================= */
|
||||||
.print-only {
|
.print-only {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4 portrait;
|
||||||
|
margin: 8mm 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
* {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
color: #000000 !important;
|
color: #000000 !important;
|
||||||
font-size: 11pt;
|
font-size: 8.5pt !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn {
|
.navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn, .events-ribbon, .admin-nav-tabs, .admin-header {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,47 +1216,85 @@ body {
|
|||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-header {
|
/* Single-line condensed header */
|
||||||
|
.print-header-singleline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
border-bottom: 2px solid #000;
|
border-bottom: 2px solid #000;
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-header h1 {
|
|
||||||
font-size: 18pt;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-sponsor-box {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
border: 1px dashed #000;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-section h3 {
|
|
||||||
font-size: 13pt;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
margin-bottom: 6px;
|
||||||
|
|
||||||
.print-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-table th, .print-table td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 6px 10px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-logo {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 11pt;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-sponsor {
|
||||||
|
font-weight: bold;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact Sammel-Box */
|
||||||
|
.print-sammel-box {
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-sammel-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 8px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-item-pill {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 1px solid #94a3b8;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
margin: 1px 3px 1px 0;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact Ausgabeliste */
|
||||||
|
.print-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-table th, .print-table td {
|
||||||
|
border: 1px solid #64748b;
|
||||||
|
padding: 2.5px 5px !important;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 8.5pt !important;
|
||||||
|
line-height: 1.15 !important;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.print-table th {
|
.print-table th {
|
||||||
background-color: #f2f2f2 !important;
|
background-color: #e2e8f0 !important;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-items-cell {
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-tag {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
font-style: italic;
|
||||||
|
color: #475569;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+272
-135
@@ -3,91 +3,87 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- Printable distribution sheet for the kitchen (hidden on screen, visible in print) -->
|
<!-- ================= COMPACT PRINT VIEW (1-PAGE A4) ================= -->
|
||||||
<div class="print-only">
|
<div class="print-only">
|
||||||
<div class="print-header">
|
<!-- Single-line condensed header -->
|
||||||
<h1>🥨 Schmankerl – Brotzeit-Verteilliste</h1>
|
<div class="print-header-singleline">
|
||||||
<p>
|
<div class="print-left">
|
||||||
<strong>Event:</strong> {{ event.title if event else 'Brotzeitrunde' }} |
|
<span class="print-logo">🥨 Schmankerl</span>
|
||||||
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} |
|
<strong>{{ event.title if event else 'Brotzeitrunde' }}</strong>
|
||||||
<strong>Team:</strong> {{ tenant.name }}
|
<span class="print-date">({{ event.event_date.strftime('%d.%m.%Y') if event else '' }})</span>
|
||||||
</p>
|
</div>
|
||||||
{% if event and event.sponsor_name %}
|
<div class="print-right">
|
||||||
<div class="print-sponsor-box">
|
<span>Teilnehmer: <strong>{{ orders|length }}</strong></span>
|
||||||
🎉 <strong>GESPONSERTE RUNDE:</strong> Ausgegeben von <strong>{{ event.sponsor_name }}</strong> – Gesamtrechnung: <strong>{{ "%.2f"|format(total_revenue) }} €</strong>
|
<span> | </span>
|
||||||
|
<span>Einkauf: <strong>{{ "%.2f"|format(total_revenue) }} €</strong></span>
|
||||||
|
{% if event and event.sponsor_name %}
|
||||||
|
<span> | </span>
|
||||||
|
<span class="print-sponsor">🎉 Ausgegeben von: <strong>{{ event.sponsor_name }}</strong></span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="print-section">
|
<!-- Compact Sammelliste (Horizontal / 2-Column) -->
|
||||||
<h3>🛒 Sammelbestellung (Metzger & Bäcker)</h3>
|
<div class="print-sammel-box">
|
||||||
<table class="print-table">
|
<span class="print-sammel-title">🛒 Einkauf (Bäcker & Metzger):</span>
|
||||||
<thead>
|
{% for name, data in item_sums.items() %}
|
||||||
<tr>
|
<span class="print-item-pill">
|
||||||
<th>Artikel</th>
|
<strong>{{ data.quantity }}x</strong> {{ name }}
|
||||||
<th style="text-align: right;">Gesamtmenge</th>
|
</span>
|
||||||
</tr>
|
{% else %}
|
||||||
</thead>
|
<span>Keine Artikel</span>
|
||||||
<tbody>
|
{% endfor %}
|
||||||
{% for name, data in item_sums.items() %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ name }}</td>
|
|
||||||
<td style="text-align: right; font-weight: bold;">{{ data.quantity }} {{ data.unit }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="print-section" style="margin-top: 1.5rem;">
|
<!-- Ausgabeliste zum Abhaken -->
|
||||||
<h3>👥 Ausgabe- & Strichliste (zum Abhaken)</h3>
|
<table class="print-table">
|
||||||
<table class="print-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th style="width: 28px; text-align: center;">☐</th>
|
||||||
<th style="width: 40px; text-align: center;">Abgehakt</th>
|
<th style="width: 25%;">Kollege / Name</th>
|
||||||
<th>Kollege / Name</th>
|
<th>Bestellte Artikel</th>
|
||||||
<th>Bestellte Artikel</th>
|
<th style="width: 70px; text-align: right;">Betrag</th>
|
||||||
<th style="text-align: right;">Betrag</th>
|
<th style="width: 85px; text-align: center;">Kasse</th>
|
||||||
<th style="text-align: center;">Status</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for order in orders %}
|
||||||
{% for order in orders %}
|
<tr>
|
||||||
<tr>
|
<td style="text-align: center; font-size: 10pt;">☐</td>
|
||||||
<td style="text-align: center; font-size: 1.2rem;">☐</td>
|
<td>
|
||||||
<td>
|
<strong>{{ order.display_name }}</strong>
|
||||||
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
|
{% if order.user and order.user.role == 'guest' %}<small class="guest-tag">(Gast)</small>{% endif %}
|
||||||
{% if order.user.role == 'guest' %}<small>(Gast)</small>{% endif %}
|
</td>
|
||||||
</td>
|
<td class="print-items-cell">
|
||||||
<td>
|
{% for item in order.items %}
|
||||||
{% for item in order.items %}
|
<span>{{ item.quantity }}x {{ item.product.name }}</span>{% 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;">
|
||||||
<td style="text-align: right; font-weight: bold;">
|
{% if event and event.sponsor_name %}
|
||||||
{% if event and event.sponsor_name %}
|
<span style="font-size: 8pt; color: #555;">Sponsor</span>
|
||||||
<span style="color: #666; font-size: 9pt;">Gesponsert</span>
|
{% else %}
|
||||||
{% else %}
|
{{ "%.2f"|format(order.total_price) }} €
|
||||||
{{ "%.2f"|format(order.total_price) }} €
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
<td style="text-align: center; font-size: 8pt;">
|
||||||
<td style="text-align: center;">
|
{% if event and event.sponsor_name %}
|
||||||
{% if event and event.sponsor_name %}
|
Übernommen
|
||||||
Übernommen ✓
|
{% elif order.is_paid %}
|
||||||
{% elif order.is_paid %}
|
Bezahlt ✓
|
||||||
Bezahlt ✓
|
{% else %}
|
||||||
{% else %}
|
Offen
|
||||||
Offen
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
{% else %}
|
||||||
{% endfor %}
|
<tr><td colspan="5" style="text-align: center;">Keine Bestellungen vorhanden.</td></tr>
|
||||||
</tbody>
|
{% endfor %}
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Normal Screen Content -->
|
<!-- ================= NORMAL SCREEN VIEW ================= -->
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -97,11 +93,46 @@
|
|||||||
<div class="admin-actions-top">
|
<div class="admin-actions-top">
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
|
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
|
||||||
<button onclick="window.print()" class="btn-secondary">🖨️ Verteilliste drucken</button>
|
<button onclick="copyGuestPassLink('{{ event.id }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
|
||||||
|
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert-success" style="margin-bottom: 20px;">
|
||||||
|
✓ {{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if bulk_count %}
|
||||||
|
<div class="alert-success" style="margin-bottom: 20px;">
|
||||||
|
✓ Erfolgreich <strong>{{ bulk_count }}</strong> Einladungen generiert! Die Links sind unten aufgeführt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- EVENT-LEISTE (Zeigt alle Events transparent an) -->
|
||||||
|
<div class="events-ribbon">
|
||||||
|
<div class="events-ribbon-title">
|
||||||
|
<span>📅 Brotzeitrunden:</span>
|
||||||
|
</div>
|
||||||
|
<div class="events-ribbon-list">
|
||||||
|
{% for ev in all_events %}
|
||||||
|
<a href="?tab={{ active_tab }}&event_id={{ ev.id }}" class="event-chip {% if event and event.id == ev.id %}active{% endif %} {% if ev.status == 'completed' %}completed{% endif %}">
|
||||||
|
<span class="chip-date">{{ ev.event_date.strftime('%d.%m.') }}</span>
|
||||||
|
<span class="chip-title">{{ ev.title }}</span>
|
||||||
|
{% if ev.status == 'open' %}
|
||||||
|
<span class="chip-badge open">🟢 Offen</span>
|
||||||
|
{% elif ev.status == 'completed' %}
|
||||||
|
<span class="chip-badge comp">🏁 Abgeschlossen</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip-badge closed">🔴 Geschlossen</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="admin-nav-tabs">
|
<div class="admin-nav-tabs">
|
||||||
<a href="?tab=orders{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'orders' %}active{% endif %}">
|
<a href="?tab=orders{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'orders' %}active{% endif %}">
|
||||||
@@ -111,30 +142,34 @@
|
|||||||
📅 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 %}">
|
<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 }})
|
🥨 Sortiment ({{ products|length }})
|
||||||
</a>
|
</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 & Massen-Einladung ({{ team_members|length }})
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 1: BESTELLUNGEN & KASSE -->
|
<!-- TAB 1: BESTELLUNGEN & KASSE -->
|
||||||
{% if active_tab == 'orders' %}
|
{% if active_tab == 'orders' %}
|
||||||
{% if all_events|length > 1 %}
|
|
||||||
<div class="event-selector-bar">
|
|
||||||
<label for="eventSelect">Runde auswählen:</label>
|
|
||||||
<select id="eventSelect" onchange="window.location.href='?tab=orders&event_id=' + this.value;">
|
|
||||||
{% for ev in all_events %}
|
|
||||||
<option value="{{ ev.id }}" {% if event and event.id == ev.id %}selected{% endif %}>
|
|
||||||
{{ ev.title }} ({{ ev.event_date.strftime('%d.%m.%Y') }}) – {% if ev.status == 'open' %}🟢 Offen{% else %}🔴 Geschlossen{% endif %}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if event %}
|
{% if event %}
|
||||||
|
|
||||||
|
<!-- Event Action Bar -->
|
||||||
|
<div class="event-action-bar">
|
||||||
|
<div class="event-info-left">
|
||||||
|
<h2>{{ event.title }}</h2>
|
||||||
|
<span class="text-muted">Termin: <strong>{{ event.event_date.strftime('%d.%m.%Y') }}</strong> | Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m. um %H:%M Uhr') }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="event-actions-right">
|
||||||
|
{% if event.status != 'completed' %}
|
||||||
|
<form action="/admin/events/{{ event.id }}/complete-and-cleanup" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du dieses Event abschließen und die temporären Gastkonten bereinigen?');">
|
||||||
|
<button type="submit" class="btn-success-small">✅ Event abschließen & Gastkonten aufräumen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-role" style="background: rgba(34, 197, 94, 0.2); color: #86efac; font-size: 0.85rem;">🏁 Abgeschlossen & Gastkonten archiviert</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if event.sponsor_name %}
|
{% if event.sponsor_name %}
|
||||||
<div class="sponsor-highlight-card">
|
<div class="sponsor-highlight-card">
|
||||||
<div class="sponsor-icon">🎁</div>
|
<div class="sponsor-icon">🎁</div>
|
||||||
@@ -208,7 +243,7 @@
|
|||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Kollege</th>
|
<th>Kollege / Gast</th>
|
||||||
<th>Bestellung</th>
|
<th>Bestellung</th>
|
||||||
<th>Betrag</th>
|
<th>Betrag</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -218,12 +253,15 @@
|
|||||||
{% for order in orders %}
|
{% for order in orders %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
|
<strong>{{ order.display_name }}</strong>
|
||||||
{% if order.user.role == 'guest' %}
|
{% if order.user and order.user.role == 'guest' %}
|
||||||
<span class="badge-role guest">Gast</span>
|
<span class="badge-role guest">Gast</span>
|
||||||
|
{% elif not order.user and order.guest_display_name %}
|
||||||
|
<span class="badge-role" style="background: rgba(148, 163, 184, 0.15); color: #cbd5e1;">Archivierter Gast</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if order.user %}
|
||||||
|
<br><small class="text-muted">{{ order.user.email }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br>
|
|
||||||
<small class="text-muted">{{ order.user.email }}</small>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ul class="mini-items-list">
|
<ul class="mini-items-list">
|
||||||
@@ -259,19 +297,41 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h2>Keine offene Bestellrunde gefunden</h2>
|
<h2>Keine Runde ausgewählt</h2>
|
||||||
<p>Erstelle im Reiter <strong>Event- & Frist-Planung</strong> eine neue Runde.</p>
|
<p>Wähle oben eine Runde aus oder erstelle eine neue.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- TAB 2: EVENT- & FRIST-PLANUNG -->
|
<!-- TAB 2: EVENT- & FRIST-PLANUNG -->
|
||||||
{% elif active_tab == 'event' %}
|
{% elif active_tab == 'event' %}
|
||||||
|
|
||||||
|
{% if warn_active_event %}
|
||||||
|
<div class="alert-warning-card">
|
||||||
|
<h3>⚠️ Aktives Event läuft bereits!</h3>
|
||||||
|
<p>
|
||||||
|
Es gibt aktuell noch das geplante Event <strong>{{ warn_active_event.title }}</strong> am <strong>{{ warn_active_event.event_date.strftime('%d.%m.%Y') }}</strong>.
|
||||||
|
Möchtest du das bisherige Event bearbeiten oder wirklich ein neues, paralleles Event starten?
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; margin-top: 12px;">
|
||||||
|
<a href="?tab=orders&event_id={{ warn_active_event.id }}" class="btn-primary">Zum bisherigen Event</a>
|
||||||
|
<button type="button" class="btn-secondary" onclick="document.getElementById('forceInput').value='true'; document.getElementById('newEventForm').submit();">Trotzdem neues Event anlegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="admin-columns">
|
<div class="admin-columns">
|
||||||
<!-- Edit current event -->
|
<!-- Edit current event -->
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2>✏️ Aktives Event bearbeiten</h2>
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
<p class="card-hint">Passe Titel, Frist, Gast-Erlaubnis und Sponsor an:</p>
|
<div>
|
||||||
|
<h2>✏️ Event bearbeiten</h2>
|
||||||
|
<p class="card-hint">Aktuelle Runde: <strong>{{ event.title }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/events/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Event und zugehörige Bestellungen wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn-revoke-small" style="font-size: 0.8rem;" title="Event löschen">🗑️ Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -295,12 +355,11 @@
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="edit_sponsor">🎉 Sponsor / Anlass (Optional)</label>
|
<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">
|
<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>
|
||||||
<div class="input-group-checkbox">
|
<div class="input-group-checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="allow_guests" value="true" {% if event.allow_guests %}checked{% endif %}>
|
<input type="checkbox" name="allow_guests" value="true" {% if event.allow_guests %}checked{% endif %}>
|
||||||
<strong>Gäste-Bestellungen erlauben</strong> (Mitbestellen ohne Registrierung)
|
<strong>Gäste- & Dienstleister-Bestellungen erlauben</strong>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group" style="margin-top: 14px;">
|
<div class="input-group" style="margin-top: 14px;">
|
||||||
@@ -308,6 +367,7 @@
|
|||||||
<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>
|
||||||
<option value="closed" {% if event.status == 'closed' %}selected{% endif %}>🔴 Geschlossen (Bestellungen gesperrt)</option>
|
<option value="closed" {% if event.status == 'closed' %}selected{% endif %}>🔴 Geschlossen (Bestellungen gesperrt)</option>
|
||||||
|
<option value="completed" {% if event.status == 'completed' %}selected{% endif %}>🏁 Abgeschlossen (Archiviert)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Änderungen speichern</button>
|
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Änderungen speichern</button>
|
||||||
@@ -320,7 +380,8 @@
|
|||||||
<h2>➕ Neue Brotzeitrunde planen</h2>
|
<h2>➕ Neue Brotzeitrunde planen</h2>
|
||||||
<p class="card-hint">Erstelle einen neuen Termin für dein Team:</p>
|
<p class="card-hint">Erstelle einen neuen Termin für dein Team:</p>
|
||||||
|
|
||||||
<form action="/admin/events/new" method="POST" class="admin-form">
|
<form action="/admin/events/new" method="POST" id="newEventForm" class="admin-form">
|
||||||
|
<input type="hidden" id="forceInput" name="force" value="false">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="new_title">Titel</label>
|
<label for="new_title">Titel</label>
|
||||||
<input type="text" id="new_title" name="title" placeholder="z. B. Weißwurstfrühstück am Freitag" required>
|
<input type="text" id="new_title" name="title" placeholder="z. B. Weißwurstfrühstück am Freitag" required>
|
||||||
@@ -346,7 +407,7 @@
|
|||||||
<div class="input-group-checkbox">
|
<div class="input-group-checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="allow_guests" value="true" checked>
|
<input type="checkbox" name="allow_guests" value="true" checked>
|
||||||
<strong>Gäste-Bestellungen erlauben</strong>
|
<strong>Gäste- & Dienstleister-Bestellungen erlauben</strong>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
@@ -357,7 +418,6 @@
|
|||||||
<!-- TAB 3: PRODUKTE & SORTIMENT -->
|
<!-- TAB 3: PRODUKTE & SORTIMENT -->
|
||||||
{% elif active_tab == 'products' %}
|
{% elif active_tab == 'products' %}
|
||||||
<div class="admin-columns">
|
<div class="admin-columns">
|
||||||
<!-- Product List -->
|
|
||||||
<div class="admin-card" style="grid-column: 1 / -1;">
|
<div class="admin-card" style="grid-column: 1 / -1;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<div>
|
<div>
|
||||||
@@ -442,11 +502,10 @@
|
|||||||
<button type="submit" class="btn-primary" style="margin-top: 10px;">Artikel speichern</button>
|
<button type="submit" class="btn-primary" style="margin-top: 10px;">Artikel speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Product Modal (Simple JS) -->
|
<!-- Edit Product Modal -->
|
||||||
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3>✏️ Produkt bearbeiten</h3>
|
<h3>✏️ Produkt bearbeiten</h3>
|
||||||
@@ -481,51 +540,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 4: TEAM & EINLADUNGEN -->
|
<!-- TAB 4: TEAM & MASSEN-EINLADUNG -->
|
||||||
{% elif active_tab == 'team' %}
|
{% elif active_tab == 'team' %}
|
||||||
<div class="admin-columns">
|
<div class="admin-columns">
|
||||||
<!-- Generate invite link -->
|
<!-- Einladungs-Bereich (Einzeln & Massen-Einladung) -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2>✉️ Teammitglied einladen</h2>
|
<h2>✉️ Team einladen</h2>
|
||||||
<p class="card-hint">Erzeuge einen Einladungslink (einmalig oder unbegrenzt dauerhaft):</p>
|
|
||||||
|
<!-- Bulk Invite Form -->
|
||||||
|
<div class="bulk-invite-box">
|
||||||
|
<h3>⚡ Massen-Einladung (Mehrere E-Mails)</h3>
|
||||||
|
<p class="card-hint">Füge mehrere Adressen kommagetrennt oder zeilenweise ein (z. B. aus Outlook/Slack kopiert):</p>
|
||||||
|
<form action="/admin/invitations/bulk" method="POST" class="admin-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea name="emails_text" rows="3" placeholder="anna@firma.de, max@firma.de, lisa@firma.de" required style="width: 100%; padding: 10px; background: rgba(11, 15, 25, 0.8); border: 1px solid var(--border-color); border-radius: var(--radius-md); color: #fff;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Rolle</label>
|
||||||
|
<select name="role">
|
||||||
|
<option value="user" selected>👤 Mitarbeiter</option>
|
||||||
|
<option value="admin">👨🍳 Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Gültigkeit</label>
|
||||||
|
<select name="valid_days">
|
||||||
|
<option value="14" selected>14 Tage</option>
|
||||||
|
<option value="30">30 Tage</option>
|
||||||
|
<option value="unlimited">∞ Unbegrenzt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="margin-top: 6px;">✉️ Alle einladen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<!-- Single / Open Invite Link Form -->
|
||||||
|
<h3>🔗 Offenen Einladungslink generieren</h3>
|
||||||
|
<p class="card-hint">Dauerhafter Link für das ganze Team (z. B. Pinned Message im Chat):</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">
|
|
||||||
<label for="inv_email">E-Mail-Adresse (Optional)</label>
|
|
||||||
<input type="email" id="inv_email" name="email" placeholder="kollege@firma.de (optional)">
|
|
||||||
<small class="form-hint">Freilassen für einen offenen Link, den der Kollege selbst ausfüllt.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
<div class="form-row-2">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="inv_role">Rolle</label>
|
<label>Rolle</label>
|
||||||
<select id="inv_role" name="role">
|
<select name="role">
|
||||||
<option value="user" selected>👤 Mitarbeiter (Besteller)</option>
|
<option value="user" selected>👤 Mitarbeiter</option>
|
||||||
<option value="admin">👨🍳 Organisator (Admin)</option>
|
<option value="admin">👨🍳 Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="inv_valid">Gültigkeit</label>
|
<label>Gültigkeit</label>
|
||||||
<select id="inv_valid" name="valid_days">
|
<select name="valid_days">
|
||||||
<option value="unlimited" selected>∞ Unbegrenzt (Dauerhafter Team-Link)</option>
|
<option value="unlimited" selected>∞ Unbegrenzt (Dauerhafter Team-Link)</option>
|
||||||
<option value="7">7 Tage</option>
|
|
||||||
<option value="14">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>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary" style="margin-top: 1rem;">🔗 Einladungslink generieren</button>
|
<button type="submit" class="btn-secondary">🔗 Offenen Link generieren</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if pending_invitations %}
|
{% if pending_invitations %}
|
||||||
<h3 style="margin-top: 2rem;">Aktive Einladungslinks</h3>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
||||||
<div class="invitations-list">
|
<h3>Aktive Einladungslinks ({{ pending_invitations|length }})</h3>
|
||||||
|
<button type="button" class="btn-copy-small" onclick="copyAllInviteLinks()">📋 Alle Links kopieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invitations-list" id="invitationsListContainer">
|
||||||
{% for inv in pending_invitations %}
|
{% for inv in pending_invitations %}
|
||||||
<div class="invite-item">
|
<div class="invite-item" data-token="{{ inv.token }}" data-email="{{ inv.email or 'Team' }}">
|
||||||
<div class="invite-info">
|
<div class="invite-info">
|
||||||
<strong>{% if inv.email %}{{ inv.email }}{% else %}Offener Team-Link{% endif %}</strong>
|
<strong>{% if inv.email %}{{ inv.email }}{% else %}Dauerhafter Team-Link{% endif %}</strong>
|
||||||
<span class="badge-role">{{ inv.role }}</span>
|
<span class="badge-role">{{ inv.role }}</span>
|
||||||
{% if inv.is_reusable %}
|
{% if inv.is_reusable %}
|
||||||
<span class="badge-role" style="background: rgba(34, 197, 94, 0.2); color: #86efac;">Dauerhaft wiederverwendbar</span>
|
<span class="badge-role" style="background: rgba(34, 197, 94, 0.2); color: #86efac;">Dauerhaft</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
@@ -547,7 +638,7 @@
|
|||||||
<!-- Current team members list -->
|
<!-- Current team members list -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2>👥 Registrierte Teammitglieder ({{ team_members|length }})</h2>
|
<h2>👥 Registrierte Teammitglieder ({{ team_members|length }})</h2>
|
||||||
<p class="card-hint">Alle aktiven Konten dieses Portals:</p>
|
<p class="card-hint">Feste Teamkonten dieses Portals:</p>
|
||||||
|
|
||||||
<div class="orders-table-wrapper">
|
<div class="orders-table-wrapper">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -568,8 +659,6 @@
|
|||||||
<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 %}
|
||||||
@@ -589,6 +678,32 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if active_guests %}
|
||||||
|
<h3 style="margin-top: 2rem;">👤 Aktive Gäste (für anstehende Events)</h3>
|
||||||
|
<p class="card-hint">Werden beim Klick auf „Event abschließen & aufräumen“ automatisch archiviert:</p>
|
||||||
|
<div class="orders-table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>E-Mail / Kennung</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for guest in active_guests %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ guest.first_name }} {{ guest.last_name }}</strong></td>
|
||||||
|
<td><small class="text-muted">{{ guest.email }}</small></td>
|
||||||
|
<td><span class="badge-role guest">Gast</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -634,7 +749,29 @@
|
|||||||
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 das Team senden.");
|
alert("Einladungslink kopiert!\n\n" + url + "\n\nDu kannst ihn jetzt an den Kollegen senden.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyGuestPassLink(eventId) {
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const url = origin + '/?event_id=' + eventId + '&guest=1';
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
alert("Dienstleister-Gastlink kopiert!\n\n" + url + "\n\nExterne können damit für diesen Termin mitbestellen, ohne ein Konto anzulegen.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAllInviteLinks() {
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
|
||||||
|
let text = "🥨 Einladungslinks für Schmankerl:\n\n";
|
||||||
|
items.forEach(el => {
|
||||||
|
const email = el.dataset.email;
|
||||||
|
const token = el.dataset.token;
|
||||||
|
text += email + ": " + origin + "/join?token=" + token + "\n";
|
||||||
|
});
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
alert("Alle Einladungslinks in die Zwischenablage kopiert!");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Multi-Event Picker (wenn mehrere anstehende Runden offen sind) -->
|
||||||
|
{% if open_events|length > 1 %}
|
||||||
|
<div class="multi-event-banner">
|
||||||
|
<span>📅 Weitere offene Termine:</span>
|
||||||
|
<div class="multi-event-chips">
|
||||||
|
{% for ev in open_events %}
|
||||||
|
<a href="/?event_id={{ ev.id }}{% if is_guest_mode %}&guest=1{% endif %}" class="mini-chip {% if event and event.id == ev.id %}active{% endif %}">
|
||||||
|
{{ ev.event_date.strftime('%d.%m.') }} – {{ ev.title }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Header Banner -->
|
<!-- Header Banner -->
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<div class="banner {% if is_closed %}banner-closed{% endif %}">
|
<div class="banner {% if is_closed %}banner-closed{% endif %}">
|
||||||
@@ -76,9 +90,9 @@
|
|||||||
<!-- 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. Bestellung & Angaben</h2>
|
<h2>2. Angaben & Übersicht</h2>
|
||||||
|
|
||||||
{% if current_user %}
|
{% if current_user and not is_guest_mode %}
|
||||||
<div class="logged-in-badge">
|
<div class="logged-in-badge">
|
||||||
<div class="user-avatar">👤</div>
|
<div class="user-avatar">👤</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -92,18 +106,19 @@
|
|||||||
<!-- Gast Angaben -->
|
<!-- Gast Angaben -->
|
||||||
<div class="guest-order-box">
|
<div class="guest-order-box">
|
||||||
<div class="privacy-note" style="margin-bottom: 12px;">
|
<div class="privacy-note" style="margin-bottom: 12px;">
|
||||||
👋 <strong>Gast-Bestellung:</strong> Du bestellst ohne dauerhaftes Benutzerkonto für diesen Termin mit.
|
👋 <strong>Gast- & Dienstleister-Bestellung:</strong><br>
|
||||||
|
Du bestellst ohne dauerhaftes Konto für diesen Termin ({{ event.event_date.strftime('%d.%m.') }}) mit.
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="g_first_name">Dein Vorname *</label>
|
<label for="g_first_name">Vorname / Name *</label>
|
||||||
<input type="text" id="g_first_name" name="guest_first_name" required placeholder="z. B. Christian">
|
<input type="text" id="g_first_name" name="guest_first_name" required placeholder="z. B. Tobias">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="g_last_name">Nachname (optional)</label>
|
<label for="g_last_name">Firma / Abteilung (optional)</label>
|
||||||
<input type="text" id="g_last_name" name="guest_last_name" placeholder="z. B. Bauer">
|
<input type="text" id="g_last_name" name="guest_last_name" placeholder="z. B. IT-Service Müller">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="g_email">E-Mail (optional für Bestätigung)</label>
|
<label for="g_email">E-Mail (optional)</label>
|
||||||
<input type="email" id="g_email" name="guest_email" placeholder="name@firma.de">
|
<input type="email" id="g_email" name="guest_email" placeholder="name@firma.de">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user