feat: Paket 1 - Token-based invites, event & deadline management with live countdown, printable distribution sheet

This commit is contained in:
2026-09-21 11:16:44 +02:00
parent 4f5ef164c3
commit 83393e363e
7 changed files with 1203 additions and 358 deletions
+268 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' }} &nbsp;|&nbsp;
<strong>Datum:</strong> {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} &nbsp;|&nbsp;
<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
View File
@@ -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> &nbsp;|&nbsp; Brotzeit-Tag: <strong>{{ event.event_date.strftime('%d.%m.%Y') }}</strong> &nbsp;|&nbsp;
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 %}
+65
View File
@@ -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
View File
@@ -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 %}