feat: token-secured guest invites, product freeze lock during active events, Outlook ics export and template, realistic test data

This commit is contained in:
2026-09-21 14:12:59 +02:00
parent 913aa646ea
commit 2001ffb831
5 changed files with 328 additions and 55 deletions
+201 -43
View File
@@ -2,10 +2,10 @@ import os
import re
import secrets
from datetime import datetime, timedelta, date
from typing import Optional, Dict, List
from typing import Optional, Dict, List, Set
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -68,6 +68,19 @@ def require_admin(request: Request, db: Session) -> User:
return current_user
def is_product_locked_in_active_event(product_id: int, tenant_id: int, db: Session) -> bool:
"""Prüft, ob ein Produkt in einer offenen Runde bereits bestellt wurde."""
open_events = db.query(Event).filter(Event.tenant_id == tenant_id, Event.status == "open").all()
if not open_events:
return False
open_ids = [e.id for e in open_events]
has_item = db.query(OrderItem).join(Order).filter(
Order.event_id.in_(open_ids),
OrderItem.product_id == product_id
).first()
return has_item is not None
def seed_demo_users_if_needed(tenant: Tenant, db: Session):
"""Stellt sicher, dass die Standard-Accounts für die Demo existieren."""
demo_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "maxi@waiswuaschd.de").first()
@@ -99,7 +112,7 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
@app.get("/", response_class=HTMLResponse)
def index(request: Request, event_id: Optional[int] = None, db: Session = Depends(get_db)):
def index(request: Request, event_id: Optional[int] = None, token: Optional[str] = None, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
@@ -120,16 +133,16 @@ def index(request: Request, event_id: Optional[int] = None, db: Session = Depend
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")
# Sicherer Gast-Check: Nur wenn token übereinstimmt, ist Gast-Modus aktiv
is_valid_guest = False
if event and token and event.guest_token and token == event.guest_token:
is_valid_guest = True
# Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login
if not current_user:
if not event or not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Wenn nicht eingeloggt und kein gültiger Gast-Token -> Redirect zu Login
if not current_user and not is_valid_guest:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Fristprüfung & Countdown
is_closed = True
@@ -168,7 +181,8 @@ def index(request: Request, event_id: Optional[int] = None, db: Session = Depend
"current_user": current_user,
"event": event,
"open_events": open_events,
"is_guest_mode": is_guest_mode,
"is_valid_guest": is_valid_guest,
"guest_token": token or (event.guest_token if event else ""),
"is_closed": is_closed,
"seconds_remaining": seconds_remaining,
"products": products,
@@ -189,19 +203,10 @@ 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)
active_event = db.query(Event).filter(
Event.tenant_id == tenant.id,
Event.status == "open",
Event.deadline > datetime.now()
).first()
allow_guests = (active_event.allow_guests if active_event else False)
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": error,
"success": success,
"allow_guests": allow_guests,
"active_event": active_event,
"current_user": None
})
@@ -221,8 +226,7 @@ def login(
return templates.TemplateResponse(request, "login.html", context={
"tenant": tenant,
"error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
"current_user": None,
"allow_guests": False
"current_user": None
}, status_code=400)
request.session["user_id"] = user.id
@@ -356,6 +360,7 @@ def process_join(
async def place_order(
request: Request,
event_id: int = Form(...),
guest_token: Optional[str] = Form(None),
guest_first_name: Optional[str] = Form(None),
guest_last_name: Optional[str] = Form(None),
guest_email: Optional[str] = Form(None),
@@ -373,8 +378,9 @@ async def place_order(
guest_name_str = None
if not current_user:
if not event.allow_guests:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Gast-Token prüfen
if not guest_token or not event.guest_token or guest_token != event.guest_token:
raise HTTPException(status_code=403, detail="Ungültiger oder fehlender Gast-Token. Bitte nutze den Einladungslink.")
if not guest_first_name or not guest_first_name.strip():
raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!")
@@ -478,6 +484,49 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db)
})
# --- OUTLOOK & CALENDAR (.ics) ---
@app.get("/events/{event_id}/calendar.ics")
def download_calendar_ics(event_id: int, request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(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")
dt_start = datetime.combine(event.event_date, datetime.min.time()).replace(hour=9, minute=30)
dt_end = dt_start + timedelta(hours=1)
dt_stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
dt_start_str = dt_start.strftime("%Y%m%dT%H%M%S")
dt_end_str = dt_end.strftime("%Y%m%dT%H%M%S")
host = request.headers.get("host", "demo.dev.waiswuaschd.de")
order_url = f"https://{host}/?event_id={event.id}"
ics_content = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Schmankerl//Brotzeitportal//DE\r\n"
"CALSCALE:GREGORIAN\r\n"
"METHOD:PUBLISH\r\n"
"BEGIN:VEVENT\r\n"
f"UID:schmankerl-event-{event.id}@{host}\r\n"
f"DTSTAMP:{dt_stamp}\r\n"
f"DTSTART:{dt_start_str}\r\n"
f"DTEND:{dt_end_str}\r\n"
f"SUMMARY:🥨 {event.title}\r\n"
f"DESCRIPTION:Gemeinsame Brotzeit / Weißwurstfrühstück.\\nBestellschluss: {event.deadline.strftime('%d.%m.%Y um %H:%M Uhr')}\\nBestell-Link: {order_url}\r\n"
f"LOCATION:Kantine / Teeküche ({tenant.name})\r\n"
"STATUS:CONFIRMED\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return Response(
content=ics_content,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="brotzeit-{event.event_date.strftime("%Y-%m-%d")}.ics"'}
)
# --- ADMIN COCKPIT (/admin) ---
@app.get("/admin", response_class=HTMLResponse)
@@ -487,6 +536,7 @@ def admin_dashboard(
tab: str = "orders",
warn_active_id: Optional[int] = None,
success: Optional[str] = None,
error: Optional[str] = None,
bulk_count: Optional[int] = None,
db: Session = Depends(get_db)
):
@@ -503,13 +553,12 @@ def admin_dashboard(
# Alle Events des Mandanten
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: Wenn event_id übergeben, nehmen wir das, sonst das erste offene, sonst das neueste
# Ausgewähltes Event bestimmen
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:
# Bevorzuge aktives offenes Event
for ev in all_events:
if ev.status == "open":
selected_event = ev
@@ -536,7 +585,7 @@ def admin_dashboard(
item_sums[pname] = {"quantity": 0, "unit": unit}
item_sums[pname]["quantity"] += it.quantity
# Teammitglieder laden (Gäste filtern oder am Ende aufführen)
# Teammitglieder laden
team_members = db.query(User).filter(
User.tenant_id == tenant.id,
User.role != "guest"
@@ -558,6 +607,14 @@ def admin_dashboard(
# Produkte laden
products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all()
# Welche Produkte sind durch Bestellungen in offenen Events gesperrt?
locked_product_ids: Set[int] = set()
open_events = [e for e in all_events if e.status == "open"]
if open_events:
open_ev_ids = [e.id for e in open_events]
ordered_pids = db.query(OrderItem.product_id).join(Order).filter(Order.event_id.in_(open_ev_ids)).distinct().all()
locked_product_ids = {p[0] for p in ordered_pids}
# Warnung bei existierendem aktivem Event
active_warning_event = None
if warn_active_id:
@@ -576,9 +633,11 @@ def admin_dashboard(
"active_guests": active_guests,
"pending_invitations": pending_invitations,
"products": products,
"locked_product_ids": locked_product_ids,
"active_tab": tab,
"warn_active_event": active_warning_event,
"success": success,
"error": error,
"bulk_count": bulk_count,
"now": now
})
@@ -606,7 +665,7 @@ def admin_reset_password(
user.password_hash = hash_password(new_password.strip())
db.commit()
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team&success=Passwort erfolgreich aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/users/{user_id}/delete")
@@ -623,7 +682,6 @@ 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}"
@@ -631,10 +689,10 @@ def admin_delete_user(
db.delete(user)
db.commit()
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=team&success=Benutzer gelöscht", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS (EINZELN & BULK) ---
# --- ADMIN INVITATIONS ---
@app.post("/admin/invitations/create")
def admin_create_invitation(
@@ -684,7 +742,6 @@ def admin_bulk_invitations(
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)]))
@@ -758,7 +815,6 @@ 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,
@@ -766,7 +822,6 @@ def admin_create_event(
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
@@ -779,7 +834,8 @@ def admin_create_event(
deadline=deadline_dt,
status="open",
sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None,
allow_guests=allow_guests
allow_guests=allow_guests,
guest_token=secrets.token_urlsafe(16)
)
db.add(new_event)
db.commit()
@@ -821,9 +877,11 @@ def admin_edit_event(
event.status = status_val
event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None
event.allow_guests = allow_guests
if not event.guest_token:
event.guest_token = secrets.token_urlsafe(16)
db.commit()
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&success=Event aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/events/{event_id}/complete-and-cleanup")
@@ -842,7 +900,6 @@ def admin_complete_event_and_cleanup_guests(
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:
@@ -872,10 +929,10 @@ def admin_delete_event(
db.delete(event)
db.commit()
return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=event&success=Event gelöscht", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN PRODUCT CATALOG MANAGEMENT ---
# --- ADMIN PRODUCT CATALOG MANAGEMENT (MIT SICHERUNGSSPERRE) ---
@app.post("/admin/products/new")
def admin_create_product(
@@ -907,7 +964,7 @@ def admin_create_product(
db.add(product)
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Produkt angelegt", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/edit")
@@ -933,6 +990,13 @@ def admin_edit_product(
except Exception:
raise HTTPException(status_code=400, detail="Ungültiger Preis")
# Prüfen, ob Preis gesperrt ist wegen laufender Bestellungen
if prod.price != clean_price and is_product_locked_in_active_event(product_id, tenant.id, db):
return RedirectResponse(
url="/admin?tab=products&error=Preisänderung gesperrt: Dieser Artikel wurde in der laufenden Runde bereits bestellt!",
status_code=status.HTTP_303_SEE_OTHER
)
prod.name = name.strip()
prod.description = description.strip()
prod.price = clean_price
@@ -940,7 +1004,7 @@ def admin_edit_product(
prod.sort_order = sort_order
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Produkt aktualisiert", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/products/{product_id}/toggle")
@@ -954,13 +1018,20 @@ def admin_toggle_product(
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if prod:
# Wenn aktiv -> versuche zu deaktivieren: prüfen, ob in aktiver Runde bestellt
if prod.is_active and is_product_locked_in_active_event(product_id, tenant.id, db):
return RedirectResponse(
url="/admin?tab=products&error=Deaktivierung gesperrt: Artikel wurde in der laufenden Runde bereits bestellt!",
status_code=status.HTTP_303_SEE_OTHER
)
prod.is_active = not prod.is_active
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
# --- ONBOARDING / SETUP WIZARD (/setup) ---
# --- ONBOARDING / SETUP WIZARD & TESTDATEN ---
@app.get("/setup", response_class=HTMLResponse)
def setup_page(request: Request, db: Session = Depends(get_db)):
@@ -1008,7 +1079,94 @@ def seed_standard_products(request: Request, db: Session = Depends(get_db)):
))
db.commit()
return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/admin?tab=products&success=Standard-Sortiment geladen", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/setup/seed-test-data")
def seed_realistic_test_data(
request: Request,
event_id: Optional[int] = Form(None),
db: Session = Depends(get_db)
):
"""Spielt realistische Testdaten für das nächste Event ein."""
tenant = resolve_tenant(request, db)
require_admin(request, db)
# Event finden
event = None
if event_id:
event = db.query(Event).filter(Event.tenant_id == tenant.id, Event.id == event_id).first()
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id, Event.status == "open").order_by(Event.event_date.asc()).first()
if not event:
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
if not event:
raise HTTPException(status_code=400, detail="Kein Event vorhanden")
# Produkte finden
products = {p.name: p for p in db.query(Product).filter(Product.tenant_id == tenant.id).all()}
if not products:
raise HTTPException(status_code=400, detail="Bitte zuerst Standard-Produkte anlegen")
# Sicherstellen, dass Basisprodukte aktiv sind
for p in products.values():
if not p.is_active:
p.is_active = True
test_users_data = [
{"email": "anna.huber@firma.de", "fname": "Anna", "lname": "Huber", "items": [("Weißwurst", 2), ("Breze", 1), ("Senf", 1)], "paid": True},
{"email": "stefan.meier@firma.de", "fname": "Stefan", "lname": "Meier", "items": [("Leberkas", 2), ("Bier", 1)], "paid": False},
{"email": "laura.wagner@firma.de", "fname": "Laura", "lname": "Wagner", "items": [("Breze", 1), ("Senf", 1), ("Weißwurst", 2)], "paid": True},
{"email": "michael.schmidt@firma.de", "fname": "Michael", "lname": "Schmidt", "items": [("Wiener", 2), ("Breze", 1)], "paid": False},
{"email": "christian.dienstleister@extern.de", "fname": "Christian", "lname": "Bauer (IT-Service)", "items": [("Weißwurst", 4), ("Breze", 2), ("Senf", 1)], "paid": True, "role": "guest"},
]
for tdata in test_users_data:
user = db.query(User).filter(User.tenant_id == tenant.id, User.email == tdata["email"]).first()
if not user:
user = User(
tenant_id=tenant.id,
email=tdata["email"],
username=tdata["email"].split("@")[0],
first_name=tdata["fname"],
last_name=tdata["lname"],
password_hash=hash_password("demo123"),
role=tdata.get("role", "user")
)
db.add(user)
db.flush()
# Bestellung prüfen
existing = db.query(Order).filter(Order.event_id == event.id, Order.user_id == user.id).first()
if not existing:
tot = Decimal("0.00")
new_ord = Order(
tenant_id=tenant.id,
event_id=event.id,
user_id=user.id,
guest_display_name=f"{user.first_name} {user.last_name}",
is_paid=tdata["paid"]
)
db.add(new_ord)
db.flush()
for key_term, qty in tdata["items"]:
prod = products.get(key_term)
if not prod:
term_lower = key_term.lower()
for p_name, p_obj in products.items():
if term_lower in p_name.lower():
prod = p_obj
break
if prod:
tot += prod.price * qty
db.add(OrderItem(order_id=new_ord.id, product_id=prod.id, quantity=qty, unit_price=prod.price))
new_ord.total_price = tot
db.commit()
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success=Realistische Testdaten erfolgreich eingespielt", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/toggle-paid/{order_id}")
+1
View File
@@ -81,6 +81,7 @@ class Event(Base):
status = Column(String(20), default="open") # 'open', 'closed', 'completed'
allow_guests = Column(Boolean, default=True, nullable=False)
sponsor_name = Column(String(150), nullable=True)
guest_token = Column(String(64), nullable=True)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="events")
+119 -7
View File
@@ -93,7 +93,9 @@
<div class="admin-actions-top">
{% if event %}
<button onclick="copyShoppingList()" class="btn-primary">📋 Einkaufsliste kopieren</button>
<button onclick="copyGuestPassLink('{{ event.id }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
<button onclick="copyGuestPassLink('{{ event.id }}', '{{ event.guest_token or '' }}')" class="btn-secondary" title="Gast-Link für Dienstleister">🔗 Dienstleister-Gastlink</button>
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" title="Termin als Outlook/Kalenderdatei (.ics) herunterladen" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Outlook (.ics)</a>
<button onclick="openOutlookModal()" class="btn-secondary" title="Outlook Einladungsvorlage anzeigen">✉️ Outlook-Vorlage</button>
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
{% endif %}
</div>
@@ -105,6 +107,12 @@
</div>
{% endif %}
{% if error %}
<div class="alert-warning-card" style="margin-bottom: 20px; border-color: #ef4444; background: rgba(239, 68, 68, 0.15); color: #fca5a5;">
⚠️ {{ error }}
</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.
@@ -201,8 +209,18 @@
<div class="stat-card">
<div class="stat-label">Teilnehmer</div>
<div class="stat-value">{{ orders|length }} Personen</div>
</div>
</div>
{% if event and event.status == 'open' %}
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
<form action="/setup/seed-test-data" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du 5 realistische Demo-Bestellungen für Kollegen und Gäste für diese Runde einfügen?');">
<input type="hidden" name="event_id" value="{{ event.id }}">
<button type="submit" class="btn-secondary" style="font-size: 0.85rem;" title="Erstellt Beispielbestellungen für Anna Huber, Stefan Meier, Michael Schmidt etc.">
🧪 Realistische Demo-Bestellungen einfügen (5 Kollegen)
</button>
</form>
</div>
{% endif %}
<div class="admin-columns">
<!-- Aggregated Shopping List -->
@@ -442,7 +460,12 @@
<tbody>
{% for prod in products %}
<tr class="{% if not prod.is_active %}row-inactive{% endif %}">
<td><strong>{{ prod.name }}</strong></td>
<td>
<strong>{{ prod.name }}</strong>
{% if prod.id in locked_product_ids %}
<span class="badge-role" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; margin-left: 6px;" title="Für die offene Runde bestellt. Preis und Deaktivierung gesichert.">🔒 Gesichert</span>
{% endif %}
</td>
<td><small class="text-muted">{{ prod.description }}</small></td>
<td><strong>{{ "%.2f"|format(prod.price) }} €</strong></td>
<td>{{ prod.unit }}</td>
@@ -455,12 +478,16 @@
</td>
<td>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}')">✏️ Bearbeiten</button>
<button type="button" class="btn-copy-small" onclick="openEditProduct('{{ prod.id }}', '{{ prod.name|escape }}', '{{ prod.description|escape }}', '{{ '%.2f'|format(prod.price) }}', '{{ prod.unit }}', '{{ prod.sort_order }}', {{ 'true' if prod.id in locked_product_ids else 'false' }})">✏️ Bearbeiten</button>
{% if prod.id in locked_product_ids and prod.is_active %}
<button type="button" class="btn-revoke-small" style="opacity: 0.5; cursor: not-allowed; color: #fca5a5;" title="In der laufenden Runde bereits bestellt – Deaktivierung gesperrt" onclick="alert('Dieser Artikel wurde in der laufenden Runde bereits bestellt und kann daher erst nach Abschluss des Events deaktiviert werden.')">Deaktivieren</button>
{% else %}
<form action="/admin/products/{{ prod.id }}/toggle" method="POST" style="margin: 0; display: inline;">
<button type="submit" class="btn-revoke-small" style="color: {% if prod.is_active %}#fca5a5{% else %}#86efac{% endif %};">
{% if prod.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
{% endif %}
</div>
</td>
</tr>
@@ -509,6 +536,9 @@
<div id="editProductModal" class="modal-backdrop" style="display: none;">
<div class="modal-card">
<h3>✏️ Produkt bearbeiten</h3>
<div id="productLockedNotice" class="alert-warning-card" style="display: none; padding: 10px 14px; margin-top: 10px; margin-bottom: 10px; font-size: 0.85rem; border-color: rgba(245, 158, 11, 0.4);">
🔒 <strong>Artikel gesichert:</strong> Dieses Produkt wurde für eine offene Runde bereits bestellt. Der Preis ist eingefroren und kann erst nach Abschluss des Events geändert werden.
</div>
<form id="editProductForm" method="POST" class="admin-form" style="margin-top: 14px;">
<div class="input-group">
<label for="m_prod_name">Artikelname</label>
@@ -729,6 +759,52 @@
</div>
{% endif %}
<!-- Outlook Template Modal (Global für alle Tabs verfügbar) -->
<div id="outlookModal" class="modal-backdrop" style="display: none;">
<div class="modal-card" style="max-width: 640px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="margin: 0;">✉️ Outlook-Einladungsvorlage</h3>
<button type="button" class="btn-revoke-small" onclick="closeOutlookModal()" style="font-size: 1rem; padding: 2px 8px;">✕</button>
</div>
<p class="card-hint">Kopiere diese Vorlage direkt in Outlook oder öffne dein Mailprogramm per Klick:</p>
<div class="input-group" style="margin-bottom: 12px;">
<label for="outlookSubject">Betreff</label>
<input type="text" id="outlookSubject" readonly value="🥨 Einladung: {{ event.title if event else 'Brotzeitrunde' }} ({{ event.event_date.strftime('%d.%m.%Y') if event else '' }})" style="width: 100%;">
</div>
<div class="input-group" style="margin-bottom: 16px;">
<label for="outlookBody">E-Mail-Text (inkl. Bestelllinks)</label>
<textarea id="outlookBody" rows="11" readonly style="width: 100%; padding: 12px; font-family: inherit; font-size: 0.85rem; line-height: 1.5; background: rgba(11, 15, 25, 0.8); border: 1px solid var(--border-color); border-radius: var(--radius-md); color: #fff; resize: vertical;">{% if event %}Hallo zusammen,
wir machen wieder eine gemeinsame Brotzeitrunde am {{ event.event_date.strftime('%d.%m.%Y') }}!
🥨 Bitte tragt eure Bestellung bis spätestens {{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }} im Brotzeit-Portal ein:
👉 https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}
{% if event.allow_guests and event.guest_token %}
Für externe Dienstleister & Gäste ohne Firmenkonto:
🔗 Gast-Bestelllink: https://demo.dev.waiswuaschd.de/?event_id={{ event.id }}&token={{ event.guest_token }}
{% endif %}
{% if event.sponsor_name %}
🎉 Gute Nachrichten: Die Runde wird heute von {{ event.sponsor_name }} spendiert!
{% endif %}
Viele Grüße,
{{ current_user.first_name }} {{ current_user.last_name }}{% endif %}</textarea>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn-primary" onclick="copyOutlookTemplate()">📋 Text kopieren</button>
<button type="button" class="btn-secondary" onclick="openMailtoClient()">✉️ In Outlook öffnen</button>
{% if event %}
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" style="text-decoration: none;">📅 .ics Kalenderdatei</a>
{% endif %}
<button type="button" class="btn-secondary" onclick="closeOutlookModal()" style="margin-left: auto;">Schließen</button>
</div>
</div>
</div>
</div>
</div>
@@ -753,14 +829,35 @@
});
}
function copyGuestPassLink(eventId) {
function copyGuestPassLink(eventId, guestToken) {
const origin = window.location.origin;
const url = origin + '/?event_id=' + eventId + '&guest=1';
const url = origin + '/?event_id=' + eventId + (guestToken ? '&token=' + guestToken : '');
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 openOutlookModal() {
document.getElementById('outlookModal').style.display = 'flex';
}
function closeOutlookModal() {
document.getElementById('outlookModal').style.display = 'none';
}
function copyOutlookTemplate() {
const body = document.getElementById('outlookBody').value;
navigator.clipboard.writeText(body).then(() => {
alert("Outlook-Einladungstext in die Zwischenablage kopiert! Kann direkt in Outlook eingefügt werden.");
});
}
function openMailtoClient() {
const subject = encodeURIComponent(document.getElementById('outlookSubject').value);
const body = encodeURIComponent(document.getElementById('outlookBody').value);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}
function copyAllInviteLinks() {
const origin = window.location.origin;
const items = document.querySelectorAll('#invitationsListContainer .invite-item');
@@ -798,7 +895,7 @@
document.getElementById('new_pw_input').value = pw;
}
function openEditProduct(id, name, desc, price, unit, sort) {
function openEditProduct(id, name, desc, price, unit, sort, isLocked) {
const modal = document.getElementById('editProductModal');
const form = document.getElementById('editProductForm');
form.action = '/admin/products/' + id + '/edit';
@@ -807,6 +904,21 @@
document.getElementById('m_prod_price').value = price;
document.getElementById('m_prod_unit').value = unit;
document.getElementById('m_prod_sort').value = sort;
const notice = document.getElementById('productLockedNotice');
const priceInput = document.getElementById('m_prod_price');
if (isLocked) {
if (notice) notice.style.display = 'block';
priceInput.readOnly = true;
priceInput.title = 'Preis ist gesichert (bereits bestellt)';
priceInput.style.opacity = '0.7';
} else {
if (notice) notice.style.display = 'none';
priceInput.readOnly = false;
priceInput.title = '';
priceInput.style.opacity = '1';
}
modal.style.display = 'flex';
}
+3 -2
View File
@@ -9,7 +9,7 @@
<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 %}">
<a href="/?event_id={{ ev.id }}{% if is_valid_guest %}&token={{ guest_token }}{% endif %}" class="mini-chip {% if event and event.id == ev.id %}active{% endif %}">
{{ ev.event_date.strftime('%d.%m.') }} – {{ ev.title }}
</a>
{% endfor %}
@@ -56,6 +56,7 @@
<!-- Order Form -->
<form action="/order" method="POST" class="order-form" id="orderForm">
<input type="hidden" name="event_id" value="{{ event.id }}">
<input type="hidden" name="guest_token" value="{{ guest_token }}">
<div class="form-grid">
<!-- Left: Products -->
@@ -92,7 +93,7 @@
<div class="summary-card">
<h2>2. Angaben & Übersicht</h2>
{% if current_user and not is_guest_mode %}
{% if current_user %}
<div class="logged-in-badge">
<div class="user-avatar">👤</div>
<div>
+4 -3
View File
@@ -5,7 +5,7 @@
<div class="success-card">
<div class="success-icon">🎉</div>
<h1>Bestellung erfolgreich erfasst!</h1>
<p class="success-sub">Vielen Dank, <strong>{{ order.user.first_name }} {{ order.user.last_name }}</strong>!</p>
<p class="success-sub">Vielen Dank, <strong>{{ order.display_name }}</strong>!</p>
<p>Deine Brotzeit für <strong>{{ order.event.title }}</strong> ist fest reserviert.</p>
<div class="order-receipt">
@@ -24,9 +24,10 @@
</div>
</div>
<div class="success-actions">
<div class="success-actions" style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 24px;">
<a href="/" class="btn-primary">Zurück zur Übersicht</a>
<a href="/admin" class="btn-outline">Zum Einkäufer-Cockpit</a>
<a href="/events/{{ order.event_id }}/calendar.ics" class="btn-secondary" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Zu Outlook (.ics) hinzufügen</a>
<a href="/admin" class="btn-secondary" style="text-decoration: none;">Zum Einkäufer-Cockpit</a>
</div>
</div>
</div>