feat: 4-tier role concept, personal balance & cash register system, left off-canvas drawer navigation
This commit is contained in:
+270
-29
@@ -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
@@ -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
@@ -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) ================= */
|
||||
|
||||
@@ -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
@@ -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>© 2026 Schmankerl by <a href="https://new.dev.waiswuaschd.de">Waiswuaschd.de</a></p>
|
||||
<p>© 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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user