diff --git a/app/main.py b/app/main.py index 5921fd6..cdb593c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import os -from datetime import datetime +import secrets +from datetime import datetime, timedelta, date from typing import Optional, Dict from decimal import Decimal @@ -11,7 +12,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 +from .models import Tenant, User, Product, Event, Order, OrderItem, Invitation from .auth import hash_password, verify_password app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal") @@ -56,6 +57,16 @@ def get_current_user(request: Request, db: Session) -> Optional[User]: return db.query(User).filter(User.id == user_id, User.is_active == True).first() +def require_admin(request: Request, db: Session) -> User: + """Stellt sicher, dass ein Administrator eingeloggt ist.""" + current_user = get_current_user(request, db) + if not current_user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Anmeldung erforderlich") + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nur für Administratoren zugänglich") + return current_user + + def seed_demo_users_if_needed(tenant: Tenant, db: Session): """Stellt sicher, dass die Standard-Accounts für die Demo existieren.""" demo_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "maxi@waiswuaschd.de").first() @@ -94,15 +105,28 @@ def index(request: Request, db: Session = Depends(get_db)): current_user = get_current_user(request, db) if not current_user: - # Require Login: Weiterleitung zur Anmeldeseite return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + now = datetime.now() + # Aktuelle offene Runde suchen event = db.query(Event).filter( Event.tenant_id == tenant.id, Event.status == "open" ).order_by(Event.deadline.asc()).first() + # Wenn keine offene, das neueste anzeigen + if not event: + event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first() + + # Fristprüfung & Countdown + is_closed = True + seconds_remaining = 0 + if event: + is_closed = (event.status != "open") or (event.deadline < now) + if not is_closed: + seconds_remaining = max(0, int((event.deadline - now).total_seconds())) + # Alle aktiven Produkte des Mandanten products = db.query(Product).filter( Product.tenant_id == tenant.id, @@ -131,11 +155,13 @@ def index(request: Request, db: Session = Depends(get_db)): "tenant": tenant, "current_user": current_user, "event": event, + "is_closed": is_closed, + "seconds_remaining": seconds_remaining, "products": products, "existing_order": existing_order, "user_items": user_items, "order_count": order_count, - "now": datetime.now() + "now": now }) @@ -203,9 +229,46 @@ def quick_demo_login( return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) -@app.post("/register") -def register( +@app.get("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + +# --- EINLADUNGS- & BEITRITTS-FLOW (/join) --- + +@app.get("/join", response_class=HTMLResponse) +def join_page(request: Request, token: str, db: Session = Depends(get_db)): + tenant = resolve_tenant(request, db) + if not tenant: + raise HTTPException(status_code=404, detail="Mandant nicht gefunden") + + invitation = db.query(Invitation).filter( + Invitation.token == token, + Invitation.tenant_id == tenant.id + ).first() + + error = None + if not invitation: + error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team." + elif invitation.used_at is not None: + error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an." + elif invitation.expires_at < datetime.now(): + error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link." + + return templates.TemplateResponse(request, "join.html", context={ + "tenant": tenant, + "invitation": invitation, + "token": token, + "error": error, + "current_user": None + }) + + +@app.post("/join") +def process_join( request: Request, + token: str = Form(...), first_name: str = Form(...), last_name: str = Form(...), email: str = Form(...), @@ -213,16 +276,30 @@ def register( db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) - clean_email = email.strip().lower() + invitation = db.query(Invitation).filter( + Invitation.token == token, + Invitation.tenant_id == tenant.id + ).first() - existing = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first() - if existing: - return templates.TemplateResponse(request, "login.html", context={ + if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now(): + raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink") + + clean_email = email.strip().lower() + existing_user = db.query(User).filter( + User.tenant_id == tenant.id, + User.email == clean_email + ).first() + + if existing_user: + return templates.TemplateResponse(request, "join.html", context={ "tenant": tenant, - "error": "Diese E-Mail-Adresse ist bereits registriert. Bitte melde dich an.", + "invitation": invitation, + "token": token, + "error": "Diese E-Mail-Adresse ist bereits im Team registriert. Bitte melde dich an.", "current_user": None }, status_code=400) + # Neuen Benutzer anlegen username = clean_email.split("@")[0] new_user = User( tenant_id=tenant.id, @@ -231,20 +308,21 @@ def register( first_name=first_name.strip(), last_name=last_name.strip(), password_hash=hash_password(password), - role="user" + role=invitation.role ) db.add(new_user) - db.commit() + # Einladung als verwendet markieren + invitation.used_at = datetime.now() + db.commit() + db.refresh(new_user) + + # Direkt einloggen request.session["user_id"] = new_user.id return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) -@app.get("/logout") -def logout(request: Request): - request.session.clear() - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - +# --- BESTELLUNGEN (/order) --- @app.post("/order") async def place_order( @@ -259,12 +337,11 @@ async def place_order( event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() if not event or event.status != "open": - raise HTTPException(status_code=400, detail="Diese Bestellrunde ist bereits geschlossen") + raise HTTPException(status_code=400, detail="Diese Bestellrunde ist geschlossen.") if event.deadline < datetime.now(): - raise HTTPException(status_code=400, detail="Die Bestellfrist ist leider abgelaufen!") + raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!") - # Formulardaten auslesen form_data = await request.form() order_items = [] total_price = Decimal("0.00") @@ -287,7 +364,7 @@ async def place_order( )) if not order_items: - raise HTTPException(status_code=400, detail="Bitte wähle mindestens ein Produkt aus!") + raise HTTPException(status_code=400, detail="Bitte wähle mindestens einen Artikel aus!") # Bestehende Bestellung des Users in diesem Event aktualisieren oder neu anlegen existing_order = db.query(Order).filter( @@ -330,7 +407,7 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db) if not current_user: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - order = db.query(Order).filter(Order.id == order_id).first() + order = db.query(Order).filter(Order.id == order_id, Order.tenant_id == tenant.id).first() if not order: raise HTTPException(status_code=404, detail="Bestellung nicht gefunden") @@ -341,8 +418,15 @@ def order_success(order_id: int, request: Request, db: Session = Depends(get_db) }) +# --- ADMIN COCKPIT (/admin) --- + @app.get("/admin", response_class=HTMLResponse) -def admin_dashboard(request: Request, db: Session = Depends(get_db)): +def admin_dashboard( + request: Request, + event_id: Optional[int] = None, + tab: str = "orders", + db: Session = Depends(get_db) +): tenant = resolve_tenant(request, db) if not tenant: return templates.TemplateResponse(request, "not_found_tenant.html", context={}) @@ -350,17 +434,26 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)): current_user = get_current_user(request, db) 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") - # Neuestes Event finden - event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first() + # Alle Events des Mandanten + all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).all() + + # Ausgewähltes Event bestimmen + selected_event = None + if event_id: + selected_event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not selected_event and all_events: + selected_event = all_events[0] orders = [] item_sums = {} total_revenue = Decimal("0.00") total_paid = Decimal("0.00") - if event: - orders = db.query(Order).filter(Order.event_id == event.id).order_by(Order.created_at.asc()).all() + if selected_event: + orders = db.query(Order).filter(Order.event_id == selected_event.id).order_by(Order.created_at.asc()).all() for ord_obj in orders: total_revenue += ord_obj.total_price if ord_obj.is_paid: @@ -373,17 +466,162 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)): item_sums[pname] = {"quantity": 0, "unit": unit} item_sums[pname]["quantity"] += it.quantity + # Teammitglieder & Einladungen laden + team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all() + pending_invitations = db.query(Invitation).filter( + Invitation.tenant_id == tenant.id, + Invitation.used_at == None, + Invitation.expires_at > datetime.now() + ).order_by(Invitation.created_at.desc()).all() + return templates.TemplateResponse(request, "admin.html", context={ "tenant": tenant, "current_user": current_user, - "event": event, + "all_events": all_events, + "event": selected_event, "orders": orders, "item_sums": item_sums, "total_revenue": total_revenue, - "total_paid": total_paid + "total_paid": total_paid, + "team_members": team_members, + "pending_invitations": pending_invitations, + "active_tab": tab, + "now": datetime.now() }) +@app.post("/admin/events/new") +def admin_create_event( + request: Request, + title: str = Form(...), + event_date: str = Form(...), + deadline_date: str = Form(...), + deadline_time: str = Form(...), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + try: + ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() + dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" + deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + + new_event = Event( + tenant_id=tenant.id, + title=title.strip(), + event_date=ev_date, + deadline=deadline_dt, + status="open" + ) + db.add(new_event) + db.commit() + db.refresh(new_event) + + return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/events/{event_id}/edit") +def admin_edit_event( + event_id: int, + request: Request, + title: str = Form(...), + event_date: str = Form(...), + deadline_date: str = Form(...), + deadline_time: str = Form(...), + status_val: str = Form(...), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not event: + raise HTTPException(status_code=404, detail="Event nicht gefunden") + + try: + ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() + dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" + deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + + event.title = title.strip() + event.event_date = ev_date + event.deadline = deadline_dt + event.status = status_val + db.commit() + + return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/events/{event_id}/toggle-status") +def admin_toggle_event_status( + event_id: int, + request: Request, + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not event: + raise HTTPException(status_code=404, detail="Event nicht gefunden") + + event.status = "closed" if event.status == "open" else "open" + db.commit() + + return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/invitations/create") +def admin_create_invitation( + request: Request, + email: Optional[str] = Form(None), + role: str = Form("user"), + valid_days: int = Form(14), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + admin = require_admin(request, db) + + token = secrets.token_urlsafe(20) + expires_at = datetime.now() + timedelta(days=valid_days) + clean_email = email.strip().lower() if email and email.strip() else None + + inv = Invitation( + tenant_id=tenant.id, + token=token, + email=clean_email, + role=role, + created_by=admin.id, + expires_at=expires_at + ) + db.add(inv) + db.commit() + + return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/invitations/{inv_id}/revoke") +def admin_revoke_invitation( + inv_id: int, + request: Request, + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + inv = db.query(Invitation).filter(Invitation.id == inv_id, Invitation.tenant_id == tenant.id).first() + if inv: + db.delete(inv) + db.commit() + + return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/admin/toggle-paid/{order_id}") def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)): current_user = get_current_user(request, db) diff --git a/app/models.py b/app/models.py index 031e167..1b33e58 100644 --- a/app/models.py +++ b/app/models.py @@ -17,6 +17,7 @@ class Tenant(Base): events = relationship("Event", back_populates="tenant", cascade="all, delete-orphan") orders = relationship("Order", back_populates="tenant", cascade="all, delete-orphan") users = relationship("User", back_populates="tenant", cascade="all, delete-orphan") + invitations = relationship("Invitation", back_populates="tenant", cascade="all, delete-orphan") class User(Base): @@ -29,7 +30,7 @@ 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") + role = Column(String(20), default="user") # 'admin', 'user' is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) @@ -37,6 +38,22 @@ class User(Base): orders = relationship("Order", back_populates="user") +class Invitation(Base): + __tablename__ = "invitations" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) + token = Column(String(64), unique=True, index=True, nullable=False) + email = Column(String(191), nullable=True) + role = Column(String(20), default="user", nullable=False) + created_by = Column(Integer, nullable=True) + expires_at = Column(DateTime, nullable=False) + used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + + tenant = relationship("Tenant", back_populates="invitations") + + class Product(Base): __tablename__ = "products" diff --git a/app/static/style.css b/app/static/style.css index f8152d5..69ed2a8 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -120,88 +120,166 @@ body { background: rgba(251, 191, 36, 0.12); color: var(--accent-gold); padding: 6px 12px; - border-radius: var(--radius-md); - border: 1px solid rgba(251, 191, 36, 0.25); + border-radius: 6px; font-weight: 600; + border: 1px solid rgba(251, 191, 36, 0.25); } -.nav-links a.nav-admin:hover { - background: rgba(251, 191, 36, 0.2); +.nav-links a.nav-login { + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + padding: 6px 14px; + border-radius: 6px; + font-weight: 600; + border: 1px solid rgba(56, 189, 248, 0.25); } -/* Main */ .main-content { - padding: 36px 0 60px; flex: 1; + padding: 36px 0; } /* Banner */ .banner { - background: linear-gradient(135deg, rgba(26, 38, 64, 0.9) 0%, rgba(19, 27, 46, 0.9) 100%); + background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.9) 100%); border: 1px solid var(--border-color); border-radius: var(--radius-lg); - padding: 28px; - margin-bottom: 32px; - backdrop-filter: blur(12px); + padding: 30px; + margin-bottom: 28px; + position: relative; + overflow: hidden; +} + +.banner.banner-closed { + background: linear-gradient(135deg, rgba(69, 26, 26, 0.6) 0%, rgba(15, 23, 42, 0.9) 100%); + border-color: rgba(239, 68, 68, 0.3); +} + +.banner-top-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 12px; } .banner-badge { display: inline-block; + background: rgba(34, 197, 94, 0.15); + color: #86efac; + padding: 5px 12px; + border-radius: 9999px; font-size: 0.8rem; + font-weight: 600; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.banner-badge.closed { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.4); +} + +/* Countdown */ +.countdown-container { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(11, 15, 25, 0.7); + border: 1px solid rgba(56, 189, 248, 0.3); + padding: 6px 14px; + border-radius: 9999px; + font-size: 0.85rem; font-weight: 700; - color: var(--success); - margin-bottom: 8px; +} + +.countdown-container.urgent { + border-color: var(--accent-gold); + background: rgba(245, 158, 11, 0.15); + color: var(--accent-gold); + animation: pulse-border 1.5s infinite; +} + +.countdown-container.expired { + border-color: var(--danger); + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +@keyframes pulse-border { + 0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); } + 70% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); } + 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); } +} + +.closed-notice-bar { + background: rgba(239, 68, 68, 0.15); + border: 1px solid var(--danger); + color: #fca5a5; + padding: 14px 18px; + border-radius: var(--radius-md); + margin-bottom: 24px; + font-size: 0.9rem; } .banner h1 { - font-size: 1.8rem; + font-size: 1.85rem; margin-bottom: 8px; } .banner-sub { color: var(--text-muted); - font-size: 1rem; + font-size: 0.95rem; margin-bottom: 12px; } .banner-stats { - font-size: 0.9rem; - color: var(--accent); + color: var(--text-muted); + font-size: 0.85rem; } /* Form Grid */ .form-grid { display: grid; - grid-template-columns: 1.4fr 1fr; - gap: 32px; + grid-template-columns: 1.6fr 1fr; + gap: 28px; } -@media (max-width: 800px) { +@media (max-width: 850px) { .form-grid { grid-template-columns: 1fr; } } .products-section h2, .summary-section h2 { - font-size: 1.3rem; - margin-bottom: 20px; + font-size: 1.25rem; + margin-bottom: 16px; } .product-list { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; } .product-card { background: var(--bg-card); border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 20px; + border-radius: var(--radius-md); + padding: 18px; display: flex; justify-content: space-between; align-items: center; - backdrop-filter: blur(12px); + transition: border-color 0.2s; +} + +.product-card.disabled { + opacity: 0.7; +} + +.product-card:hover:not(.disabled) { + border-color: rgba(56, 189, 248, 0.4); } .product-info h3 { @@ -212,62 +290,71 @@ body { .product-desc { color: var(--text-muted); font-size: 0.85rem; - margin-bottom: 8px; + margin-bottom: 6px; } .product-price { - font-size: 1.1rem; font-weight: 700; color: var(--accent); } .product-price small { color: var(--text-muted); - font-weight: normal; - font-size: 0.8rem; + font-weight: 400; } /* Quantity Control */ .quantity-control { display: flex; align-items: center; + gap: 8px; background: rgba(11, 15, 25, 0.8); border: 1px solid var(--border-color); border-radius: var(--radius-md); - overflow: hidden; + padding: 4px; } .qty-btn { - background: transparent; - border: none; + width: 34px; + height: 34px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color); color: var(--text-main); - width: 38px; - height: 38px; + border-radius: 6px; font-size: 1.2rem; - font-weight: 700; cursor: pointer; - transition: background 0.15s; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; } -.qty-btn:hover { - background: rgba(255, 255, 255, 0.1); +.qty-btn:hover:not(:disabled) { + background: var(--accent); + color: #0b0f19; +} + +.qty-btn:disabled { + opacity: 0.4; + cursor: not-allowed; } .qty-input { - width: 45px; + width: 44px; background: transparent; border: none; color: var(--text-main); - text-align: center; - font-size: 1rem; + font-size: 1.1rem; font-weight: 700; - -moz-appearance: textfield; + text-align: center; } -.qty-input::-webkit-outer-spin-button, -.qty-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; +.qty-input:disabled { + color: var(--text-muted); +} + +.qty-input:focus { + outline: none; } /* Summary Card */ @@ -275,29 +362,16 @@ body { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); - padding: 28px; + padding: 24px; position: sticky; top: 90px; - backdrop-filter: blur(12px); } .logged-in-badge { display: flex; align-items: center; gap: 12px; - background: rgba(11, 15, 25, 0.8); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 12px 16px; - margin-bottom: 16px; -} - -.user-avatar { - font-size: 1.5rem; -} - -.notice-box { - background: rgba(56, 189, 248, 0.1); + background: rgba(11, 15, 25, 0.6); border: 1px solid rgba(56, 189, 248, 0.25); border-radius: var(--radius-md); padding: 12px 14px; @@ -318,7 +392,7 @@ body { color: var(--text-muted); } -.input-group input { +.input-group input, .input-group select { width: 100%; padding: 10px 14px; background: rgba(11, 15, 25, 0.8); @@ -328,11 +402,23 @@ body { font-size: 0.95rem; } -.input-group input:focus { +.input-group input:focus, .input-group select:focus { outline: none; border-color: var(--accent); } +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.readonly-input { + background: rgba(255, 255, 255, 0.05) !important; + color: var(--text-muted) !important; + cursor: not-allowed; +} + .divider { border: none; border-top: 1px solid var(--border-color); @@ -370,17 +456,29 @@ body { transition: all 0.2s; } -.btn-submit:hover { +.btn-submit:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 0 20px rgba(56, 189, 248, 0.4); } +.btn-submit:disabled { + background: #334155; + color: #94a3b8; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + .form-hint { display: block; text-align: center; color: var(--text-muted); font-size: 0.75rem; - margin-top: 12px; + margin-top: 8px; +} + +.text-warning { + color: var(--warning) !important; } /* Login Page */ @@ -428,7 +526,7 @@ body { border: 1px solid rgba(251, 191, 36, 0.3); border-radius: var(--radius-md); padding: 18px; - margin-bottom: 28px; + margin-bottom: 24px; } .demo-login-card h3 { @@ -476,31 +574,23 @@ body { font-size: 0.75rem; } -.auth-tabs { +.invite-notice-card { display: flex; - border-bottom: 1px solid var(--border-color); - margin-bottom: 20px; + align-items: flex-start; + gap: 12px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + margin-top: 24px; + font-size: 0.85rem; } -.auth-tab { - flex: 1; - background: transparent; - border: none; - color: var(--text-muted); - font-size: 0.95rem; - font-weight: 600; - padding: 12px; - cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.2s; +.invite-icon { + font-size: 1.3rem; } -.auth-tab.active { - color: var(--text-main); - border-bottom-color: var(--accent); -} - -.alert-danger { +.alert-error { background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #fca5a5; @@ -520,78 +610,73 @@ body { margin-bottom: 18px; } -/* Success Card */ -.success-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 40px; - text-align: center; - backdrop-filter: blur(12px); -} - -.success-icon { - font-size: 3rem; - margin-bottom: 16px; -} - -.success-sub { - color: var(--text-muted); - font-size: 1.1rem; - margin-bottom: 24px; -} - -.order-receipt { - background: rgba(11, 15, 25, 0.8); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 24px; - margin: 24px 0; - text-align: left; -} - -.receipt-list { - list-style: none; - margin: 16px 0; -} - -.receipt-list li { - display: flex; - justify-content: space-between; - padding: 8px 0; - border-bottom: 1px solid var(--border-color); - font-size: 0.95rem; -} - -.receipt-total { - display: flex; - justify-content: space-between; - font-size: 1.2rem; - font-weight: 700; - padding-top: 12px; - color: var(--accent-gold); -} - -.success-actions { - display: flex; - gap: 16px; - justify-content: center; -} - -/* Admin Dashboard */ +/* Admin Dashboard Tabs */ .admin-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 28px; + margin-bottom: 20px; flex-wrap: wrap; gap: 16px; } +.admin-actions-top { + display: flex; + gap: 10px; +} + .admin-sub { color: var(--text-muted); } +.admin-nav-tabs { + display: flex; + gap: 8px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 24px; + overflow-x: auto; +} + +.admin-tab-btn { + padding: 12px 18px; + color: var(--text-muted); + text-decoration: none; + font-size: 0.95rem; + font-weight: 600; + border-bottom: 2px solid transparent; + transition: all 0.2s; + white-space: nowrap; +} + +.admin-tab-btn:hover { + color: var(--text-main); +} + +.admin-tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.event-selector-bar { + display: flex; + align-items: center; + gap: 12px; + background: rgba(11, 15, 25, 0.6); + border: 1px solid var(--border-color); + padding: 10px 16px; + border-radius: var(--radius-md); + margin-bottom: 20px; +} + +.event-selector-bar select { + background: rgba(11, 15, 25, 0.9); + border: 1px solid var(--border-color); + color: var(--text-main); + padding: 6px 12px; + border-radius: 6px; + font-size: 0.9rem; +} + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -705,10 +790,75 @@ body { border: 1px solid rgba(245, 158, 11, 0.4); } +.badge-role { + display: inline-block; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 9999px; + background: rgba(56, 189, 248, 0.15); + color: var(--accent); + font-weight: 600; +} + +.badge-role.admin { + background: rgba(251, 191, 36, 0.15); + color: var(--accent-gold); +} + +/* Invitations List */ +.invitations-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 12px; +} + +.invite-item { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(11, 15, 25, 0.6); + border: 1px solid var(--border-color); + padding: 10px 14px; + border-radius: var(--radius-md); + gap: 12px; +} + +.invite-info { + font-size: 0.85rem; +} + +.invite-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.btn-copy-small { + padding: 4px 10px; + background: rgba(56, 189, 248, 0.15); + border: 1px solid rgba(56, 189, 248, 0.3); + color: var(--accent); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; +} + +.btn-revoke-small { + padding: 4px 8px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + border-radius: 6px; + font-size: 0.75rem; + cursor: pointer; +} + /* Buttons */ .btn-primary { display: inline-block; - padding: 10px 20px; + padding: 10px 18px; background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%); color: #0b0f19; text-decoration: none; @@ -718,15 +868,20 @@ body { border: none; } -.btn-outline { +.btn-secondary { display: inline-block; - padding: 10px 20px; - background: rgba(255, 255, 255, 0.05); + padding: 10px 18px; + background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-main); text-decoration: none; border-radius: var(--radius-md); font-weight: 600; + cursor: pointer; +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.15); } /* Footer */ @@ -760,3 +915,65 @@ body { font-size: 3rem; margin-bottom: 16px; } + +/* --- PRINT MEDIA QUERY --- */ +.print-only { + display: none; +} + +@media print { + body { + background: #ffffff !important; + color: #000000 !important; + font-size: 11pt; + } + + .navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn { + display: none !important; + } + + .main-content { + padding: 0 !important; + } + + .print-only { + display: block !important; + } + + .print-header { + border-bottom: 2px solid #000; + padding-bottom: 10px; + margin-bottom: 20px; + } + + .print-header h1 { + font-size: 18pt; + margin-bottom: 6px; + } + + .print-section h3 { + font-size: 13pt; + margin-bottom: 8px; + border-bottom: 1px solid #ccc; + padding-bottom: 4px; + } + + .print-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; + } + + .print-table th, .print-table td { + border: 1px solid #ddd; + padding: 6px 10px; + text-align: left; + font-size: 10pt; + color: #000; + } + + .print-table th { + background-color: #f2f2f2 !important; + font-weight: bold; + } +} diff --git a/app/templates/admin.html b/app/templates/admin.html index 1191d1e..591e651 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -3,121 +3,378 @@ {% block content %}
-
-
-

👨‍🍳 Einkäufer-Cockpit

-

Mandant: {{ tenant.name }} | Aktuelle Brotzeitrunde

+ + @@ -89,7 +113,7 @@

Aktuell keine offene Bestellrunde

Für dieses Team ist im Moment keine Brotzeit geplant.

{% if current_user.role == 'admin' %} - Neue Bestellrunde im Cockpit anlegen + Neue Bestellrunde im Cockpit anlegen {% endif %}
{% endif %} @@ -99,6 +123,7 @@ {% endblock %} diff --git a/app/templates/join.html b/app/templates/join.html new file mode 100644 index 0000000..a4a2c11 --- /dev/null +++ b/app/templates/join.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + +
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index b7e9078..35a7c16 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,16 +1,14 @@ {% extends "base.html" %} -{% block title %}Anmelden – Schmankerl{% endblock %} - {% block content %}