feat: Paket 1 - Token-based invites, event & deadline management with live countdown, printable distribution sheet
This commit is contained in:
+268
-30
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
from .models import Tenant, User, Product, Event, Order, OrderItem
|
from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation
|
||||||
from .auth import hash_password, verify_password
|
from .auth import hash_password, verify_password
|
||||||
|
|
||||||
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
|
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
|
||||||
@@ -56,6 +57,16 @@ def get_current_user(request: Request, db: Session) -> Optional[User]:
|
|||||||
return db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
return db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request, db: Session) -> User:
|
||||||
|
"""Stellt sicher, dass ein Administrator eingeloggt ist."""
|
||||||
|
current_user = get_current_user(request, db)
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Anmeldung erforderlich")
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Administratoren zugänglich")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def seed_demo_users_if_needed(tenant: Tenant, db: Session):
|
def seed_demo_users_if_needed(tenant: Tenant, db: Session):
|
||||||
"""Stellt sicher, dass die Standard-Accounts für die Demo existieren."""
|
"""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()
|
demo_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "maxi@waiswuaschd.de").first()
|
||||||
@@ -94,15 +105,28 @@ def index(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
current_user = get_current_user(request, db)
|
current_user = get_current_user(request, db)
|
||||||
if not current_user:
|
if not current_user:
|
||||||
# Require Login: Weiterleitung zur Anmeldeseite
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
# Aktuelle offene Runde suchen
|
# Aktuelle offene Runde suchen
|
||||||
event = db.query(Event).filter(
|
event = 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.deadline.asc()).first()
|
||||||
|
|
||||||
|
# Wenn keine offene, das neueste anzeigen
|
||||||
|
if not event:
|
||||||
|
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
||||||
|
|
||||||
|
# Fristprüfung & Countdown
|
||||||
|
is_closed = True
|
||||||
|
seconds_remaining = 0
|
||||||
|
if event:
|
||||||
|
is_closed = (event.status != "open") or (event.deadline < now)
|
||||||
|
if not is_closed:
|
||||||
|
seconds_remaining = max(0, int((event.deadline - now).total_seconds()))
|
||||||
|
|
||||||
# Alle aktiven Produkte des Mandanten
|
# Alle aktiven Produkte des Mandanten
|
||||||
products = db.query(Product).filter(
|
products = db.query(Product).filter(
|
||||||
Product.tenant_id == tenant.id,
|
Product.tenant_id == tenant.id,
|
||||||
@@ -131,11 +155,13 @@ 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,
|
||||||
|
"is_closed": is_closed,
|
||||||
|
"seconds_remaining": seconds_remaining,
|
||||||
"products": products,
|
"products": products,
|
||||||
"existing_order": existing_order,
|
"existing_order": existing_order,
|
||||||
"user_items": user_items,
|
"user_items": user_items,
|
||||||
"order_count": order_count,
|
"order_count": order_count,
|
||||||
"now": datetime.now()
|
"now": now
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -203,9 +229,46 @@ def quick_demo_login(
|
|||||||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/register")
|
@app.get("/logout")
|
||||||
def register(
|
def logout(request: Request):
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# --- EINLADUNGS- & BEITRITTS-FLOW (/join) ---
|
||||||
|
|
||||||
|
@app.get("/join", response_class=HTMLResponse)
|
||||||
|
def join_page(request: Request, token: str, db: Session = Depends(get_db)):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
|
||||||
|
|
||||||
|
invitation = db.query(Invitation).filter(
|
||||||
|
Invitation.token == token,
|
||||||
|
Invitation.tenant_id == tenant.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if not invitation:
|
||||||
|
error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team."
|
||||||
|
elif invitation.used_at is not None:
|
||||||
|
error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an."
|
||||||
|
elif invitation.expires_at < datetime.now():
|
||||||
|
error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link."
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "join.html", context={
|
||||||
|
"tenant": tenant,
|
||||||
|
"invitation": invitation,
|
||||||
|
"token": token,
|
||||||
|
"error": error,
|
||||||
|
"current_user": None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/join")
|
||||||
|
def process_join(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
token: str = Form(...),
|
||||||
first_name: str = Form(...),
|
first_name: str = Form(...),
|
||||||
last_name: str = Form(...),
|
last_name: str = Form(...),
|
||||||
email: str = Form(...),
|
email: str = Form(...),
|
||||||
@@ -213,16 +276,30 @@ def register(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
tenant = resolve_tenant(request, db)
|
tenant = resolve_tenant(request, db)
|
||||||
clean_email = email.strip().lower()
|
invitation = db.query(Invitation).filter(
|
||||||
|
Invitation.token == token,
|
||||||
|
Invitation.tenant_id == tenant.id
|
||||||
|
).first()
|
||||||
|
|
||||||
existing = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
|
if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now():
|
||||||
if existing:
|
raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink")
|
||||||
return templates.TemplateResponse(request, "login.html", context={
|
|
||||||
|
clean_email = email.strip().lower()
|
||||||
|
existing_user = db.query(User).filter(
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.email == clean_email
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
return templates.TemplateResponse(request, "join.html", context={
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"error": "Diese E-Mail-Adresse ist bereits registriert. Bitte melde dich an.",
|
"invitation": invitation,
|
||||||
|
"token": token,
|
||||||
|
"error": "Diese E-Mail-Adresse ist bereits im Team registriert. Bitte melde dich an.",
|
||||||
"current_user": None
|
"current_user": None
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
|
|
||||||
|
# Neuen Benutzer anlegen
|
||||||
username = clean_email.split("@")[0]
|
username = clean_email.split("@")[0]
|
||||||
new_user = User(
|
new_user = User(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
@@ -231,20 +308,21 @@ def register(
|
|||||||
first_name=first_name.strip(),
|
first_name=first_name.strip(),
|
||||||
last_name=last_name.strip(),
|
last_name=last_name.strip(),
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
role="user"
|
role=invitation.role
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
# Einladung als verwendet markieren
|
||||||
|
invitation.used_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
# Direkt einloggen
|
||||||
request.session["user_id"] = new_user.id
|
request.session["user_id"] = new_user.id
|
||||||
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
# --- BESTELLUNGEN (/order) ---
|
||||||
def logout(request: Request):
|
|
||||||
request.session.clear()
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/order")
|
@app.post("/order")
|
||||||
async def place_order(
|
async def place_order(
|
||||||
@@ -259,12 +337,11 @@ async def place_order(
|
|||||||
|
|
||||||
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
if not event or event.status != "open":
|
if not event or event.status != "open":
|
||||||
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist bereits geschlossen")
|
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist geschlossen.")
|
||||||
|
|
||||||
if event.deadline < datetime.now():
|
if event.deadline < datetime.now():
|
||||||
raise HTTPException(status_code=400, detail="Die Bestellfrist ist leider abgelaufen!")
|
raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!")
|
||||||
|
|
||||||
# Formulardaten auslesen
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
order_items = []
|
order_items = []
|
||||||
total_price = Decimal("0.00")
|
total_price = Decimal("0.00")
|
||||||
@@ -287,7 +364,7 @@ async def place_order(
|
|||||||
))
|
))
|
||||||
|
|
||||||
if not order_items:
|
if not order_items:
|
||||||
raise HTTPException(status_code=400, detail="Bitte wähle mindestens ein Produkt 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
|
# Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen
|
||||||
existing_order = db.query(Order).filter(
|
existing_order = db.query(Order).filter(
|
||||||
@@ -330,7 +407,7 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db)
|
|||||||
if not current_user:
|
if not current_user:
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first()
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
|
||||||
|
|
||||||
@@ -341,8 +418,15 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db)
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# --- ADMIN COCKPIT (/admin) ---
|
||||||
|
|
||||||
@app.get("/admin", response_class=HTMLResponse)
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
def admin_dashboard(
|
||||||
|
request: Request,
|
||||||
|
event_id: Optional[int] = None,
|
||||||
|
tab: str = "orders",
|
||||||
|
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={})
|
||||||
@@ -350,17 +434,26 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
|||||||
current_user = get_current_user(request, db)
|
current_user = get_current_user(request, db)
|
||||||
if not current_user:
|
if not current_user:
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren")
|
||||||
|
|
||||||
# Neuestes Event finden
|
# Alle Events des Mandanten
|
||||||
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
|
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).all()
|
||||||
|
|
||||||
|
# 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 and all_events:
|
||||||
|
selected_event = all_events[0]
|
||||||
|
|
||||||
orders = []
|
orders = []
|
||||||
item_sums = {}
|
item_sums = {}
|
||||||
total_revenue = Decimal("0.00")
|
total_revenue = Decimal("0.00")
|
||||||
total_paid = Decimal("0.00")
|
total_paid = Decimal("0.00")
|
||||||
|
|
||||||
if event:
|
if selected_event:
|
||||||
orders = db.query(Order).filter(Order.event_id == event.id).order_by(Order.created_at.asc()).all()
|
orders = db.query(Order).filter(Order.event_id == selected_event.id).order_by(Order.created_at.asc()).all()
|
||||||
for ord_obj in orders:
|
for ord_obj in orders:
|
||||||
total_revenue += ord_obj.total_price
|
total_revenue += ord_obj.total_price
|
||||||
if ord_obj.is_paid:
|
if ord_obj.is_paid:
|
||||||
@@ -373,17 +466,162 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
|||||||
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
|
||||||
|
team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all()
|
||||||
|
pending_invitations = db.query(Invitation).filter(
|
||||||
|
Invitation.tenant_id == tenant.id,
|
||||||
|
Invitation.used_at == None,
|
||||||
|
Invitation.expires_at > datetime.now()
|
||||||
|
).order_by(Invitation.created_at.desc()).all()
|
||||||
|
|
||||||
return templates.TemplateResponse(request, "admin.html", context={
|
return templates.TemplateResponse(request, "admin.html", context={
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"event": event,
|
"all_events": all_events,
|
||||||
|
"event": selected_event,
|
||||||
"orders": orders,
|
"orders": orders,
|
||||||
"item_sums": item_sums,
|
"item_sums": item_sums,
|
||||||
"total_revenue": total_revenue,
|
"total_revenue": total_revenue,
|
||||||
"total_paid": total_paid
|
"total_paid": total_paid,
|
||||||
|
"team_members": team_members,
|
||||||
|
"pending_invitations": pending_invitations,
|
||||||
|
"active_tab": tab,
|
||||||
|
"now": datetime.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/events/new")
|
||||||
|
def admin_create_event(
|
||||||
|
request: Request,
|
||||||
|
title: str = Form(...),
|
||||||
|
event_date: str = Form(...),
|
||||||
|
deadline_date: str = Form(...),
|
||||||
|
deadline_time: str = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
require_admin(request, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
|
||||||
|
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
|
||||||
|
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
||||||
|
|
||||||
|
new_event = Event(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
title=title.strip(),
|
||||||
|
event_date=ev_date,
|
||||||
|
deadline=deadline_dt,
|
||||||
|
status="open"
|
||||||
|
)
|
||||||
|
db.add(new_event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_event)
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/events/{event_id}/edit")
|
||||||
|
def admin_edit_event(
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
title: str = Form(...),
|
||||||
|
event_date: str = Form(...),
|
||||||
|
deadline_date: str = Form(...),
|
||||||
|
deadline_time: str = Form(...),
|
||||||
|
status_val: str = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
require_admin(request, db)
|
||||||
|
|
||||||
|
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
|
||||||
|
dl_str = f"{deadline_date.strip()} {deadline_time.strip()}"
|
||||||
|
deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}")
|
||||||
|
|
||||||
|
event.title = title.strip()
|
||||||
|
event.event_date = ev_date
|
||||||
|
event.deadline = deadline_dt
|
||||||
|
event.status = status_val
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/events/{event_id}/toggle-status")
|
||||||
|
def admin_toggle_event_status(
|
||||||
|
event_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
require_admin(request, db)
|
||||||
|
|
||||||
|
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event nicht gefunden")
|
||||||
|
|
||||||
|
event.status = "closed" if event.status == "open" else "open"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/invitations/create")
|
||||||
|
def admin_create_invitation(
|
||||||
|
request: Request,
|
||||||
|
email: Optional[str] = Form(None),
|
||||||
|
role: str = Form("user"),
|
||||||
|
valid_days: int = Form(14),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
admin = require_admin(request, db)
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(20)
|
||||||
|
expires_at = datetime.now() + timedelta(days=valid_days)
|
||||||
|
clean_email = email.strip().lower() if email and email.strip() else None
|
||||||
|
|
||||||
|
inv = Invitation(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
token=token,
|
||||||
|
email=clean_email,
|
||||||
|
role=role,
|
||||||
|
created_by=admin.id,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/invitations/{inv_id}/revoke")
|
||||||
|
def admin_revoke_invitation(
|
||||||
|
inv_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
tenant = resolve_tenant(request, db)
|
||||||
|
require_admin(request, db)
|
||||||
|
|
||||||
|
inv = db.query(Invitation).filter(Invitation.id == inv_id, Invitation.tenant_id == tenant.id).first()
|
||||||
|
if inv:
|
||||||
|
db.delete(inv)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/toggle-paid/{order_id}")
|
@app.post("/admin/toggle-paid/{order_id}")
|
||||||
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
|
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
current_user = get_current_user(request, db)
|
current_user = get_current_user(request, db)
|
||||||
|
|||||||
+18
-1
@@ -17,6 +17,7 @@ class Tenant(Base):
|
|||||||
events = relationship("Event", back_populates="tenant", cascade="all, delete-orphan")
|
events = relationship("Event", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
orders = relationship("Order", back_populates="tenant", cascade="all, delete-orphan")
|
orders = relationship("Order", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
|
invitations = relationship("Invitation", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -29,7 +30,7 @@ class User(Base):
|
|||||||
password_hash = Column(String(255), default="")
|
password_hash = Column(String(255), default="")
|
||||||
first_name = Column(String(100), nullable=False)
|
first_name = Column(String(100), nullable=False)
|
||||||
last_name = Column(String(100), nullable=False)
|
last_name = Column(String(100), nullable=False)
|
||||||
role = Column(String(20), default="user")
|
role = Column(String(20), default="user") # 'admin', 'user'
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
@@ -37,6 +38,22 @@ class User(Base):
|
|||||||
orders = relationship("Order", back_populates="user")
|
orders = relationship("Order", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Invitation(Base):
|
||||||
|
__tablename__ = "invitations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
token = Column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
email = Column(String(191), nullable=True)
|
||||||
|
role = Column(String(20), default="user", nullable=False)
|
||||||
|
created_by = Column(Integer, nullable=True)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
used_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
tenant = relationship("Tenant", back_populates="invitations")
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
__tablename__ = "products"
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
|||||||
+367
-150
@@ -120,88 +120,166 @@ body {
|
|||||||
background: rgba(251, 191, 36, 0.12);
|
background: rgba(251, 191, 36, 0.12);
|
||||||
color: var(--accent-gold);
|
color: var(--accent-gold);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links a.nav-admin:hover {
|
.nav-links a.nav-login {
|
||||||
background: rgba(251, 191, 36, 0.2);
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main */
|
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 36px 0 60px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner */
|
/* Banner */
|
||||||
.banner {
|
.banner {
|
||||||
background: linear-gradient(135deg, rgba(26, 38, 64, 0.9) 0%, rgba(19, 27, 46, 0.9) 100%);
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.9) 100%);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 28px;
|
padding: 30px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 28px;
|
||||||
backdrop-filter: blur(12px);
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.banner-closed {
|
||||||
|
background: linear-gradient(135deg, rgba(69, 26, 26, 0.6) 0%, rgba(15, 23, 42, 0.9) 100%);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-top-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-badge {
|
.banner-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #86efac;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-badge.closed {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown */
|
||||||
|
.countdown-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(11, 15, 25, 0.7);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--success);
|
}
|
||||||
margin-bottom: 8px;
|
|
||||||
|
.countdown-container.urgent {
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
animation: pulse-border 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-container.expired {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.closed-notice-bar {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner h1 {
|
.banner h1 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.85rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-sub {
|
.banner-sub {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-stats {
|
.banner-stats {
|
||||||
font-size: 0.9rem;
|
color: var(--text-muted);
|
||||||
color: var(--accent);
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form Grid */
|
/* Form Grid */
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: 1.6fr 1fr;
|
||||||
gap: 32px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 850px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-section h2, .summary-section h2 {
|
.products-section h2, .summary-section h2 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.25rem;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-list {
|
.product-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-card {
|
.product-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-md);
|
||||||
padding: 20px;
|
padding: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
backdrop-filter: blur(12px);
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover:not(.disabled) {
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-info h3 {
|
.product-info h3 {
|
||||||
@@ -212,62 +290,71 @@ body {
|
|||||||
.product-desc {
|
.product-desc {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-price {
|
.product-price {
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-price small {
|
.product-price small {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: normal;
|
font-weight: 400;
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quantity Control */
|
/* Quantity Control */
|
||||||
.quantity-control {
|
.quantity-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
background: rgba(11, 15, 25, 0.8);
|
background: rgba(11, 15, 25, 0.8);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qty-btn {
|
.qty-btn {
|
||||||
background: transparent;
|
width: 34px;
|
||||||
border: none;
|
height: 34px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
width: 38px;
|
border-radius: 6px;
|
||||||
height: 38px;
|
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qty-btn:hover {
|
.qty-btn:hover:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--accent);
|
||||||
|
color: #0b0f19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qty-input {
|
.qty-input {
|
||||||
width: 45px;
|
width: 44px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
text-align: center;
|
font-size: 1.1rem;
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
-moz-appearance: textfield;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qty-input::-webkit-outer-spin-button,
|
.qty-input:disabled {
|
||||||
.qty-input::-webkit-inner-spin-button {
|
color: var(--text-muted);
|
||||||
-webkit-appearance: none;
|
}
|
||||||
margin: 0;
|
|
||||||
|
.qty-input:focus {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Summary Card */
|
/* Summary Card */
|
||||||
@@ -275,29 +362,16 @@ body {
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 28px;
|
padding: 24px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 90px;
|
top: 90px;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logged-in-badge {
|
.logged-in-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
background: rgba(11, 15, 25, 0.8);
|
background: rgba(11, 15, 25, 0.6);
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice-box {
|
|
||||||
background: rgba(56, 189, 248, 0.1);
|
|
||||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -318,7 +392,7 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input {
|
.input-group input, .input-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(11, 15, 25, 0.8);
|
background: rgba(11, 15, 25, 0.8);
|
||||||
@@ -328,11 +402,23 @@ body {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input:focus {
|
.input-group input:focus, .input-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-input {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -370,17 +456,29 @@ body {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:hover {
|
.btn-submit:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4);
|
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
margin-top: 12px;
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Login Page */
|
/* Login Page */
|
||||||
@@ -428,7 +526,7 @@ body {
|
|||||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-login-card h3 {
|
.demo-login-card h3 {
|
||||||
@@ -476,31 +574,23 @@ body {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-tabs {
|
.invite-notice-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--border-color);
|
align-items: flex-start;
|
||||||
margin-bottom: 20px;
|
gap: 12px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-tab {
|
.invite-icon {
|
||||||
flex: 1;
|
font-size: 1.3rem;
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-tab.active {
|
.alert-error {
|
||||||
color: var(--text-main);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
border: 1px solid var(--danger);
|
border: 1px solid var(--danger);
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
@@ -520,78 +610,73 @@ body {
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Success Card */
|
/* Admin Dashboard Tabs */
|
||||||
.success-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 40px;
|
|
||||||
text-align: center;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-sub {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-receipt {
|
|
||||||
background: rgba(11, 15, 25, 0.8);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 24px;
|
|
||||||
margin: 24px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-list li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-total {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding-top: 12px;
|
|
||||||
color: var(--accent-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin Dashboard */
|
|
||||||
.admin-header {
|
.admin-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-actions-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sub {
|
.admin-sub {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-btn {
|
||||||
|
padding: 12px 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-btn:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab-btn.active {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
@@ -705,10 +790,75 @@ body {
|
|||||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-role {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-role.admin {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invitations List */
|
||||||
|
.invitations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(11, 15, 25, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-small {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-revoke-small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 20px;
|
padding: 10px 18px;
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
|
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
|
||||||
color: #0b0f19;
|
color: #0b0f19;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -718,15 +868,20 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-secondary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 20px;
|
padding: 10px 18px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
@@ -760,3 +915,65 @@ body {
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- PRINT MEDIA QUERY --- */
|
||||||
|
.print-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-header {
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-header h1 {
|
||||||
|
font-size: 18pt;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-table th {
|
||||||
|
background-color: #f2f2f2 !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+271
-6
@@ -3,16 +3,111 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Printable distribution sheet for the kitchen (hidden in screen, visible in print) -->
|
||||||
|
<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>
|
||||||
|
</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 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></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;">{{ "%.2f"|format(order.total_price) }} €</td>
|
||||||
|
<td style="text-align: center;">{% if order.is_paid %}Bezahlt ✓{% else %}Offen{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal Screen Content -->
|
||||||
|
<div class="no-print">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>👨🍳 Einkäufer-Cockpit</h1>
|
<h1>👨🍳 Einkäufer- & Admin-Cockpit</h1>
|
||||||
<p class="admin-sub">Mandant: <strong>{{ tenant.name }}</strong> | Aktuelle Brotzeitrunde</p>
|
<p class="admin-sub">Portal: <strong>{{ tenant.name }}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-actions-top">
|
<div class="admin-actions-top">
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}">
|
||||||
|
🛒 Bestellungen & Kasse
|
||||||
|
</a>
|
||||||
|
<a href="?tab=event{% if event %}&event_id={{ event.id }}{% endif %}" class="admin-tab-btn {% if active_tab == 'event' %}active{% endif %}">
|
||||||
|
📅 Event- & Frist-Planung
|
||||||
|
</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 }})
|
||||||
|
</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 %}
|
{% if event %}
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
@@ -30,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Teilnehmer</div>
|
<div class="stat-label">Teilnehmer</div>
|
||||||
<div class="stat-value">{{ orders|length }} Personen</div>
|
<div class="stat-value">{{ orders|length }} Kollegen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,7 +133,7 @@
|
|||||||
<!-- Aggregated Shopping List -->
|
<!-- Aggregated Shopping List -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2>🛒 Sammelliste für Metzger & Bäcker</h2>
|
<h2>🛒 Sammelliste für Metzger & Bäcker</h2>
|
||||||
<p class="card-hint">Exakt aufsummierte Gesamtmengen aller Kollegen:</p>
|
<p class="card-hint">Aufsummierte Mengen für den Einkauf:</p>
|
||||||
|
|
||||||
<div class="shopping-table-wrapper">
|
<div class="shopping-table-wrapper">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -67,7 +162,7 @@
|
|||||||
<!-- Individual Orders & Payment Checklist -->
|
<!-- Individual Orders & Payment Checklist -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2>👥 Wer hat bestellt? (Strichliste)</h2>
|
<h2>👥 Wer hat bestellt? (Strichliste)</h2>
|
||||||
<p class="card-hint">Klicke auf den Status, um den Bezahlstatus zu ändern:</p>
|
<p class="card-hint">Klicke auf den Status, um bezahlt/offen umzuschalten:</p>
|
||||||
|
|
||||||
<div class="orders-table-wrapper">
|
<div class="orders-table-wrapper">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -114,10 +209,172 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h2>Keine offene Bestellrunde gefunden</h2>
|
<h2>Keine offene Bestellrunde gefunden</h2>
|
||||||
<p>Aktuell gibt es kein aktives Event.</p>
|
<p>Erstelle im Reiter <strong>Event- & Frist-Planung</strong> eine neue Runde.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- TAB 2: EVENT- & FRIST-PLANUNG -->
|
||||||
|
{% elif active_tab == 'event' %}
|
||||||
|
<div class="admin-columns">
|
||||||
|
<!-- Edit current event -->
|
||||||
|
{% if event %}
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2>✏️ Aktives Event bearbeiten</h2>
|
||||||
|
<p class="card-hint">Passe Titel, Termin und die Bestellfrist sekundengenau an:</p>
|
||||||
|
|
||||||
|
<form action="/admin/events/{{ event.id }}/edit" method="POST" class="admin-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="edit_title">Titel der Runde</label>
|
||||||
|
<input type="text" id="edit_title" name="title" value="{{ event.title }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="edit_event_date">Brotzeit-Tag (Datum)</label>
|
||||||
|
<input type="date" id="edit_event_date" name="event_date" value="{{ event.event_date.strftime('%Y-%m-%d') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="edit_deadline_date">Bestellschluss (Tag)</label>
|
||||||
|
<input type="date" id="edit_deadline_date" name="deadline_date" value="{{ event.deadline.strftime('%Y-%m-%d') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="edit_deadline_time">Bestellschluss (Uhrzeit)</label>
|
||||||
|
<input type="time" id="edit_deadline_time" name="deadline_time" value="{{ event.deadline.strftime('%H:%M') }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="edit_status">Status der Runde</label>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Änderungen speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create new event -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="new_event_date">Brotzeit-Tag (Datum)</label>
|
||||||
|
<input type="date" id="new_event_date" name="event_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="new_deadline_date">Bestellschluss (Tag)</label>
|
||||||
|
<input type="date" id="new_deadline_date" name="deadline_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="new_deadline_time">Bestellschluss (Uhrzeit)</label>
|
||||||
|
<input type="time" id="new_deadline_time" name="deadline_time" value="08:30" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="margin-top: 1rem;">Runde jetzt starten</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 3: TEAM & EINLADUNGEN -->
|
||||||
|
{% elif active_tab == 'team' %}
|
||||||
|
<div class="admin-columns">
|
||||||
|
<!-- Generate invite link -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2>✉️ Teammitglied einladen</h2>
|
||||||
|
<p class="card-hint">Erzeuge einen sicheren Einladungslink zum Teilen per Chat (Slack, Teams, WhatsApp):</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="inv_valid">Gültigkeit</label>
|
||||||
|
<select id="inv_valid" name="valid_days">
|
||||||
|
<option value="7">7 Tage</option>
|
||||||
|
<option value="14" selected>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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if pending_invitations %}
|
||||||
|
<h3 style="margin-top: 2rem;">Offene Einladungslinks</h3>
|
||||||
|
<div class="invitations-list">
|
||||||
|
{% for inv in pending_invitations %}
|
||||||
|
<div class="invite-item">
|
||||||
|
<div class="invite-info">
|
||||||
|
<strong>{% if inv.email %}{{ inv.email }}{% else %}Offener Link{% endif %}</strong>
|
||||||
|
<span class="badge-role">{{ inv.role }}</span>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">Gültig bis {{ inv.expires_at.strftime('%d.%m.%Y') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="invite-actions">
|
||||||
|
<button type="button" class="btn-copy-small" onclick="copyInviteUrl('{{ inv.token }}')">📋 Kopieren</button>
|
||||||
|
<form action="/admin/invitations/{{ inv.id }}/revoke" method="POST" style="margin: 0; display: inline;">
|
||||||
|
<button type="submit" class="btn-revoke-small" title="Link widerrufen">✕</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<div class="orders-table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in team_members %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ member.first_name }} {{ member.last_name }}</strong></td>
|
||||||
|
<td><small class="text-muted">{{ member.email }}</small></td>
|
||||||
|
<td>
|
||||||
|
{% if member.role == 'admin' %}
|
||||||
|
<span class="badge-role admin">Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-role">Mitarbeiter</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -132,5 +389,13 @@
|
|||||||
alert("Einkaufsliste in die Zwischenablage kopiert! Kann direkt in WhatsApp oder Slack eingefügt werden.");
|
alert("Einkaufsliste in die Zwischenablage kopiert! Kann direkt in WhatsApp oder Slack eingefügt werden.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyInviteUrl(token) {
|
||||||
|
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 den Kollegen senden.");
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+95
-17
@@ -5,18 +5,34 @@
|
|||||||
|
|
||||||
<!-- Header Banner -->
|
<!-- Header Banner -->
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<div class="banner">
|
<div class="banner {% if is_closed %}banner-closed{% endif %}">
|
||||||
<div class="banner-badge">🟢 Runde geöffnet</div>
|
<div class="banner-top-row">
|
||||||
|
<div class="banner-badge {% if is_closed %}closed{% endif %}">
|
||||||
|
{% if is_closed %}🔴 Runde geschlossen{% else %}🟢 Runde geöffnet{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="countdown-container" id="countdownContainer">
|
||||||
|
<span class="countdown-label">Frist:</span>
|
||||||
|
<span class="countdown-timer" id="countdownTimer">Berechne...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1>{{ event.title }}</h1>
|
<h1>{{ event.title }}</h1>
|
||||||
<p class="banner-sub">
|
<p class="banner-sub">
|
||||||
Brotzeit-Tag: <strong>{{ event.event_date.strftime('%d.%m.%Y') }}</strong> |
|
Brotzeit-Tag: <strong>{{ event.event_date.strftime('%d.%m.%Y') }}</strong> |
|
||||||
Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong>
|
Bestellschluss: <strong>{{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="banner-stats">
|
<div class="banner-stats">
|
||||||
<span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen von Kollegen eingegangen</span>
|
<span>👥 Bereits <strong>{{ order_count }}</strong> Bestellungen von Kollegen eingegangen</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if is_closed %}
|
||||||
|
<div class="closed-notice-bar">
|
||||||
|
🔒 <strong>Bestellschluss erreicht:</strong> Diese Bestellrunde ist geschlossen. Änderungen oder neue Bestellungen sind nicht mehr möglich. Wende dich bei Notfällen direkt an den Organisator.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Order Form -->
|
<!-- Order Form -->
|
||||||
<form action="/order" method="POST" class="order-form" id="orderForm">
|
<form action="/order" method="POST" class="order-form" id="orderForm">
|
||||||
<input type="hidden" name="event_id" value="{{ event.id }}">
|
<input type="hidden" name="event_id" value="{{ event.id }}">
|
||||||
@@ -28,16 +44,16 @@
|
|||||||
<div class="product-list">
|
<div class="product-list">
|
||||||
{% for product in products %}
|
{% for product in products %}
|
||||||
{% set current_qty = user_items.get(product.id, 0) %}
|
{% set current_qty = user_items.get(product.id, 0) %}
|
||||||
<div class="product-card" data-price="{{ product.price }}" data-id="{{ product.id }}">
|
<div class="product-card {% if is_closed %}disabled{% endif %}" data-price="{{ product.price }}" data-id="{{ product.id }}">
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<h3>{{ product.name }}</h3>
|
<h3>{{ product.name }}</h3>
|
||||||
<p class="product-desc">{{ product.description }}</p>
|
<p class="product-desc">{{ product.description }}</p>
|
||||||
<div class="product-price">{{ "%.2f"|format(product.price) }} € <small>/ {{ product.unit }}</small></div>
|
<div class="product-price">{{ "%.2f"|format(product.price) }} € <small>/ {{ product.unit }}</small></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quantity-control">
|
<div class="quantity-control">
|
||||||
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)">-</button>
|
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)" {% if is_closed %}disabled{% endif %}>-</button>
|
||||||
<input type="number" id="qty_{{ product.id }}" name="qty_{{ product.id }}" value="{{ current_qty }}" min="0" max="50" class="qty-input" onchange="updateTotal()">
|
<input type="number" id="qty_{{ product.id }}" name="qty_{{ product.id }}" value="{{ current_qty }}" min="0" max="50" class="qty-input" onchange="updateTotal()" {% if is_closed %}disabled{% endif %}>
|
||||||
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, 1)">+</button>
|
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, 1)" {% if is_closed %}disabled{% endif %}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -59,7 +75,8 @@
|
|||||||
|
|
||||||
{% if existing_order %}
|
{% if existing_order %}
|
||||||
<div class="notice-box">
|
<div class="notice-box">
|
||||||
ℹ️ Du hast bereits für diese Runde bestellt. Du kannst deine Mengen hier anpassen und aktualisieren.
|
ℹ️ Du hast bereits für diese Runde bestellt ({% if existing_order.is_paid %}<strong>Bereits bezahlt ✓</strong>{% else %}Noch offen{% endif %}).
|
||||||
|
{% if not is_closed %}Du kannst deine Mengen anpassen und aktualisieren.{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -70,14 +87,21 @@
|
|||||||
<div class="total-amount" id="totalAmount">0,00 €</div>
|
<div class="total-amount" id="totalAmount">0,00 €</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-submit" id="submitBtn">
|
<button type="submit" class="btn-submit" id="submitBtn" {% if is_closed %}disabled{% endif %}>
|
||||||
{% if existing_order %}
|
{% if is_closed %}
|
||||||
|
🔒 Bestellschluss vorüber
|
||||||
|
{% elif existing_order %}
|
||||||
🥨 Bestellung aktualisieren
|
🥨 Bestellung aktualisieren
|
||||||
{% else %}
|
{% else %}
|
||||||
🥨 Bestellung verbindlich abschicken
|
🥨 Bestellung verbindlich abschicken
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<small class="form-hint">Du kannst deine Bestellung bis zum Stichtag jederzeit hier im Portal anpassen.</small>
|
|
||||||
|
{% if not is_closed %}
|
||||||
|
<small class="form-hint">Du kannst deine Bestellung bis zum Bestellschluss jederzeit hier anpassen.</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="form-hint text-warning">Die Frist ist abgelaufen.</small>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +113,7 @@
|
|||||||
<h2>Aktuell keine offene Bestellrunde</h2>
|
<h2>Aktuell keine offene Bestellrunde</h2>
|
||||||
<p>Für dieses Team ist im Moment keine Brotzeit geplant.</p>
|
<p>Für dieses Team ist im Moment keine Brotzeit geplant.</p>
|
||||||
{% if current_user.role == 'admin' %}
|
{% if current_user.role == 'admin' %}
|
||||||
<a href="/admin" class="btn-primary">Neue Bestellrunde im Cockpit anlegen</a>
|
<a href="/admin?tab=event" class="btn-primary" style="margin-top: 1rem;">Neue Bestellrunde im Cockpit anlegen</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,6 +123,7 @@
|
|||||||
<script>
|
<script>
|
||||||
function changeQty(productId, delta) {
|
function changeQty(productId, delta) {
|
||||||
const input = document.getElementById('qty_' + productId);
|
const input = document.getElementById('qty_' + productId);
|
||||||
|
if (!input || input.disabled) return;
|
||||||
let val = parseInt(input.value) || 0;
|
let val = parseInt(input.value) || 0;
|
||||||
val = Math.max(0, val + delta);
|
val = Math.max(0, val + delta);
|
||||||
input.value = val;
|
input.value = val;
|
||||||
@@ -112,20 +137,73 @@
|
|||||||
const price = parseFloat(card.dataset.price) || 0;
|
const price = parseFloat(card.dataset.price) || 0;
|
||||||
const id = card.dataset.id;
|
const id = card.dataset.id;
|
||||||
const qtyInput = document.getElementById('qty_' + id);
|
const qtyInput = document.getElementById('qty_' + id);
|
||||||
const qty = parseInt(qtyInput.value) || 0;
|
const qty = parseInt(qtyInput ? qtyInput.value : 0) || 0;
|
||||||
total += price * qty;
|
total += price * qty;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('totalAmount').innerText = total.toFixed(2).replace('.', ',') + ' €';
|
const totalEl = document.getElementById('totalAmount');
|
||||||
|
if (totalEl) {
|
||||||
|
totalEl.innerText = total.toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LIVE COUNTDOWN TIMER ---
|
||||||
|
{% if event and not is_closed %}
|
||||||
|
const deadlineMs = new Date("{{ event.deadline.isoformat() }}").getTime();
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
const nowMs = new Date().getTime();
|
||||||
|
const diffMs = deadlineMs - nowMs;
|
||||||
|
const timerEl = document.getElementById('countdownTimer');
|
||||||
|
const containerEl = document.getElementById('countdownContainer');
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
if (total > 0) {
|
|
||||||
submitBtn.removeAttribute('disabled');
|
if (diffMs <= 0) {
|
||||||
submitBtn.style.opacity = '1';
|
if (timerEl) timerEl.innerText = "Abgelaufen (geschlossen)";
|
||||||
|
if (containerEl) {
|
||||||
|
containerEl.classList.add('expired');
|
||||||
|
}
|
||||||
|
// Formular sperren
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = "🔒 Bestellschluss vorüber";
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.qty-btn').forEach(btn => btn.disabled = true);
|
||||||
|
document.querySelectorAll('.qty-input').forEach(inp => inp.disabled = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
if (days > 0) {
|
||||||
|
text = `${days}T ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||||
} else {
|
} else {
|
||||||
submitBtn.style.opacity = '0.7';
|
text = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerEl) timerEl.innerText = `⏳ Noch ${text}`;
|
||||||
|
|
||||||
|
if (totalSeconds < 3600 && containerEl) {
|
||||||
|
containerEl.classList.add('urgent');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pad(num) {
|
||||||
|
return num.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(updateCountdown, 1000);
|
||||||
|
updateCountdown();
|
||||||
|
{% elif is_closed %}
|
||||||
|
const timerEl = document.getElementById('countdownTimer');
|
||||||
|
if (timerEl) timerEl.innerText = "Frist beendet";
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', updateTotal);
|
document.addEventListener('DOMContentLoaded', updateTotal);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container narrow">
|
||||||
|
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-icon">🎉</div>
|
||||||
|
<h1>Team-Einladung</h1>
|
||||||
|
<p class="login-sub">
|
||||||
|
Portal: <strong>{{ tenant.name }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<a href="/login" class="btn-primary">Zur Anmeldung</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="privacy-note">
|
||||||
|
👋 Du wurdest eingeladen, am Schmankerl-Brotzeitportal von <strong>{{ tenant.name }}</strong> teilzunehmen. Bitte lege deinen Namen und ein persönliches Passwort fest.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/join" method="POST" class="auth-form" style="margin-top: 1.5rem;">
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="reg_first_name">Vorname</label>
|
||||||
|
<input type="text" id="reg_first_name" name="first_name" required placeholder="z. B. Florian">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="reg_last_name">Nachname</label>
|
||||||
|
<input type="text" id="reg_last_name" name="last_name" required placeholder="z. B. Meier">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="reg_email">E-Mail-Adresse</label>
|
||||||
|
{% if invitation.email %}
|
||||||
|
<input type="email" id="reg_email" name="email" value="{{ invitation.email }}" readonly class="readonly-input">
|
||||||
|
<small class="form-hint">E-Mail durch Einladung vorgegeben.</small>
|
||||||
|
{% else %}
|
||||||
|
<input type="email" id="reg_email" name="email" required placeholder="name@firma.de">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="reg_password">Passwort festlegen</label>
|
||||||
|
<input type="password" id="reg_password" name="password" required minlength="6" placeholder="Mindestens 6 Zeichen">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit" style="margin-top: 1rem;">
|
||||||
|
🥨 Konto aktivieren & Team beitreten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+20
-55
@@ -1,16 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Anmelden – Schmankerl{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container narrow">
|
<div class="container narrow">
|
||||||
|
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<div class="login-icon">🥨</div>
|
<div class="login-icon">🥨</div>
|
||||||
<h1>Anmeldung erforderlich</h1>
|
<h1>Anmeldung</h1>
|
||||||
<p class="login-sub">
|
<p class="login-sub">
|
||||||
Portal: <strong>{{ tenant.name if tenant else 'Schmankerl' }}</strong>
|
Portal: <strong>{{ tenant.name }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="privacy-note">
|
<div class="privacy-note">
|
||||||
🔒 Aus Datenschutzgründen (DSGVO) sind Bestelllisten und Teilnehmerdaten nur für angemeldete Teammitglieder sichtbar.
|
🔒 Aus Datenschutzgründen (DSGVO) sind Bestelllisten und Teilnehmerdaten nur für angemeldete Teammitglieder sichtbar.
|
||||||
@@ -18,13 +16,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert-danger">{{ error }}</div>
|
<div class="alert-error">
|
||||||
{% endif %}
|
{{ error }}
|
||||||
{% if success %}
|
</div>
|
||||||
<div class="alert-success">{{ success }}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- 1-Click Demo Login Box (For easy review & testing) -->
|
{% if success %}
|
||||||
|
<div class="alert-success">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 1-Click Demo Login Box -->
|
||||||
<div class="demo-login-card">
|
<div class="demo-login-card">
|
||||||
<h3>🚀 1-Klick Demo-Zugang</h3>
|
<h3>🚀 1-Klick Demo-Zugang</h3>
|
||||||
<p class="card-hint">Wähle eine Rolle zum sofortigen Ausprobieren:</p>
|
<p class="card-hint">Wähle eine Rolle zum sofortigen Ausprobieren:</p>
|
||||||
@@ -46,14 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<div class="auth-tabs">
|
|
||||||
<button class="auth-tab active" id="tabLoginBtn" onclick="switchAuthTab('login')">Anmelden</button>
|
|
||||||
<button class="auth-tab" id="tabRegisterBtn" onclick="switchAuthTab('register')">Neu registrieren</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form action="/login" method="POST" id="formLogin" class="auth-form">
|
<form action="/login" method="POST" id="formLogin" class="auth-form" style="margin-top: 1.5rem;">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="login_email">E-Mail-Adresse</label>
|
<label for="login_email">E-Mail-Adresse</label>
|
||||||
<input type="email" id="login_email" name="email" required placeholder="name@firma.de">
|
<input type="email" id="login_email" name="email" required placeholder="name@firma.de">
|
||||||
@@ -65,49 +62,17 @@
|
|||||||
<button type="submit" class="btn-submit">Anmelden</button>
|
<button type="submit" class="btn-submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Register Form (Hidden by default) -->
|
<div class="invite-notice-card">
|
||||||
<form action="/register" method="POST" id="formRegister" class="auth-form" style="display: none;">
|
<div class="invite-icon">✉️</div>
|
||||||
<div class="input-group">
|
<div>
|
||||||
<label for="reg_first_name">Vorname</label>
|
<strong>Neu im Team?</strong><br>
|
||||||
<input type="text" id="reg_first_name" name="first_name" required placeholder="z. B. Anna">
|
<small class="text-muted">
|
||||||
|
Registrierungen sind nur auf Einladung möglich. Wende dich an deinen Team-Organisator, um einen persönlichen Einladungslink zu erhalten.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
|
||||||
<label for="reg_last_name">Nachname</label>
|
|
||||||
<input type="text" id="reg_last_name" name="last_name" required placeholder="z. B. Huber">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
|
||||||
<label for="reg_email">E-Mail-Adresse</label>
|
|
||||||
<input type="email" id="reg_email" name="email" required placeholder="anna@firma.de">
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="reg_password">Passwort festlegen</label>
|
|
||||||
<input type="password" id="reg_password" name="password" required minlength="6" placeholder="Mindestens 6 Zeichen">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-submit">Konto erstellen & anmelden</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function switchAuthTab(tab) {
|
|
||||||
const loginForm = document.getElementById('formLogin');
|
|
||||||
const regForm = document.getElementById('formRegister');
|
|
||||||
const loginBtn = document.getElementById('tabLoginBtn');
|
|
||||||
const regBtn = document.getElementById('tabRegisterBtn');
|
|
||||||
|
|
||||||
if (tab === 'login') {
|
|
||||||
loginForm.style.display = 'block';
|
|
||||||
regForm.style.display = 'none';
|
|
||||||
loginBtn.classList.add('active');
|
|
||||||
regBtn.classList.remove('active');
|
|
||||||
} else {
|
|
||||||
loginForm.style.display = 'none';
|
|
||||||
regForm.style.display = 'block';
|
|
||||||
loginBtn.classList.remove('active');
|
|
||||||
regBtn.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user