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:
2026-09-21 13:29:15 +02:00
parent e5ca9c3cbf
commit 913aa646ea
5 changed files with 741 additions and 263 deletions
+164 -28
View File
@@ -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,12 +501,19 @@ 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:
# Bevorzuge aktives offenes Event
for ev in all_events:
if ev.status == "open":
selected_event = ev
break
if not selected_event and all_events: if not selected_event and all_events:
selected_event = all_events[0] selected_event = all_events[0]
@@ -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,
User.role != "guest"
).order_by(User.role.asc(), User.first_name.asc()).all()
# Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft) # 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)
event.status = "closed" if event.status == "open" else "open"
db.commit() db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/admin?tab=event", 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
View File
@@ -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
View File
@@ -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;
} }
} }
+240 -103
View File
@@ -3,77 +3,72 @@
{% 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' }} &nbsp;|&nbsp; <span class="print-logo">🥨 Schmankerl</span>
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} &nbsp;|&nbsp; <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>
{% if event and event.sponsor_name %}
<div class="print-sponsor-box">
🎉 <strong>GESPONSERTE RUNDE:</strong> Ausgegeben von <strong>{{ event.sponsor_name }}</strong> – Gesamtrechnung: <strong>{{ "%.2f"|format(total_revenue) }} €</strong>
</div> </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 %} {% endif %}
</div> </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>
</div> </div>
<div class="print-section" style="margin-top: 1.5rem;"> <!-- Compact Sammelliste (Horizontal / 2-Column) -->
<h3>👥 Ausgabe- & Strichliste (zum Abhaken)</h3> <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>
<!-- Ausgabeliste zum Abhaken -->
<table class="print-table"> <table class="print-table">
<thead> <thead>
<tr> <tr>
<th style="width: 40px; text-align: center;">Abgehakt</th> <th style="width: 28px; text-align: center;">☐</th>
<th>Kollege / Name</th> <th style="width: 25%;">Kollege / Name</th>
<th>Bestellte Artikel</th> <th>Bestellte Artikel</th>
<th style="text-align: right;">Betrag</th> <th style="width: 70px; text-align: right;">Betrag</th>
<th style="text-align: center;">Status</th> <th style="width: 85px; text-align: center;">Kasse</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for order in orders %} {% for order in orders %}
<tr> <tr>
<td style="text-align: center; font-size: 1.2rem;">☐</td> <td style="text-align: center; font-size: 10pt;">☐</td>
<td> <td>
<strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong> <strong>{{ order.display_name }}</strong>
{% if order.user.role == 'guest' %}<small>(Gast)</small>{% endif %} {% if order.user and order.user.role == 'guest' %}<small class="guest-tag">(Gast)</small>{% endif %}
</td> </td>
<td> <td class="print-items-cell">
{% for item in order.items %} {% for item in order.items %}
{{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %} <span>{{ item.quantity }}x {{ item.product.name }}</span>{% 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="color: #666; font-size: 9pt;">Gesponsert</span> <span style="font-size: 8pt; color: #555;">Sponsor</span>
{% else %} {% else %}
{{ "%.2f"|format(order.total_price) }} € {{ "%.2f"|format(order.total_price) }} €
{% endif %} {% endif %}
</td> </td>
<td style="text-align: center;"> <td style="text-align: center; font-size: 8pt;">
{% 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 %}
@@ -81,13 +76,14 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% else %}
<tr><td colspan="5" style="text-align: center;">Keine Bestellungen vorhanden.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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>
<form action="/admin/invitations/create" method="POST" class="admin-form"> <!-- 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"> <div class="input-group">
<label for="inv_email">E-Mail-Adresse (Optional)</label> <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>
<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>
<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="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="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="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!");
}); });
} }
+23 -8
View File
@@ -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>