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 re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, date
|
||||
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)
|
||||
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)
|
||||
if not tenant:
|
||||
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)
|
||||
now = datetime.now()
|
||||
|
||||
# Aktuelle offene Runde suchen
|
||||
event = db.query(Event).filter(
|
||||
# Alle offenen Events laden
|
||||
open_events = db.query(Event).filter(
|
||||
Event.tenant_id == tenant.id,
|
||||
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:
|
||||
# Neuestes Event überhaupt anzeigen
|
||||
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
|
||||
if not current_user:
|
||||
if not event or not event.allow_guests:
|
||||
@@ -157,6 +167,8 @@ def index(request: Request, db: Session = Depends(get_db)):
|
||||
"tenant": tenant,
|
||||
"current_user": current_user,
|
||||
"event": event,
|
||||
"open_events": open_events,
|
||||
"is_guest_mode": is_guest_mode,
|
||||
"is_closed": is_closed,
|
||||
"seconds_remaining": seconds_remaining,
|
||||
"products": products,
|
||||
@@ -177,7 +189,6 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
|
||||
if current_user:
|
||||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
# Prüfen, ob aktives Event Gäste erlaubt
|
||||
active_event = db.query(Event).filter(
|
||||
Event.tenant_id == tenant.id,
|
||||
Event.status == "open",
|
||||
@@ -190,6 +201,7 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[
|
||||
"error": error,
|
||||
"success": success,
|
||||
"allow_guests": allow_guests,
|
||||
"active_event": active_event,
|
||||
"current_user": None
|
||||
})
|
||||
|
||||
@@ -328,7 +340,6 @@ def process_join(
|
||||
)
|
||||
db.add(new_user)
|
||||
|
||||
# Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen!
|
||||
if not invitation.is_reusable:
|
||||
invitation.used_at = datetime.now()
|
||||
|
||||
@@ -360,16 +371,17 @@ async def place_order(
|
||||
if event.deadline < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
|
||||
|
||||
# Falls nicht eingeloggt -> Gast-Bestellung prüfen
|
||||
guest_name_str = None
|
||||
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!")
|
||||
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
|
||||
|
||||
fname = guest_first_name.strip()
|
||||
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")
|
||||
|
||||
guest_user = User(
|
||||
@@ -386,6 +398,8 @@ async def place_order(
|
||||
db.refresh(guest_user)
|
||||
current_user = guest_user
|
||||
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()
|
||||
order_items = []
|
||||
@@ -411,7 +425,6 @@ async def place_order(
|
||||
if not order_items:
|
||||
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(
|
||||
Order.tenant_id == tenant.id,
|
||||
Order.event_id == event.id,
|
||||
@@ -421,6 +434,7 @@ async def place_order(
|
||||
if existing_order:
|
||||
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
|
||||
existing_order.total_price = total_price
|
||||
existing_order.guest_display_name = guest_name_str
|
||||
for item in order_items:
|
||||
item.order_id = existing_order.id
|
||||
db.add(item)
|
||||
@@ -431,6 +445,7 @@ async def place_order(
|
||||
tenant_id=tenant.id,
|
||||
event_id=event.id,
|
||||
user_id=current_user.id,
|
||||
guest_display_name=guest_name_str,
|
||||
total_price=total_price,
|
||||
is_paid=False
|
||||
)
|
||||
@@ -470,6 +485,9 @@ def admin_dashboard(
|
||||
request: Request,
|
||||
event_id: Optional[int] = None,
|
||||
tab: str = "orders",
|
||||
warn_active_id: Optional[int] = None,
|
||||
success: Optional[str] = None,
|
||||
bulk_count: Optional[int] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
@@ -483,14 +501,21 @@ def admin_dashboard(
|
||||
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
|
||||
|
||||
# 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
|
||||
if event_id:
|
||||
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 = []
|
||||
item_sums = {}
|
||||
@@ -511,19 +536,33 @@ def admin_dashboard(
|
||||
item_sums[pname] = {"quantity": 0, "unit": unit}
|
||||
item_sums[pname]["quantity"] += it.quantity
|
||||
|
||||
# Teammitglieder & Einladungen laden
|
||||
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all()
|
||||
|
||||
# Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft)
|
||||
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
|
||||
team_members = db.query(User).filter(
|
||||
User.tenant_id == tenant.id,
|
||||
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()
|
||||
pending_invitations = db.query(Invitation).filter(
|
||||
Invitation.tenant_id == tenant.id,
|
||||
(Invitation.expires_at == None) | (Invitation.expires_at > now)
|
||||
).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()
|
||||
|
||||
# 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={
|
||||
"tenant": tenant,
|
||||
"current_user": current_user,
|
||||
@@ -534,9 +573,13 @@ def admin_dashboard(
|
||||
"total_revenue": total_revenue,
|
||||
"total_paid": total_paid,
|
||||
"team_members": team_members,
|
||||
"active_guests": active_guests,
|
||||
"pending_invitations": pending_invitations,
|
||||
"products": products,
|
||||
"active_tab": tab,
|
||||
"warn_active_event": active_warning_event,
|
||||
"success": success,
|
||||
"bulk_count": bulk_count,
|
||||
"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()
|
||||
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.commit()
|
||||
|
||||
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- ADMIN INVITATIONS ---
|
||||
# --- ADMIN INVITATIONS (EINZELN & BULK) ---
|
||||
|
||||
@app.post("/admin/invitations/create")
|
||||
def admin_create_invitation(
|
||||
request: Request,
|
||||
email: Optional[str] = Form(None),
|
||||
role: str = Form("user"),
|
||||
valid_days: str = Form("14"),
|
||||
valid_days: str = Form("unlimited"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
@@ -602,7 +650,6 @@ def admin_create_invitation(
|
||||
token = secrets.token_urlsafe(20)
|
||||
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
|
||||
@@ -626,6 +673,50 @@ def admin_create_invitation(
|
||||
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")
|
||||
def admin_revoke_invitation(
|
||||
inv_id: int,
|
||||
@@ -654,6 +745,7 @@ def admin_create_event(
|
||||
deadline_time: str = Form(...),
|
||||
sponsor_name: Optional[str] = Form(None),
|
||||
allow_guests: bool = Form(True),
|
||||
force: bool = Form(False),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
tenant = resolve_tenant(request, db)
|
||||
@@ -666,6 +758,20 @@ def admin_create_event(
|
||||
except ValueError as 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(
|
||||
tenant_id=tenant.id,
|
||||
title=title.strip(),
|
||||
@@ -679,7 +785,7 @@ def admin_create_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)
|
||||
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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@app.post("/admin/events/{event_id}/toggle-status")
|
||||
def admin_toggle_event_status(
|
||||
@app.post("/admin/events/{event_id}/complete-and-cleanup")
|
||||
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,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
@@ -730,13 +868,11 @@ def admin_toggle_event_status(
|
||||
require_admin(request, db)
|
||||
|
||||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||
if event:
|
||||
db.delete(event)
|
||||
db.commit()
|
||||
|
||||
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)
|
||||
return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
|
||||
@@ -885,7 +1021,7 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
if order:
|
||||
order.is_paid = not order.is_paid
|
||||
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")
|
||||
|
||||
+9
-2
@@ -35,7 +35,7 @@ class User(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
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):
|
||||
@@ -93,7 +93,8 @@ class Order(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||
event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
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)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
@@ -103,6 +104,12 @@ class Order(Base):
|
||||
user = relationship("User", back_populates="orders")
|
||||
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):
|
||||
__tablename__ = "order_items"
|
||||
|
||||
+269
-86
@@ -139,6 +139,47 @@ body {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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-header {
|
||||
display: flex;
|
||||
@@ -669,6 +835,7 @@ body {
|
||||
.admin-actions-top {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-sub {
|
||||
@@ -703,26 +870,6 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -882,6 +1029,20 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -986,35 +1147,6 @@ body {
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Setup Wizard Steps */
|
||||
.setup-step-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: rgba(11, 15, 25, 0.6);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #0b0f19;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content h3 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -1047,19 +1179,32 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* --- PRINT MEDIA QUERY --- */
|
||||
/* ================= PRINT MEDIA QUERY (1-PAGE A4) ================= */
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 8mm 10mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #ffffff !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;
|
||||
}
|
||||
|
||||
@@ -1071,47 +1216,85 @@ body {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 6px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.print-logo {
|
||||
font-weight: 800;
|
||||
font-size: 11pt;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.print-sponsor {
|
||||
font-weight: bold;
|
||||
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 {
|
||||
background-color: #f2f2f2 !important;
|
||||
background-color: #e2e8f0 !important;
|
||||
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 %}
|
||||
<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-header">
|
||||
<h1>🥨 Schmankerl – Brotzeit-Verteilliste</h1>
|
||||
<p>
|
||||
<strong>Event:</strong> {{ event.title if event else 'Brotzeitrunde' }} |
|
||||
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} |
|
||||
<strong>Team:</strong> {{ tenant.name }}
|
||||
</p>
|
||||
{% if event and event.sponsor_name %}
|
||||
<div class="print-sponsor-box">
|
||||
🎉 <strong>GESPONSERTE RUNDE:</strong> Ausgegeben von <strong>{{ event.sponsor_name }}</strong> – Gesamtrechnung: <strong>{{ "%.2f"|format(total_revenue) }} €</strong>
|
||||
<!-- Single-line condensed header -->
|
||||
<div class="print-header-singleline">
|
||||
<div class="print-left">
|
||||
<span class="print-logo">🥨 Schmankerl</span>
|
||||
<strong>{{ event.title if event else 'Brotzeitrunde' }}</strong>
|
||||
<span class="print-date">({{ event.event_date.strftime('%d.%m.%Y') if event else '' }})</span>
|
||||
</div>
|
||||
<div class="print-right">
|
||||
<span>Teilnehmer: <strong>{{ orders|length }}</strong></span>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="print-section">
|
||||
<h3>🛒 Sammelbestellung (Metzger & Bäcker)</h3>
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikel</th>
|
||||
<th style="text-align: right;">Gesamtmenge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for name, data in item_sums.items() %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td style="text-align: right; font-weight: bold;">{{ data.quantity }} {{ data.unit }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Compact Sammelliste (Horizontal / 2-Column) -->
|
||||
<div class="print-sammel-box">
|
||||
<span class="print-sammel-title">🛒 Einkauf (Bäcker & Metzger):</span>
|
||||
{% for name, data in item_sums.items() %}
|
||||
<span class="print-item-pill">
|
||||
<strong>{{ data.quantity }}x</strong> {{ name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span>Keine Artikel</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="print-section" style="margin-top: 1.5rem;">
|
||||
<h3>👥 Ausgabe- & Strichliste (zum Abhaken)</h3>
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px; text-align: center;">Abgehakt</th>
|
||||
<th>Kollege / Name</th>
|
||||
<th>Bestellte Artikel</th>
|
||||
<th style="text-align: right;">Betrag</th>
|
||||
<th style="text-align: center;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td style="text-align: center; font-size: 1.2rem;">☐</td>
|
||||
<td>
|
||||
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
|
||||
{% if order.user.role == 'guest' %}<small>(Gast)</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for item in order.items %}
|
||||
{{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td style="text-align: right; font-weight: bold;">
|
||||
{% if event and event.sponsor_name %}
|
||||
<span style="color: #666; font-size: 9pt;">Gesponsert</span>
|
||||
{% else %}
|
||||
{{ "%.2f"|format(order.total_price) }} €
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
{% if event and event.sponsor_name %}
|
||||
Übernommen ✓
|
||||
{% elif order.is_paid %}
|
||||
Bezahlt ✓
|
||||
{% else %}
|
||||
Offen
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Ausgabeliste zum Abhaken -->
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28px; text-align: center;">☐</th>
|
||||
<th style="width: 25%;">Kollege / Name</th>
|
||||
<th>Bestellte Artikel</th>
|
||||
<th style="width: 70px; text-align: right;">Betrag</th>
|
||||
<th style="width: 85px; text-align: center;">Kasse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td style="text-align: center; font-size: 10pt;">☐</td>
|
||||
<td>
|
||||
<strong>{{ order.display_name }}</strong>
|
||||
{% if order.user and order.user.role == 'guest' %}<small class="guest-tag">(Gast)</small>{% endif %}
|
||||
</td>
|
||||
<td class="print-items-cell">
|
||||
{% for item in order.items %}
|
||||
<span>{{ item.quantity }}x {{ item.product.name }}</span>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td style="text-align: right; font-weight: bold;">
|
||||
{% if event and event.sponsor_name %}
|
||||
<span style="font-size: 8pt; color: #555;">Sponsor</span>
|
||||
{% else %}
|
||||
{{ "%.2f"|format(order.total_price) }} €
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center; font-size: 8pt;">
|
||||
{% if event and event.sponsor_name %}
|
||||
Übernommen
|
||||
{% elif order.is_paid %}
|
||||
Bezahlt ✓
|
||||
{% else %}
|
||||
Offen
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="text-align: center;">Keine Bestellungen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Normal Screen Content -->
|
||||
<!-- ================= NORMAL SCREEN VIEW ================= -->
|
||||
<div class="no-print">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
@@ -97,11 +93,46 @@
|
||||
<div class="admin-actions-top">
|
||||
{% if event %}
|
||||
<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 %}
|
||||
</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 -->
|
||||
<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 %}">
|
||||
@@ -111,30 +142,34 @@
|
||||
📅 Event- & Frist-Planung
|
||||
</a>
|
||||
<a href="?tab=products{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'products' %}active{% endif %}">
|
||||
🥨 Produkte & Sortiment ({{ products|length }})
|
||||
🥨 Sortiment ({{ products|length }})
|
||||
</a>
|
||||
<a href="?tab=team{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'team' %}active{% endif %}">
|
||||
👥 Team & Einladungen ({{ team_members|length }})
|
||||
👥 Team & Massen-Einladung ({{ team_members|length }})
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: BESTELLUNGEN & KASSE -->
|
||||
{% 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 %}
|
||||
|
||||
<!-- 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 %}
|
||||
<div class="sponsor-highlight-card">
|
||||
<div class="sponsor-icon">🎁</div>
|
||||
@@ -208,7 +243,7 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kollege</th>
|
||||
<th>Kollege / Gast</th>
|
||||
<th>Bestellung</th>
|
||||
<th>Betrag</th>
|
||||
<th>Status</th>
|
||||
@@ -218,12 +253,15 @@
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>
|
||||
{% if order.user.role == 'guest' %}
|
||||
<strong>{{ order.display_name }}</strong>
|
||||
{% if order.user and order.user.role == 'guest' %}
|
||||
<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 %}
|
||||
<br>
|
||||
<small class="text-muted">{{ order.user.email }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="mini-items-list">
|
||||
@@ -259,19 +297,41 @@
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h2>Keine offene Bestellrunde gefunden</h2>
|
||||
<p>Erstelle im Reiter <strong>Event- & Frist-Planung</strong> eine neue Runde.</p>
|
||||
<h2>Keine Runde ausgewählt</h2>
|
||||
<p>Wähle oben eine Runde aus oder erstelle eine neue.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- TAB 2: EVENT- & FRIST-PLANUNG -->
|
||||
{% 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">
|
||||
<!-- Edit current event -->
|
||||
{% if event %}
|
||||
<div class="admin-card">
|
||||
<h2>✏️ Aktives Event bearbeiten</h2>
|
||||
<p class="card-hint">Passe Titel, Frist, Gast-Erlaubnis und Sponsor an:</p>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<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">
|
||||
<div class="input-group">
|
||||
@@ -295,12 +355,11 @@
|
||||
<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)
|
||||
<strong>Gäste- & Dienstleister-Bestellungen erlauben</strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group" style="margin-top: 14px;">
|
||||
@@ -308,6 +367,7 @@
|
||||
<select id="edit_status" name="status_val">
|
||||
<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="completed" {% if event.status == 'completed' %}selected{% endif %}>🏁 Abgeschlossen (Archiviert)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Änderungen speichern</button>
|
||||
@@ -320,7 +380,8 @@
|
||||
<h2>➕ Neue Brotzeitrunde planen</h2>
|
||||
<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">
|
||||
<label for="new_title">Titel</label>
|
||||
<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">
|
||||
<label>
|
||||
<input type="checkbox" name="allow_guests" value="true" checked>
|
||||
<strong>Gäste-Bestellungen erlauben</strong>
|
||||
<strong>Gäste- & Dienstleister-Bestellungen erlauben</strong>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Runde jetzt starten</button>
|
||||
@@ -357,7 +418,6 @@
|
||||
<!-- 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>
|
||||
@@ -442,11 +502,10 @@
|
||||
<button type="submit" class="btn-primary" style="margin-top: 10px;">Artikel speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Product Modal (Simple JS) -->
|
||||
<!-- Edit Product Modal -->
|
||||
<div id="editProductModal" class="modal-backdrop" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<h3>✏️ Produkt bearbeiten</h3>
|
||||
@@ -481,51 +540,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 4: TEAM & EINLADUNGEN -->
|
||||
<!-- TAB 4: TEAM & MASSEN-EINLADUNG -->
|
||||
{% elif active_tab == 'team' %}
|
||||
<div class="admin-columns">
|
||||
<!-- Generate invite link -->
|
||||
<!-- Einladungs-Bereich (Einzeln & Massen-Einladung) -->
|
||||
<div class="admin-card">
|
||||
<h2>✉️ Teammitglied einladen</h2>
|
||||
<p class="card-hint">Erzeuge einen Einladungslink (einmalig oder unbegrenzt dauerhaft):</p>
|
||||
<h2>✉️ Team einladen</h2>
|
||||
|
||||
<!-- 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">
|
||||
<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="input-group">
|
||||
<label for="inv_role">Rolle</label>
|
||||
<select id="inv_role" name="role">
|
||||
<option value="user" selected>👤 Mitarbeiter (Besteller)</option>
|
||||
<option value="admin">👨🍳 Organisator (Admin)</option>
|
||||
<label>Rolle</label>
|
||||
<select name="role">
|
||||
<option value="user" selected>👤 Mitarbeiter</option>
|
||||
<option value="admin">👨🍳 Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="inv_valid">Gültigkeit</label>
|
||||
<select id="inv_valid" name="valid_days">
|
||||
<label>Gültigkeit</label>
|
||||
<select name="valid_days">
|
||||
<option value="unlimited" selected>∞ Unbegrenzt (Dauerhafter Team-Link)</option>
|
||||
<option value="7">7 Tage</option>
|
||||
<option value="14">14 Tage</option>
|
||||
<option value="30">30 Tage</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{% if pending_invitations %}
|
||||
<h3 style="margin-top: 2rem;">Aktive Einladungslinks</h3>
|
||||
<div class="invitations-list">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
|
||||
<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 %}
|
||||
<div class="invite-item">
|
||||
<div class="invite-item" data-token="{{ inv.token }}" data-email="{{ inv.email or 'Team' }}">
|
||||
<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>
|
||||
{% 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 %}
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
@@ -547,7 +638,7 @@
|
||||
<!-- Current team members list -->
|
||||
<div class="admin-card">
|
||||
<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">
|
||||
<table class="data-table">
|
||||
@@ -568,8 +659,6 @@
|
||||
<td>
|
||||
{% if member.role == 'admin' %}
|
||||
<span class="badge-role admin">Admin</span>
|
||||
{% elif member.role == 'guest' %}
|
||||
<span class="badge-role guest">Gast</span>
|
||||
{% else %}
|
||||
<span class="badge-role">Mitarbeiter</span>
|
||||
{% endif %}
|
||||
@@ -589,6 +678,32 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
@@ -634,7 +749,29 @@
|
||||
const origin = window.location.origin;
|
||||
const url = origin + '/join?token=' + token;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert("Einladungslink kopiert!\n\n" + url + "\n\nDu kannst ihn jetzt an 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 %}
|
||||
<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 -->
|
||||
{% if event %}
|
||||
<div class="banner {% if is_closed %}banner-closed{% endif %}">
|
||||
@@ -76,9 +90,9 @@
|
||||
<!-- Right: User Details & Summary -->
|
||||
<div class="summary-section">
|
||||
<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="user-avatar">👤</div>
|
||||
<div>
|
||||
@@ -92,18 +106,19 @@
|
||||
<!-- 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.
|
||||
👋 <strong>Gast- & Dienstleister-Bestellung:</strong><br>
|
||||
Du bestellst ohne dauerhaftes Konto für diesen Termin ({{ event.event_date.strftime('%d.%m.') }}) 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">
|
||||
<label for="g_first_name">Vorname / Name *</label>
|
||||
<input type="text" id="g_first_name" name="guest_first_name" required placeholder="z. B. Tobias">
|
||||
</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">
|
||||
<label for="g_last_name">Firma / Abteilung (optional)</label>
|
||||
<input type="text" id="g_last_name" name="guest_last_name" placeholder="z. B. IT-Service Müller">
|
||||
</div>
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user