diff --git a/app/main.py b/app/main.py index bef47a7..63261cf 100644 --- a/app/main.py +++ b/app/main.py @@ -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 "" diff --git a/app/models.py b/app/models.py index 277f3b9..771a5d0 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/static/style.css b/app/static/style.css index 586d473..db008f6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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) ================= */ diff --git a/app/templates/admin.html b/app/templates/admin.html index d1e9d1f..6062547 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -701,6 +701,7 @@
Sponsor / Anlass: {{ event.sponsor_name }} übernimmt die Gesamtrechnung von {{ "%.2f"|format(total_revenue) }} € für das gesamte Team.
Das musst du für die gesamte Runde besorgen:
- -Mengen gebündelt für deinen Einkauf an der Theke:
+ +| Kollege / Gast | Bestellung | Betrag | -Status | +Status / Kasse | @@ -234,6 +231,8 @@ Gast {% elif not order.user and order.guest_display_name %} Gast + {% elif order.user %} +@@ -250,12 +249,31 @@ | {% if event.sponsor_name %} ✓ Gesponsert - {% else %} - | diff --git a/app/templates/kasse.html b/app/templates/kasse.html new file mode 100644 index 0000000..57a24dd --- /dev/null +++ b/app/templates/kasse.html @@ -0,0 +1,297 @@ +{% extends "base.html" %} + +{% block title %}Kassensystem & Guthaben – {{ tenant.name }}{% endblock %} + +{% block content %} +
|---|---|---|---|---|---|
| Kollege | +Rolle | +Guthaben | +Aktion | +
|---|---|---|---|
|
+ {{ u.first_name }} {{ u.last_name }}
+ {{ u.email }} + |
+ + {% if u.role == 'admin' %} + Admin + {% elif u.role == 'organizer' %} + Orga + {% else %} + Mitarbeiter + {% endif %} + | ++ {{ "%.2f"|format(u.balance) }} € + | +
+ |
+
| Keine Teammitglieder vorhanden. | |||
Revisionssichere Historie aller Einzahlungen und Abbuchungen:
+ +| Datum & Zeit | +Mitarbeiter | +Typ | +Beschreibung / Anlass | +Verbucht durch | +Betrag | +
|---|---|---|---|---|---|
| {{ t.created_at.strftime('%d.%m.%Y %H:%M') }} | ++ {{ t.user.first_name }} {{ t.user.last_name }} + | ++ {% if t.transaction_type == 'deposit' %} + + Einzahlung + {% elif t.transaction_type == 'order_charge' %} + - Brotzeit + {% else %} + {{ t.transaction_type }} + {% endif %} + | +{{ t.description or '—' }} | ++ {% if t.created_by %} + {{ t.created_by.first_name }} {{ t.created_by.last_name }} + {% else %} + System + {% endif %} + | ++ {% if t.amount > 0 %}+{% endif %}{{ "%.2f"|format(t.amount) }} € + | +
| Noch keine Buchungen im Journal vorhanden. | |||||
Hier siehst du transparent alle Aufladungen und Abbuchungen deines Kontos:
+ +| Datum | +Vorgang | +Notiz / Anlass | +Betrag | +
|---|---|---|---|
| {{ t.created_at.strftime('%d.%m.%Y %H:%M') }} | ++ {% if t.transaction_type == 'deposit' %} + + Einzahlung + {% elif t.transaction_type == 'order_charge' %} + - Brotzeit + {% else %} + {{ t.transaction_type }} + {% endif %} + | +{{ t.description or 'Brotzeit-Abrechnung' }} | ++ {% if t.amount > 0 %}+{% endif %}{{ "%.2f"|format(t.amount) }} € + | +
| Bisher sind noch keine Buchungen für dein Konto vorhanden. | |||
orga@waiswuaschd.de
+ (PW: demo123)
+