feat: 4-tier role concept, personal balance & cash register system, left off-canvas drawer navigation

This commit is contained in:
2026-09-22 09:05:14 +02:00
parent 0bdcbe7976
commit 896f1d7d08
8 changed files with 1350 additions and 257 deletions
+270 -29
View File
@@ -13,7 +13,7 @@ from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy.orm import Session
from .database import get_db
from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation
from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation, BalanceTransaction
from .auth import hash_password, verify_password
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
@@ -31,22 +31,17 @@ app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), na
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]:
"""Ermittelt den Mandanten anhand der Subdomain (Host-Header)."""
def resolve_tenant(request: Request, db: Session) -> Tenant:
host = request.headers.get("host", "").split(":")[0].lower()
parts = host.split(".")
slug = "demo"
if len(parts) >= 4 and parts[-3:] == ["waiswuaschd", "de", ""]:
slug = parts[0]
elif len(parts) >= 3 and parts[1] == "dev" and parts[2] == "waiswuaschd":
slug = parts[0]
elif parts and parts[0] not in ["localhost", "127", "dev"]:
slug = parts[0]
subdomain = host.split(".")[0]
tenant = db.query(Tenant).filter(Tenant.slug == slug, Tenant.is_active == True).first()
tenant = db.query(Tenant).filter(Tenant.subdomain == host, Tenant.is_active == True).first()
if not tenant:
tenant = db.query(Tenant).filter(Tenant.slug == "demo").first()
tenant = db.query(Tenant).filter(Tenant.slug == subdomain, Tenant.is_active == True).first()
if not tenant:
tenant = db.query(Tenant).filter(Tenant.slug == "demo", Tenant.is_active == True).first()
if not tenant:
tenant = db.query(Tenant).first()
return tenant
@@ -68,6 +63,16 @@ def require_admin(request: Request, db: Session) -> User:
return current_user
def require_organizer_or_admin(request: Request, db: Session) -> User:
"""Stellt sicher, dass ein Organisator oder 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 not in ["admin", "organizer"]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Organisatoren und Administratoren zugänglich")
return current_user
def is_product_locked_in_active_event(product_id: int, tenant_id: int, db: Session) -> bool:
"""Prüft, ob ein Produkt in einer offenen Runde bereits bestellt wurde."""
open_events = db.query(Event).filter(Event.tenant_id == tenant_id, Event.status == "open").all()
@@ -96,6 +101,19 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
)
db.add(demo_user)
orga_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "orga@waiswuaschd.de").first()
if not orga_user:
orga_user = User(
tenant_id=tenant.id,
email="orga@waiswuaschd.de",
username="orga",
first_name="Monika",
last_name="Gruber",
password_hash=hash_password("demo123"),
role="organizer"
)
db.add(orga_user)
admin_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "admin@waiswuaschd.de").first()
if not admin_user:
admin_user = User(
@@ -544,6 +562,8 @@ def buyer_dashboard(
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if current_user.role == "guest":
raise HTTPException(status_code=403, detail="Gäste haben keinen Zugriff auf das Einkäufer-Cockpit")
# Alle Events des Mandanten
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.event_date.desc(), Event.id.desc()).all()
@@ -623,8 +643,14 @@ def admin_dashboard(
current_user = get_current_user(request, db)
if not current_user:
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")
if current_user.role not in ["admin", "organizer"]:
raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren und Organisatoren")
# Tab-Aliase unterstützen
if tab == "events":
tab = "event"
elif tab == "invitations":
tab = "team"
# Alle Events des Mandanten
all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.event_date.desc(), Event.id.desc()).all()
@@ -768,6 +794,32 @@ def admin_delete_user(
return RedirectResponse(url="/admin?tab=team&success=Benutzer gelöscht", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/users/{user_id}/role")
def admin_change_user_role(
user_id: int,
request: Request,
role: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
if admin.id == user_id:
raise HTTPException(status_code=400, detail="Du kannst deine eigene Rolle nicht ändern")
if role not in ["user", "organizer", "admin"]:
raise HTTPException(status_code=400, detail="Ungültige Rolle")
user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
user.role = role
db.commit()
return RedirectResponse(url="/admin?tab=team&success=Rolle erfolgreich geändert", status_code=status.HTTP_303_SEE_OTHER)
# --- ADMIN INVITATIONS ---
@app.post("/admin/invitations/create")
@@ -779,7 +831,11 @@ def admin_create_invitation(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
actor = require_organizer_or_admin(request, db)
# Sicherheit: Organisatoren können keine Admin-Rolle vergeben
if actor.role != "admin" and role == "admin":
role = "user"
token = secrets.token_urlsafe(20)
clean_email = email.strip().lower() if email and email.strip() else None
@@ -797,7 +853,7 @@ def admin_create_invitation(
token=token,
email=clean_email,
role=role,
created_by=admin.id,
created_by=actor.id,
expires_at=expires_at,
is_reusable=is_reusable
)
@@ -816,7 +872,11 @@ def admin_bulk_invitations(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
admin = require_admin(request, db)
actor = require_organizer_or_admin(request, db)
# Sicherheit: Organisatoren können keine Admin-Rolle vergeben
if actor.role != "admin" and role == "admin":
role = "user"
email_pattern = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+'
found_emails = list(set([e.lower().strip() for e in re.findall(email_pattern, emails_text)]))
@@ -839,7 +899,7 @@ def admin_bulk_invitations(
token=token,
email=em,
role=role,
created_by=admin.id,
created_by=actor.id,
expires_at=expires_at,
is_reusable=is_reusable
)
@@ -857,7 +917,7 @@ def admin_revoke_invitation(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
inv = db.query(Invitation).filter(Invitation.id == inv_id, Invitation.tenant_id == tenant.id).first()
if inv:
@@ -883,7 +943,7 @@ def admin_create_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
try:
ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date()
@@ -944,7 +1004,7 @@ def admin_edit_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
@@ -986,7 +1046,7 @@ def admin_complete_event_and_cleanup_guests(
):
"""Schließt das Event ab und bereinigt temporäre Gast-Konten."""
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event:
@@ -1016,7 +1076,7 @@ def admin_delete_event(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if event:
@@ -1039,7 +1099,7 @@ def admin_create_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
try:
clean_price = Decimal(price.strip().replace(",", "."))
@@ -1073,7 +1133,7 @@ def admin_edit_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if not prod:
@@ -1108,7 +1168,7 @@ def admin_toggle_product(
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
require_admin(request, db)
require_organizer_or_admin(request, db)
prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first()
if prod:
@@ -1263,6 +1323,168 @@ def seed_realistic_test_data(
return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success=Realistische Testdaten erfolgreich eingespielt", status_code=status.HTTP_303_SEE_OTHER)
# --- KASSENSYSTEM & GUTHABEN ---
@app.get("/kasse", response_class=HTMLResponse)
def kasse_dashboard(
request: Request,
success: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
# Persönliche Transaktionen des aktuellen Nutzers
user_transactions = db.query(BalanceTransaction).filter(
BalanceTransaction.tenant_id == tenant.id,
BalanceTransaction.user_id == current_user.id
).order_by(BalanceTransaction.created_at.desc()).limit(100).all()
# Für Kassenwarte / Admins / Organisatoren: Teamübersicht & Gesamthistorie
team_members = []
all_transactions = []
total_team_balance = Decimal("0.00")
positive_users_count = 0
if current_user.role in ["admin", "organizer"]:
team_members = db.query(User).filter(
User.tenant_id == tenant.id,
User.role.in_(["user", "organizer", "admin"])
).order_by(User.first_name.asc(), User.last_name.asc()).all()
for tm in team_members:
if tm.balance > 0:
positive_users_count += 1
total_team_balance += tm.balance
all_transactions = db.query(BalanceTransaction).filter(
BalanceTransaction.tenant_id == tenant.id
).order_by(BalanceTransaction.created_at.desc()).limit(200).all()
return templates.TemplateResponse(request, "kasse.html", context={
"tenant": tenant,
"current_user": current_user,
"team_members": team_members,
"user_transactions": user_transactions,
"all_transactions": all_transactions,
"total_team_balance": total_team_balance,
"positive_users_count": positive_users_count,
"success": success,
"error": error
})
@app.post("/kasse/deposit")
def kasse_deposit(
request: Request,
user_id: int = Form(...),
amount: str = Form(...),
description: Optional[str] = Form("Barzahlung"),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
actor = require_organizer_or_admin(request, db)
try:
clean_amount = Decimal(amount.strip().replace(",", "."))
if clean_amount <= 0:
raise ValueError()
except Exception:
return RedirectResponse(url="/kasse?error=Ungültiger Betrag", status_code=status.HTTP_303_SEE_OTHER)
target_user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first()
if not target_user:
return RedirectResponse(url="/kasse?error=Mitarbeiter nicht gefunden", status_code=status.HTTP_303_SEE_OTHER)
# Guthaben aufbuchen
target_user.balance += clean_amount
# Transaktion revisionssicher protokollieren
tx = BalanceTransaction(
tenant_id=tenant.id,
user_id=target_user.id,
amount=clean_amount,
transaction_type="deposit",
description=description.strip() if description and description.strip() else "Einzahlung",
created_by_id=actor.id
)
db.add(tx)
db.commit()
return RedirectResponse(
url=f"/kasse?success={clean_amount:.2f} € erfolgreich für {target_user.first_name} {target_user.last_name} gutgeschrieben",
status_code=status.HTTP_303_SEE_OTHER
)
@app.post("/kasse/charge-order/{order_id}")
def kasse_charge_order(
order_id: int,
request: Request,
method: str = "balance",
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
actor = get_current_user(request, db)
if not actor:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first()
if not order:
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
is_buyer = order.event and order.event.buyer_id == actor.id
if actor.role not in ["admin", "organizer"] and not is_buyer:
raise HTTPException(status_code=403, detail="Keine Berechtigung zur Kassenverwaltung")
ev_id = order.event_id
if method == "balance":
if not order.user:
return RedirectResponse(
url=f"/einkauf?event_id={ev_id}&error=Gäste ohne Konto können nicht per Guthaben abbuchen",
status_code=status.HTTP_303_SEE_OTHER
)
if order.user.balance < order.total_price:
return RedirectResponse(
url=f"/einkauf?event_id={ev_id}&error=Nicht genügend Guthaben vorhanden ({order.user.balance:.2f} €)",
status_code=status.HTTP_303_SEE_OTHER
)
# Guthaben abbuchen
order.user.balance -= order.total_price
order.is_paid = True
order.payment_method = "balance"
event = order.event
ev_title = event.title if event else "Brotzeit"
tx = BalanceTransaction(
tenant_id=tenant.id,
user_id=order.user.id,
order_id=order.id,
amount=-order.total_price,
transaction_type="order_charge",
description=f"Brotzeit: {ev_title}",
created_by_id=actor.id
)
db.add(tx)
db.commit()
else:
# Barzahlung
order.is_paid = True
order.payment_method = "cash"
db.commit()
return RedirectResponse(url=f"/einkauf?event_id={ev_id}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, db)
@@ -1271,7 +1493,26 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.is_paid = not order.is_paid
if order.is_paid:
# Bei Rücknahme: Falls per Guthaben bezahlt, Guthaben wieder gutschreiben (Refund)
if order.payment_method == "balance" and order.user:
order.user.balance += order.total_price
ev_title = order.event.title if order.event else "Brotzeit"
tx = BalanceTransaction(
tenant_id=order.tenant_id,
user_id=order.user.id,
order_id=order.id,
amount=order.total_price,
transaction_type="refund",
description=f"Rückerstattung: {ev_title}",
created_by_id=current_user.id
)
db.add(tx)
order.is_paid = False
order.payment_method = "cash"
else:
order.is_paid = True
order.payment_method = "cash"
db.commit()
ev_id = order.event_id if order else ""
+25 -3
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Numeric, ForeignKey, func
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Numeric, ForeignKey, func, desc
from sqlalchemy.orm import relationship
from .database import Base
@@ -30,12 +30,33 @@ class User(Base):
password_hash = Column(String(255), default="")
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
role = Column(String(20), default="user") # 'admin', 'user', 'guest'
role = Column(String(20), default="user") # 'admin', 'organizer', 'user', 'guest'
balance = Column(Numeric(10, 2), default=0.00, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="users")
orders = relationship("Order", back_populates="user")
transactions = relationship("BalanceTransaction", foreign_keys="BalanceTransaction.user_id", back_populates="user", order_by="desc(BalanceTransaction.created_at)")
class BalanceTransaction(Base):
__tablename__ = "balance_transactions"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
order_id = Column(Integer, ForeignKey("orders.id", ondelete="SET NULL"), nullable=True)
amount = Column(Numeric(10, 2), nullable=False)
transaction_type = Column(String(30), nullable=False) # 'deposit', 'order_charge', 'adjustment', 'refund'
description = Column(String(255), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant")
user = relationship("User", foreign_keys=[user_id], back_populates="transactions")
order = relationship("Order")
created_by = relationship("User", foreign_keys=[created_by_id])
class Invitation(Base):
@@ -45,7 +66,7 @@ class Invitation(Base):
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)
role = Column(String(20), default="user", nullable=False) # 'admin', 'organizer', 'user', 'guest'
created_by = Column(Integer, nullable=True)
expires_at = Column(DateTime, nullable=True)
used_at = Column(DateTime, nullable=True)
@@ -100,6 +121,7 @@ class Order(Base):
guest_display_name = Column(String(150), nullable=True)
total_price = Column(Numeric(10, 2), nullable=False, default=0.00)
is_paid = Column(Boolean, default=False)
payment_method = Column(String(30), default="cash") # 'cash', 'balance', 'sponsored'
created_at = Column(DateTime, default=func.now())
tenant = relationship("Tenant", back_populates="orders")
+493 -157
View File
@@ -85,7 +85,7 @@ body {
max-width: 580px;
}
/* Navbar */
/* ================= TOP NAVBAR ================= */
.navbar {
background: var(--bg-navbar);
backdrop-filter: blur(16px);
@@ -102,27 +102,102 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
height: 62px;
}
.nav-right-cluster {
.nav-left {
display: flex;
align-items: center;
gap: 14px;
}
.menu-toggle-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 7px 13px;
cursor: pointer;
color: var(--text-main);
font-weight: 700;
font-size: 0.88rem;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.menu-toggle-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
transform: translateY(-1px);
}
.menu-toggle-btn .bar {
display: block;
width: 16px;
height: 2px;
background-color: var(--text-main);
border-radius: 2px;
}
.menu-btn-text {
font-size: 0.88rem;
font-weight: 700;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-main);
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
}
.brand span {
color: var(--accent);
}
.tenant-badge {
background: var(--accent-light);
color: var(--accent);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 800;
border: 1px solid var(--border-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-right {
display: flex;
align-items: center;
gap: 10px;
}
@media (min-width: 769px) {
.nav-right-cluster {
order: 3;
margin-left: 14px;
}
.nav-links {
order: 2;
margin-left: auto;
}
.user-balance-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.35);
border-radius: 9999px;
padding: 5px 12px;
text-decoration: none;
color: var(--success);
font-weight: 800;
font-size: 0.85rem;
transition: all 0.15s ease;
}
.user-balance-pill:hover {
transform: scale(1.04);
background: rgba(34, 197, 94, 0.22);
}
/* Theme Toggle Button */
.theme-toggle-btn {
display: inline-flex;
align-items: center;
@@ -130,7 +205,7 @@ body {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 9999px;
padding: 6px 13px;
padding: 6px 12px;
color: var(--text-main);
font-size: 0.82rem;
font-weight: 600;
@@ -145,138 +220,317 @@ body {
transform: translateY(-1px);
}
/* Mobile Toggle Hamburger */
.mobile-toggle {
display: none;
.user-chip-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 9999px;
padding: 4px 10px 4px 5px;
cursor: pointer;
color: var(--text-main);
font-weight: 600;
font-size: 0.85rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
}
.user-chip-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
transform: translateY(-1px);
}
.user-avatar-circle {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.82rem;
}
.btn-login-top {
padding: 6px 14px;
font-size: 0.85rem;
text-decoration: none;
}
/* ================= LEFT-SIDE NAVIGATION DRAWER ================= */
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 998;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
.drawer-backdrop.open {
opacity: 1;
visibility: visible;
}
.nav-drawer {
position: fixed;
top: 0;
left: 0;
width: 320px;
max-width: 85vw;
height: 100vh;
background: var(--bg-card);
border-right: 1px solid var(--border-color);
box-shadow: 10px 0 40px rgba(0, 0, 0, 0.25);
z-index: 999;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
overflow-y: auto;
}
.nav-drawer.open {
transform: translateX(0);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 22px;
border-bottom: 1px solid var(--border-color);
}
.drawer-brand {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-logo {
font-size: 1.8rem;
}
.drawer-tenant {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.drawer-close-btn {
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
padding: 8px;
width: 42px;
height: 42px;
flex-direction: column;
justify-content: center;
color: var(--text-main);
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
gap: 5px;
z-index: 100;
transition: background 0.2s, border-color 0.2s;
justify-content: center;
cursor: pointer;
font-size: 1rem;
transition: background 0.15s ease;
}
.mobile-toggle:hover, .mobile-toggle:focus-visible {
.drawer-close-btn:hover {
background: var(--accent-light);
border-color: var(--accent);
outline: none;
}
.mobile-toggle .bar {
width: 20px;
height: 2px;
background-color: var(--text-main);
border-radius: 2px;
transition: transform 0.25s ease, opacity 0.25s ease;
/* User Card in Drawer */
.drawer-user-card {
padding: 18px 22px;
background: var(--bg-card-subtle);
border-bottom: 1px solid var(--border-color);
}
.mobile-toggle.open .bar:nth-child(1) {
transform: translateY(7px) rotate(45deg);
.user-card-top {
display: flex;
align-items: center;
gap: 12px;
}
.mobile-toggle.open .bar:nth-child(2) {
opacity: 0;
transform: scaleX(0);
}
.mobile-toggle.open .bar:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.brand {
font-size: 1.25rem;
.user-card-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--accent);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
.user-card-name {
font-weight: 700;
font-size: 0.95rem;
color: var(--text-main);
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.brand span {
color: var(--accent);
}
.tenant-badge {
background: var(--accent-light);
color: var(--accent);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--border-color);
text-transform: uppercase;
}
.nav-links {
display: flex;
align-items: center;
gap: 18px;
}
.nav-links a {
.user-card-email {
font-size: 0.78rem;
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--accent);
.user-card-role {
margin-top: 4px;
}
.user-greeting {
color: var(--text-main);
font-size: 0.85rem;
font-weight: 600;
background: var(--accent-light);
.badge-role.organizer {
background: rgba(14, 165, 233, 0.15);
color: #0284c7;
border-color: rgba(14, 165, 233, 0.3);
}
.badge-role.employee {
background: rgba(100, 116, 139, 0.15);
color: var(--text-muted);
}
.drawer-balance-box {
margin-top: 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
padding: 4px 10px;
border-radius: 6px;
border-radius: var(--radius-md);
padding: 10px 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logout {
color: var(--danger) !important;
font-size: 0.85rem !important;
.balance-box-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
}
.nav-links a.nav-admin {
.balance-box-value {
font-size: 1.25rem;
font-weight: 800;
color: var(--success);
}
.balance-box-link {
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
}
/* Drawer Navigation Items */
.drawer-nav {
padding: 16px 14px;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-group-title {
font-size: 0.72rem;
font-weight: 800;
color: var(--text-muted);
padding: 14px 12px 6px;
letter-spacing: 0.6px;
}
.drawer-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
color: var(--text-main);
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.15s ease;
}
.drawer-nav-item:hover {
background: var(--accent-light);
color: var(--accent);
transform: translateX(3px);
}
.nav-item-icon {
font-size: 1.2rem;
width: 24px;
text-align: center;
}
.nav-item-text {
flex: 1;
}
.nav-badge-pill {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 9999px;
background: var(--accent-light);
color: var(--accent);
border: 1px solid var(--border-color);
}
.nav-badge-pill.admin {
background: var(--accent-gold-bg);
color: var(--accent-gold);
padding: 6px 12px;
border-radius: 6px;
font-weight: 700;
border: 1px solid rgba(180, 83, 9, 0.25);
border-color: rgba(180, 83, 9, 0.3);
}
.nav-links a.nav-einkauf-link {
background: var(--accent-light);
color: var(--accent);
padding: 6px 12px;
border-radius: 6px;
font-weight: 700;
.drawer-nav-item.nav-logout {
color: var(--danger);
}
.drawer-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-card-subtle);
}
.drawer-theme-btn {
width: 100%;
padding: 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-main);
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.nav-links a.nav-login {
background: var(--accent);
color: #ffffff !important;
padding: 6px 14px;
border-radius: 6px;
font-weight: 700;
.drawer-theme-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
}
.main-content {
flex: 1;
padding: 36px 0;
padding: 30px 0;
}
/* Multi-Event Banner on Order Page */
@@ -1408,56 +1662,12 @@ body {
/* ================= COMPREHENSIVE RESPONSIVE MOBILE STYLES ================= */
@media (max-width: 768px) {
.mobile-toggle {
display: flex;
.menu-toggle-btn {
padding: 6px 10px;
}
.nav-links {
.menu-btn-text {
display: none;
position: fixed;
top: 64px;
left: 0;
width: 100%;
height: calc(100vh - 64px);
background: rgba(11, 15, 25, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-color);
flex-direction: column;
padding: 24px 20px 40px;
gap: 16px;
overflow-y: auto;
z-index: 90;
align-items: stretch;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8);
}
.nav-links.open {
display: flex;
animation: fadeInNav 0.22s ease forwards;
}
.nav-links a {
font-size: 1.05rem;
padding: 12px 14px;
border-radius: var(--radius-md);
display: block;
text-align: left;
border: 1px solid transparent;
}
.nav-links a:hover {
background: rgba(56, 189, 248, 0.1);
border-color: rgba(56, 189, 248, 0.25);
}
.user-greeting {
padding: 12px 14px;
font-size: 0.95rem;
display: block;
text-align: left;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-md);
}
.container {
@@ -1824,8 +2034,134 @@ body {
box-shadow: var(--shadow-card);
}
[data-theme="light"] .data-table tbody tr:hover {
background: rgba(0, 102, 178, 0.04);
/* ================= KASSENSYSTEM & MITARBEITER-GUTHABEN (/kasse) ================= */
.kasse-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.kasse-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.user-balance-hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 32px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.balance-hero-label {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
}
.balance-hero-amount {
font-size: 3.2rem;
font-weight: 900;
color: var(--accent);
line-height: 1.1;
margin-bottom: 12px;
}
.balance-hero-amount.positive {
color: var(--success);
}
.balance-hero-amount.negative {
color: var(--danger);
}
.balance-hero-hint {
font-size: 0.9rem;
color: var(--text-muted);
max-width: 480px;
}
.info-bubble {
display: flex;
gap: 14px;
background: var(--accent-light);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px;
max-width: 440px;
font-size: 0.85rem;
line-height: 1.45;
}
.bubble-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.kassenwart-section {
background: var(--bg-card-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px;
margin-bottom: 32px;
}
.section-badge-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.quick-amounts {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.quick-amt-btn {
flex: 1;
padding: 6px 10px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-main);
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s ease;
}
.quick-amt-btn:hover {
background: var(--accent-light);
border-color: var(--accent);
color: var(--accent);
}
.kasse-columns {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 24px;
}
@media (max-width: 900px) {
.kasse-columns {
grid-template-columns: 1fr;
}
}
/* ================= PRINT MEDIA QUERY (1-PAGE A4) ================= */
+23 -3
View File
@@ -701,6 +701,7 @@
<tr>
<th>Name & E-Mail</th>
<th>Rolle</th>
<th style="text-align: right;">Guthaben</th>
<th>Aktionen</th>
</tr>
</thead>
@@ -712,20 +713,39 @@
<small class="text-muted">{{ member.email }}</small>
</td>
<td>
{% if member.role == 'admin' %}
<span class="badge-role admin">Admin</span>
{% if current_user.role == 'admin' and member.id != current_user.id %}
<form action="/admin/users/{{ member.id }}/role" method="POST" style="margin: 0;">
<select name="role" onchange="this.form.submit()" style="padding: 4px 8px; font-size: 0.8rem; border-radius: 6px; background: var(--bg-input); color: var(--text-main); border: 1px solid var(--border-color);">
<option value="user" {% if member.role == 'user' %}selected{% endif %}>👤 Mitarbeiter</option>
<option value="organizer" {% if member.role == 'organizer' %}selected{% endif %}>📋 Organisator</option>
<option value="admin" {% if member.role == 'admin' %}selected{% endif %}>👑 Admin</option>
</select>
</form>
{% else %}
<span class="badge-role">Mitarbeiter</span>
{% if member.role == 'admin' %}
<span class="badge-role admin">👑 Admin</span>
{% elif member.role == 'organizer' %}
<span class="badge-role organizer">📋 Organisator</span>
{% else %}
<span class="badge-role">👤 Mitarbeiter</span>
{% endif %}
{% endif %}
</td>
<td style="text-align: right; font-weight: 700;">
<a href="/kasse" style="text-decoration: none; color: {% if member.balance > 0 %}var(--success){% elif member.balance < 0 %}var(--danger){% else %}var(--text-muted){% endif %};">
{{ "%.2f"|format(member.balance) }} €
</a>
</td>
<td>
<div style="display: flex; gap: 6px;">
{% if current_user.role == 'admin' %}
<button type="button" class="btn-copy-small" onclick="openResetPassword('{{ member.id }}', '{{ member.first_name|escape }} {{ member.last_name|escape }}')">🔑 Passwort</button>
{% if member.id != current_user.id %}
<form action="/admin/users/{{ member.id }}/delete" method="POST" style="margin: 0;" onsubmit="return confirm('Möchtest du {{ member.first_name }} {{ member.last_name }} wirklich unwiderruflich löschen?');">
<button type="submit" class="btn-revoke-small" title="Benutzer löschen">🗑️</button>
</form>
{% endif %}
{% endif %}
</div>
</td>
</tr>
+195 -47
View File
@@ -16,52 +16,184 @@
</head>
<body>
<!-- ================= TOP NAVBAR ================= -->
<header class="navbar">
<div class="container nav-content">
<a href="/" class="brand">
🥨 <span>Schmankerl</span> <small class="tenant-badge">{{ tenant.name if tenant else 'Portal' }}</small>
</a>
<!-- LEFT: Menu Toggle + Brand -->
<div class="nav-left">
<button class="menu-toggle-btn" id="drawerToggleBtn" onclick="openDrawer()" aria-label="Menü öffnen" aria-expanded="false">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="menu-btn-text">Menü</span>
</button>
<div class="nav-right-cluster">
<!-- Theme Switcher -->
<a href="/" class="brand">
🥨 <span>Schmankerl</span>
{% if tenant and tenant.name %}
<small class="tenant-badge">{{ tenant.name.replace('Schmankerl ', '') }}</small>
{% endif %}
</a>
</div>
<!-- RIGHT: Balance (if logged in) + Theme Toggle + User Chip -->
<div class="nav-right">
{% if current_user and current_user.role != 'guest' %}
<a href="/kasse" class="user-balance-pill" title="Aktuelles Guthaben – Klick für Kassendetails">
<span class="balance-icon">💰</span>
<span class="balance-amount">{{ "%.2f"|format(current_user.balance) }} €</span>
</a>
{% endif %}
<!-- Theme Toggle -->
<button type="button" class="theme-toggle-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Design umschalten (Hell / Dunkel)">
<span id="themeToggleIcon">🌙</span> <span id="themeToggleLabel" class="hide-mobile">Dunkel</span>
</button>
<!-- Mobile Hamburger Toggle -->
<button class="mobile-toggle" aria-label="Menü öffnen" id="mobileNavToggle" aria-expanded="false">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
{% if current_user %}
<button type="button" class="user-chip-btn" id="userChipBtn" onclick="openDrawer()" title="Profil & Menü öffnen">
<span class="user-avatar-circle">{{ current_user.first_name[0] }}</span>
<span class="user-chip-name hide-mobile">{{ current_user.first_name }}</span>
</button>
{% else %}
<a href="/login" class="btn-primary btn-login-top">Anmelden</a>
{% endif %}
</div>
<nav class="nav-links" id="navLinks">
{% if current_user %}
<a href="/" class="nav-item">🥨 Bestellen</a>
<a href="/einkauf" class="nav-item nav-einkauf-link">🛒 Einkaufsliste & Kasse</a>
{% if current_user.role == 'admin' %}
<a href="/admin" class="nav-item nav-admin">⚙️ Orga & Admin</a>
{% endif %}
<span class="user-greeting">👤 {{ current_user.first_name }}</span>
<a href="/logout" class="nav-item nav-logout">Abmelden</a>
{% else %}
<a href="/login" class="nav-item nav-login">Anmelden</a>
{% endif %}
<a href="https://new.dev.waiswuaschd.de" target="_blank" class="nav-item nav-home">Zur Plattform</a>
</nav>
</div>
</header>
<!-- ================= LEFT NAVIGATION DRAWER ================= -->
<div class="drawer-backdrop" id="drawerBackdrop" onclick="closeDrawer()"></div>
<aside class="nav-drawer" id="navDrawer" aria-hidden="true">
<div class="drawer-header">
<div class="drawer-brand">
<span class="drawer-logo">🥨</span>
<div>
<strong style="font-size: 1.1rem; color: var(--text-main);">Schmankerl</strong>
<div class="drawer-tenant">{{ tenant.name if tenant else 'Brotzeit-Portal' }}</div>
</div>
</div>
<button type="button" class="drawer-close-btn" onclick="closeDrawer()" aria-label="Menü schließen">✕</button>
</div>
<!-- User Profile Card in Drawer -->
{% if current_user %}
<div class="drawer-user-card">
<div class="user-card-top">
<div class="user-card-avatar">{{ current_user.first_name[0] }}{{ current_user.last_name[0] if current_user.last_name else '' }}</div>
<div class="user-card-meta">
<div class="user-card-name">{{ current_user.first_name }} {{ current_user.last_name }}</div>
<div class="user-card-email">{{ current_user.email }}</div>
<div class="user-card-role">
{% if current_user.role == 'admin' %}
<span class="badge-role admin">👑 Administrator</span>
{% elif current_user.role == 'organizer' %}
<span class="badge-role organizer">📋 Organisator</span>
{% elif current_user.role == 'guest' %}
<span class="badge-role guest">🥨 Gast</span>
{% else %}
<span class="badge-role employee">👤 Mitarbeiter</span>
{% endif %}
</div>
</div>
</div>
{% if current_user.role != 'guest' %}
<div class="drawer-balance-box">
<div>
<div class="balance-box-label">Guthaben-Stand</div>
<div class="balance-box-value">{{ "%.2f"|format(current_user.balance) }} €</div>
</div>
<a href="/kasse" class="balance-box-link" onclick="closeDrawer()">Kasse & Verlauf →</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Navigation Items -->
<nav class="drawer-nav">
<div class="nav-group-title">BROTZEIT</div>
<a href="/" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">🥨</span>
<span class="nav-item-text">Brotzeit bestellen</span>
</a>
{% if current_user and (current_user.role in ['admin', 'organizer']) %}
<a href="/einkauf" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">🛒</span>
<span class="nav-item-text">Einkaufsliste & Kasse</span>
<span class="nav-badge-pill">Einkauf</span>
</a>
{% endif %}
{% if current_user and current_user.role != 'guest' %}
<div class="nav-group-title">KASSE & GUTHABEN</div>
<a href="/kasse" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">💰</span>
<span class="nav-item-text">Kassensystem & Guthaben</span>
</a>
{% endif %}
{% if current_user and current_user.role in ['admin', 'organizer'] %}
<div class="nav-group-title">ORGANISATION</div>
<a href="/admin?tab=event" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">📅</span>
<span class="nav-item-text">Termine & Runden</span>
</a>
<a href="/admin?tab=products" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">🥨</span>
<span class="nav-item-text">Sortiment & Preise</span>
</a>
<a href="/admin?tab=team" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">✉️</span>
<span class="nav-item-text">Einladungen & Gast-Links</span>
</a>
{% endif %}
{% if current_user and current_user.role == 'admin' %}
<div class="nav-group-title">SYSTEM-ADMINISTRATION</div>
<a href="/admin?tab=team" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">👥</span>
<span class="nav-item-text">Team- & Rollenverwaltung</span>
<span class="nav-badge-pill admin">Admin</span>
</a>
{% endif %}
<div class="nav-group-title">KONTO</div>
{% if current_user %}
<a href="/logout" class="drawer-nav-item nav-logout" onclick="closeDrawer()">
<span class="nav-item-icon">🚪</span>
<span class="nav-item-text">Abmelden</span>
</a>
{% else %}
<a href="/login" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">🔑</span>
<span class="nav-item-text">Anmelden</span>
</a>
{% endif %}
</nav>
<div class="drawer-footer">
<button type="button" class="drawer-theme-btn" onclick="toggleTheme()">
<span id="drawerThemeIcon">🌙</span> <span id="drawerThemeLabel">Dunkelmodus</span>
</button>
</div>
</aside>
<!-- ================= MAIN CONTENT ================= -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
<!-- ================= FOOTER ================= -->
<footer class="footer">
<div class="container footer-inner">
<p>&copy; 2026 Schmankerl by <a href="https://new.dev.waiswuaschd.de">Waiswuaschd.de</a></p>
<p>&copy; 2026 Schmankerl – Das moderne Brotzeit-Portal</p>
<div class="footer-tenant-info">
Mandant: <strong>{{ tenant.slug if tenant else 'demo' }}</strong> ({{ tenant.subdomain if tenant else '' }})
Mandant: <strong>{{ tenant.name if tenant else 'demo' }}</strong>
</div>
</div>
</footer>
@@ -71,12 +203,18 @@
function updateThemeUI(theme) {
const icon = document.getElementById('themeToggleIcon');
const label = document.getElementById('themeToggleLabel');
const dIcon = document.getElementById('drawerThemeIcon');
const dLabel = document.getElementById('drawerThemeLabel');
if (theme === 'dark') {
if (icon) icon.innerText = '☀️';
if (label) label.innerText = 'Hell';
if (dIcon) dIcon.innerText = '☀️';
if (dLabel) dLabel.innerText = 'Hellmodus umschalten';
} else {
if (icon) icon.innerText = '🌙';
if (label) label.innerText = 'Dunkel';
if (dIcon) dIcon.innerText = '🌙';
if (dLabel) dLabel.innerText = 'Dunkelmodus umschalten';
}
}
@@ -88,30 +226,40 @@
updateThemeUI(next);
}
// Initialize Theme UI on load
// Drawer Open / Close Logic
function openDrawer() {
const drawer = document.getElementById('navDrawer');
const backdrop = document.getElementById('drawerBackdrop');
const btn = document.getElementById('drawerToggleBtn');
if (drawer && backdrop) {
drawer.classList.add('open');
backdrop.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
if (btn) btn.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
}
}
function closeDrawer() {
const drawer = document.getElementById('navDrawer');
const backdrop = document.getElementById('drawerBackdrop');
const btn = document.getElementById('drawerToggleBtn');
if (drawer && backdrop) {
drawer.classList.remove('open');
backdrop.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
if (btn) btn.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDrawer();
});
document.addEventListener('DOMContentLoaded', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
updateThemeUI(current);
// Mobile Nav Drawer Toggle
const navToggle = document.getElementById('mobileNavToggle');
const navLinks = document.getElementById('navLinks');
if (navToggle && navLinks) {
navToggle.addEventListener('click', () => {
const isOpen = navLinks.classList.toggle('open');
navToggle.classList.toggle('open', isOpen);
navToggle.setAttribute('aria-expanded', isOpen);
document.body.style.overflow = isOpen ? 'hidden' : '';
});
navLinks.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
navLinks.classList.remove('open');
navToggle.classList.remove('open');
navToggle.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
});
});
}
});
</script>
</body>
+36 -18
View File
@@ -106,9 +106,6 @@
<button onclick="window.print()" class="btn-secondary">🖨️ Liste drucken (A4)</button>
<a href="/events/{{ event.id }}/calendar.ics" class="btn-secondary" style="text-decoration: none; display: inline-flex; align-items: center;">📅 Outlook (.ics)</a>
{% endif %}
{% if current_user.role == 'admin' %}
<a href="/admin" class="btn-outline" style="text-decoration: none;">⚙️ Zur Verwaltung</a>
{% endif %}
</div>
</div>
@@ -148,13 +145,13 @@
<div class="sponsor-highlight-card">
<div class="sponsor-icon">🎁</div>
<div>
<h3>Diese Runde wird gesponsert!</h3>
<h3>🎉 Diese Runde wird gesponsert!</h3>
<p>Sponsor / Anlass: <strong>{{ event.sponsor_name }}</strong> übernimmt die Gesamtrechnung von <strong>{{ "%.2f"|format(total_revenue) }} €</strong> für das gesamte Team.</p>
</div>
</div>
{% endif %}
<!-- Stats Cards -->
<!-- 4 Quick Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Gesamtsumme Einkauf</div>
@@ -173,20 +170,20 @@
</div>
</div>
<div class="stat-card">
<div class="stat-label">Teilnehmer</div>
<div class="stat-label">Bestellungen</div>
<div class="stat-value">{{ orders|length }} Personen</div>
</div>
</div>
<!-- 2-Column Responsive Layout -->
<div class="admin-columns">
<!-- Grid: Links Sammelliste, Rechts Wer bekommt was -->
<div class="buyer-grid">
<!-- Sammelliste für Metzger & Bäcker -->
<div class="admin-card">
<h2>🛒 Sammelliste für Metzger & Bäcker</h2>
<p class="card-hint">Das musst du für die gesamte Runde besorgen:</p>
<div class="shopping-table-wrapper">
<h2>🥖 Sammelliste für Bäcker & Metzger</h2>
<p class="card-hint">Mengen gebündelt für deinen Einkauf an der Theke:</p>
<div class="orders-table-wrapper">
<table class="data-table">
<thead>
<tr>
@@ -213,7 +210,7 @@
<!-- Einzelübersicht / Strichliste zum Abhaken -->
<div class="admin-card">
<h2>👥 Wer bekommt was? (Kasse & Abhaken)</h2>
<p class="card-hint">Tippe auf den Status, um bezahlt/offen umzuschalten:</p>
<p class="card-hint">Tippe auf den Status zum Abbuchen oder Abhaken:</p>
<div class="orders-table-wrapper">
<table class="data-table">
@@ -222,7 +219,7 @@
<th>Kollege / Gast</th>
<th>Bestellung</th>
<th>Betrag</th>
<th>Status</th>
<th>Status / Kasse</th>
</tr>
</thead>
<tbody>
@@ -234,6 +231,8 @@
<span class="badge-role guest">Gast</span>
{% elif not order.user and order.guest_display_name %}
<span class="badge-role" style="background: rgba(148, 163, 184, 0.15); color: #cbd5e1;">Gast</span>
{% elif order.user %}
<div><small class="text-muted">Guthaben: <strong>{{ "%.2f"|format(order.user.balance) }} €</strong></small></div>
{% endif %}
</td>
<td>
@@ -250,12 +249,31 @@
<td>
{% if event.sponsor_name %}
<span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span>
{% else %}
<form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0;">
<button type="submit" class="badge-btn {% if order.is_paid %}paid{% else %}unpaid{% endif %}">
{% if order.is_paid %}✓ Bezahlt{% else %}⏳ Offen{% endif %}
{% elif order.is_paid %}
<form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0; display: inline;">
<button type="submit" class="badge-btn paid" title="Klick um Bezahlung zurückzusetzen">
{% if order.payment_method == 'balance' %}
✓ Bezahlt (Guthaben)
{% else %}
✓ Bezahlt (Bar)
{% endif %}
</button>
</form>
{% else %}
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
{% if order.user and order.user.balance >= order.total_price %}
<form action="/kasse/charge-order/{{ order.id }}?method=balance" method="POST" style="margin: 0;">
<button type="submit" class="badge-btn paid" style="background: rgba(34, 197, 94, 0.15); color: var(--success);" title="Vom Guthaben des Mitarbeiters abbuchen">
💰 Abbuchen
</button>
</form>
{% endif %}
<form action="/kasse/charge-order/{{ order.id }}?method=cash" method="POST" style="margin: 0;">
<button type="submit" class="badge-btn unpaid" title="Barzahlung annehmen">
💵 Bar bezahlt
</button>
</form>
</div>
{% endif %}
</td>
</tr>
+297
View File
@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}Kassensystem & Guthaben – {{ tenant.name }}{% endblock %}
{% block content %}
<div class="container">
<!-- Header -->
<div class="kasse-header">
<div>
<span class="badge-role" style="margin-bottom: 6px;">💰 Kasse & Finanzen</span>
<h1>Brotzeit-Kasse & Guthaben</h1>
<p class="admin-sub">
Transparente Abrechnung, bargeldloses Bestellen und Übersicht aller Buchungen.
</p>
</div>
{% if current_user.role in ['admin', 'organizer'] %}
<div class="kasse-actions">
<a href="/einkauf" class="btn-secondary" style="text-decoration: none;">🛒 Zur Einkaufsliste</a>
<a href="/admin" class="btn-secondary" style="text-decoration: none;">⚙️ Zum Orga-Cockpit</a>
</div>
{% endif %}
</div>
{% if success %}
<div class="alert-success" style="margin-bottom: 24px;">
✓ {{ success }}
</div>
{% endif %}
{% if error %}
<div class="alert-error" style="margin-bottom: 24px;">
✕ {{ error }}
</div>
{% endif %}
<!-- ================= PERSÖNLICHE GUTHABEN-KARTE ================= -->
<div class="user-balance-hero">
<div class="balance-hero-left">
<div class="balance-hero-label">Dein persönliches Guthaben:</div>
<div class="balance-hero-amount {% if current_user.balance > 0 %}positive{% elif current_user.balance < 0 %}negative{% endif %}">
{{ "%.2f"|format(current_user.balance) }} €
</div>
<p class="balance-hero-hint">
{% if current_user.balance > 0 %}
Du hast ausreichend Guthaben für kommende Brotzeitrunden. Bei der Abholung kannst du einfach per 1-Klick abbuchen lassen!
{% elif current_user.balance == 0 %}
Dein Guthaben ist aktuell ausgeglichen (0,00 €). Du kannst Bar zahlen oder dein Konto beim Kassenwart aufladen.
{% else %}
Dein Konto ist im Minus. Bitte gleiche den Betrag beim Kassenwart oder Organisator aus.
{% endif %}
</p>
</div>
<div class="balance-hero-right">
<div class="info-bubble">
<span class="bubble-icon">💡</span>
<div>
<strong>Wie funktioniert das Guthaben?</strong>
<p>Gib deinem Kassenwart oder Organisator einfach deinen gewünschten Betrag (z. B. 20 € bar oder per PayPal). Dieser wird deinem Konto gutgeschrieben und bei jeder Brotzeit automatisch abgezogen.</p>
</div>
</div>
</div>
</div>
<!-- ================= KASSENWART / ORGA BEREICH ================= -->
{% if current_user.role in ['admin', 'organizer'] %}
<div class="kassenwart-section">
<div class="section-badge-row">
<h2>👑 Kassenwart-Cockpit (Team-Kasse)</h2>
<span class="badge-role admin">Admin & Orga Ansicht</span>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Gesamter Kassenbestand (Team)</div>
<div class="stat-value success">{{ "%.2f"|format(total_team_balance) }} €</div>
</div>
<div class="stat-card">
<div class="stat-label">Mitarbeiter mit Guthaben</div>
<div class="stat-value">{{ positive_users_count }} Personen</div>
</div>
<div class="stat-card">
<div class="stat-label">Gesamtzahl Buchungen</div>
<div class="stat-value">{{ all_transactions|length }} Vorgänge</div>
</div>
</div>
<div class="kasse-columns">
<!-- Formular: Einzahlung buchen -->
<div class="admin-card">
<h2>➕ Guthaben für Mitarbeiter einzahlen</h2>
<p class="card-hint">Verbuche Barzahlungen, PayPal oder Überweisungen direkt auf das Mitarbeiter-Konto:</p>
<form action="/kasse/deposit" method="POST" class="admin-form">
<div class="input-group">
<label for="deposit_user_id">Mitarbeiter auswählen</label>
<select id="deposit_user_id" name="user_id" required>
<option value="">-- Kollege auswählen --</option>
{% for u in team_members %}
<option value="{{ u.id }}">
{{ u.first_name }} {{ u.last_name }} (Aktuell: {{ "%.2f"|format(u.balance) }} €)
</option>
{% endfor %}
</select>
</div>
<div class="input-group">
<label for="deposit_amount">Betrag in Euro (€)</label>
<div class="quick-amounts">
<button type="button" class="quick-amt-btn" onclick="setDepositAmount('10.00')">+10 €</button>
<button type="button" class="quick-amt-btn" onclick="setDepositAmount('20.00')">+20 €</button>
<button type="button" class="quick-amt-btn" onclick="setDepositAmount('30.00')">+30 €</button>
<button type="button" class="quick-amt-btn" onclick="setDepositAmount('50.00')">+50 €</button>
</div>
<input type="number" id="deposit_amount" name="amount" step="0.50" min="0.50" max="500.00" placeholder="20.00" required>
</div>
<div class="input-group">
<label for="deposit_note">Zahlungsart / Bemerkung</label>
<input type="text" id="deposit_note" name="description" placeholder="z. B. Barzahlung an Kasse oder PayPal" value="Barzahlung">
</div>
<button type="submit" class="btn-primary" style="width: 100%; margin-top: 10px;">
💰 Guthaben jetzt gutschreiben
</button>
</form>
</div>
<!-- Tabelle: Mitarbeiter-Salden -->
<div class="admin-card">
<h2>👥 Team-Guthaben im Überblick</h2>
<p class="card-hint">Aktuelle Salden aller registrierten Kollegen:</p>
<div class="orders-table-wrapper" style="max-height: 400px; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>Kollege</th>
<th>Rolle</th>
<th style="text-align: right;">Guthaben</th>
<th style="text-align: center;">Aktion</th>
</tr>
</thead>
<tbody>
{% for u in team_members %}
<tr>
<td>
<strong>{{ u.first_name }} {{ u.last_name }}</strong>
<br><small class="text-muted">{{ u.email }}</small>
</td>
<td>
{% if u.role == 'admin' %}
<span class="badge-role admin">Admin</span>
{% elif u.role == 'organizer' %}
<span class="badge-role organizer">Orga</span>
{% else %}
<span class="badge-role employee">Mitarbeiter</span>
{% endif %}
</td>
<td style="text-align: right; font-weight: 800; font-size: 1rem; {% if u.balance > 0 %}color: var(--success);{% elif u.balance < 0 %}color: var(--danger);{% endif %}">
{{ "%.2f"|format(u.balance) }} €
</td>
<td style="text-align: center;">
<button type="button" class="btn-fill-small" onclick="quickSelectUser({{ u.id }}, '{{ u.first_name }} {{ u.last_name }}')">
+ Aufladen
</button>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="empty-cell">Keine Teammitglieder vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Vollständiges Buchungsjournal (Für Kassenwart) -->
<div class="admin-card" style="margin-top: 28px;">
<h2>📜 Kassenbuch / Transaktions-Journal</h2>
<p class="card-hint">Revisionssichere Historie aller Einzahlungen und Abbuchungen:</p>
<div class="orders-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Datum & Zeit</th>
<th>Mitarbeiter</th>
<th>Typ</th>
<th>Beschreibung / Anlass</th>
<th>Verbucht durch</th>
<th style="text-align: right;">Betrag</th>
</tr>
</thead>
<tbody>
{% for t in all_transactions %}
<tr>
<td>{{ t.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>
<strong>{{ t.user.first_name }} {{ t.user.last_name }}</strong>
</td>
<td>
{% if t.transaction_type == 'deposit' %}
<span class="badge-role" style="background: rgba(34, 197, 94, 0.15); color: var(--success);">+ Einzahlung</span>
{% elif t.transaction_type == 'order_charge' %}
<span class="badge-role" style="background: rgba(56, 189, 248, 0.15); color: var(--accent);">- Brotzeit</span>
{% else %}
<span class="badge-role">{{ t.transaction_type }}</span>
{% endif %}
</td>
<td>{{ t.description or '—' }}</td>
<td>
{% if t.created_by %}
{{ t.created_by.first_name }} {{ t.created_by.last_name }}
{% else %}
System
{% endif %}
</td>
<td style="text-align: right; font-weight: 800; {% if t.amount > 0 %}color: var(--success);{% else %}color: var(--text-main);{% endif %}">
{% if t.amount > 0 %}+{% endif %}{{ "%.2f"|format(t.amount) }} €
</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty-cell">Noch keine Buchungen im Journal vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- ================= PERSÖNLICHE BUCHUNGSHISTORIE ================= -->
<div class="admin-card" style="margin-top: 28px;">
<h2>📑 Deine persönlichen Buchungen</h2>
<p class="card-hint">Hier siehst du transparent alle Aufladungen und Abbuchungen deines Kontos:</p>
<div class="orders-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Datum</th>
<th>Vorgang</th>
<th>Notiz / Anlass</th>
<th style="text-align: right;">Betrag</th>
</tr>
</thead>
<tbody>
{% for t in user_transactions %}
<tr>
<td>{{ t.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>
{% if t.transaction_type == 'deposit' %}
<span class="badge-role" style="background: rgba(34, 197, 94, 0.15); color: var(--success);">+ Einzahlung</span>
{% elif t.transaction_type == 'order_charge' %}
<span class="badge-role" style="background: rgba(56, 189, 248, 0.15); color: var(--accent);">- Brotzeit</span>
{% else %}
<span class="badge-role">{{ t.transaction_type }}</span>
{% endif %}
</td>
<td>{{ t.description or 'Brotzeit-Abrechnung' }}</td>
<td style="text-align: right; font-weight: 800; {% if t.amount > 0 %}color: var(--success);{% else %}color: var(--text-main);{% endif %}">
{% if t.amount > 0 %}+{% endif %}{{ "%.2f"|format(t.amount) }} €
</td>
</tr>
{% else %}
<tr><td colspan="4" class="empty-cell">Bisher sind noch keine Buchungen für dein Konto vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
function setDepositAmount(val) {
document.getElementById('deposit_amount').value = val;
}
function quickSelectUser(userId, name) {
const select = document.getElementById('deposit_user_id');
if (select) {
select.value = userId;
select.focus();
document.getElementById('deposit_amount').focus();
}
}
</script>
{% endblock %}
+11
View File
@@ -51,6 +51,17 @@
</button>
</div>
<div class="demo-cred-item">
<div>
<span class="badge-role organizer">📋 Organisator</span>
<code>orga@waiswuaschd.de</code>
<span class="text-muted" style="font-size: 0.8rem;">(PW: <code>demo123</code>)</span>
</div>
<button type="button" class="btn-fill-small" onclick="fillCredentials('orga@waiswuaschd.de', 'demo123')">
✍️ Ausfüllen
</button>
</div>
<div class="demo-cred-item">
<div>
<span class="badge-role">👤 Mitarbeiter</span>