diff --git a/app/main.py b/app/main.py index 30b334f..bef47a7 100644 --- a/app/main.py +++ b/app/main.py @@ -527,6 +527,82 @@ def download_calendar_ics(event_id: int, request: Request, db: Session = Depends ) +# --- EINKAUFS- & KASSEN-ZENTRALE (/einkauf) --- + +@app.get("/einkauf", response_class=HTMLResponse) +def buyer_dashboard( + request: Request, + event_id: Optional[int] = None, + 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) + + # 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() + + # Ausgewähltes Event bestimmen: + # 1. Prio: explizit übergebenes event_id + # 2. Prio: Event, bei dem current_user als buyer zugewiesen ist und status == 'open' + # 3. Prio: erstes offenes Event + # 4. Prio: neuestes Event + 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: + for ev in all_events: + if ev.buyer_id == current_user.id and ev.status == "open": + selected_event = ev + break + if not selected_event: + for ev in all_events: + if ev.status == "open": + selected_event = ev + break + 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 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: + total_paid += ord_obj.total_price + + for it in ord_obj.items: + pname = it.product.name if it.product else "Artikel" + unit = it.product.unit if it.product else "Stk" + if pname not in item_sums: + item_sums[pname] = {"quantity": 0, "unit": unit} + item_sums[pname]["quantity"] += it.quantity + + return templates.TemplateResponse(request, "buyer.html", context={ + "tenant": tenant, + "current_user": current_user, + "event": selected_event, + "all_events": all_events, + "orders": orders, + "item_sums": item_sums, + "total_revenue": total_revenue, + "total_paid": total_paid, + "success": success, + "error": error, + }) + + # --- ADMIN COCKPIT (/admin) --- @app.get("/admin", response_class=HTMLResponse) @@ -801,6 +877,7 @@ def admin_create_event( deadline_date: str = Form(...), deadline_time: str = Form(...), sponsor_name: Optional[str] = Form(None), + buyer_id: Optional[str] = Form(None), allow_guests: bool = Form(True), force: bool = Form(False), db: Session = Depends(get_db) @@ -827,6 +904,13 @@ def admin_create_event( status_code=status.HTTP_303_SEE_OTHER ) + buyer_id_int: Optional[int] = None + if buyer_id and buyer_id.strip(): + try: + buyer_id_int = int(buyer_id.strip()) + except ValueError: + pass + new_event = Event( tenant_id=tenant.id, title=title.strip(), @@ -834,6 +918,7 @@ def admin_create_event( deadline=deadline_dt, status="open", sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None, + buyer_id=buyer_id_int, allow_guests=allow_guests, guest_token=secrets.token_urlsafe(16) ) @@ -854,6 +939,7 @@ def admin_edit_event( deadline_time: str = Form(...), status_val: str = Form(...), sponsor_name: Optional[str] = Form(None), + buyer_id: Optional[str] = Form(None), allow_guests: bool = Form(False), db: Session = Depends(get_db) ): @@ -871,11 +957,19 @@ def admin_edit_event( except ValueError as e: raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + buyer_id_int: Optional[int] = None + if buyer_id and buyer_id.strip(): + try: + buyer_id_int = int(buyer_id.strip()) + except ValueError: + pass + event.title = title.strip() event.event_date = ev_date event.deadline = deadline_dt event.status = status_val event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None + event.buyer_id = buyer_id_int event.allow_guests = allow_guests if not event.guest_token: event.guest_token = secrets.token_urlsafe(16) @@ -1179,7 +1273,12 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)): if order: order.is_paid = not order.is_paid db.commit() - return RedirectResponse(url=f"/admin?event_id={order.event_id}", status_code=status.HTTP_303_SEE_OTHER) + + ev_id = order.event_id if order else "" + referer = request.headers.get("referer", "") + if "/einkauf" in referer: + return RedirectResponse(url=f"/einkauf?event_id={ev_id}", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url=f"/admin?event_id={ev_id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER) @app.get("/health") diff --git a/app/models.py b/app/models.py index 471e364..277f3b9 100644 --- a/app/models.py +++ b/app/models.py @@ -82,9 +82,11 @@ class Event(Base): allow_guests = Column(Boolean, default=True, nullable=False) sponsor_name = Column(String(150), nullable=True) guest_token = Column(String(64), nullable=True) + buyer_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="events") + buyer = relationship("User", foreign_keys=[buyer_id]) orders = relationship("Order", back_populates="event", cascade="all, delete-orphan") diff --git a/app/static/style.css b/app/static/style.css index c73eef7..586d473 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,19 +1,52 @@ -:root { - --bg-primary: #0b0f19; - --bg-secondary: #131b2e; - --bg-card: rgba(26, 38, 64, 0.7); - --border-color: rgba(255, 255, 255, 0.08); +:root, [data-theme="light"] { + --bg-primary: #f0f5fa; + --bg-secondary: #e2edf8; + --bg-card: #ffffff; + --bg-card-subtle: #f8fafc; + --bg-navbar: rgba(255, 255, 255, 0.94); + --bg-navbar-mobile: rgba(255, 255, 255, 0.98); + --bg-input: #ffffff; + --border-color: #cbdbee; + --border-subtle: #e2e8f0; + --text-main: #0f172a; + --text-muted: #475569; + --accent: #0066b2; /* Bavarian Diamond Blue / Rautenblau */ + --accent-hover: #004d88; + --accent-gold: #b45309; /* Pretzel Crust Gold */ + --accent-gold-bg: rgba(180, 83, 9, 0.1); + --accent-light: rgba(0, 102, 178, 0.08); + --success: #16a34a; + --warning: #d97706; + --danger: #dc2626; + --radius-lg: 16px; + --radius-md: 10px; + --shadow-card: 0 4px 20px -2px rgba(0, 60, 130, 0.07), 0 2px 6px -1px rgba(0, 0, 0, 0.04); + --shadow-md: 0 10px 25px -5px rgba(0, 60, 130, 0.1); + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +[data-theme="dark"] { + --bg-primary: #070e1b; + --bg-secondary: #0e172a; + --bg-card: rgba(18, 28, 51, 0.88); + --bg-card-subtle: rgba(26, 40, 71, 0.7); + --bg-navbar: rgba(7, 14, 27, 0.94); + --bg-navbar-mobile: rgba(7, 14, 27, 0.98); + --bg-input: rgba(11, 20, 38, 0.85); + --border-color: rgba(70, 120, 190, 0.22); + --border-subtle: rgba(255, 255, 255, 0.08); --text-main: #f8fafc; --text-muted: #94a3b8; --accent: #38bdf8; --accent-hover: #0ea5e9; --accent-gold: #fbbf24; + --accent-gold-bg: rgba(251, 191, 36, 0.15); + --accent-light: rgba(56, 189, 248, 0.12); --success: #22c55e; --warning: #f59e0b; --danger: #ef4444; - --radius-lg: 16px; - --radius-md: 10px; - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --shadow-card: 0 8px 30px rgba(0, 0, 0, 0.4); + --shadow-md: 0 12px 35px rgba(0, 0, 0, 0.5); } * { @@ -38,6 +71,7 @@ body { overflow-x: hidden; width: 100%; position: relative; + transition: background-color 0.2s ease, color 0.2s ease; } .container { @@ -53,13 +87,15 @@ body { /* Navbar */ .navbar { - background: rgba(11, 15, 25, 0.9); + background: var(--bg-navbar); backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); border-bottom: 1px solid var(--border-color); position: sticky; top: 0; z-index: 50; width: 100%; + transition: background 0.2s ease, border-color 0.2s ease; } .nav-content { @@ -69,6 +105,46 @@ body { height: 64px; } +.nav-right-cluster { + 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; + } +} + +/* Theme Toggle Button */ +.theme-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 9999px; + padding: 6px 13px; + color: var(--text-main); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; +} + +.theme-toggle-btn:hover { + border-color: var(--accent); + background: var(--accent-light); + transform: translateY(-1px); +} + /* Mobile Toggle Hamburger */ .mobile-toggle { display: none; @@ -88,7 +164,7 @@ body { } .mobile-toggle:hover, .mobile-toggle:focus-visible { - background: rgba(255, 255, 255, 0.08); + background: var(--accent-light); border-color: var(--accent); outline: none; } @@ -129,13 +205,14 @@ body { } .tenant-badge { - background: rgba(56, 189, 248, 0.12); + background: var(--accent-light); color: var(--accent); padding: 2px 8px; border-radius: 6px; font-size: 0.75rem; - font-weight: 600; - border: 1px solid rgba(56, 189, 248, 0.2); + font-weight: 700; + border: 1px solid var(--border-color); + text-transform: uppercase; } .nav-links { @@ -148,44 +225,53 @@ body { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; - font-weight: 500; + font-weight: 600; transition: color 0.2s; } .nav-links a:hover { - color: var(--text-main); + color: var(--accent); } .user-greeting { color: var(--text-main); font-size: 0.85rem; font-weight: 600; - background: rgba(255, 255, 255, 0.05); + background: var(--accent-light); + border: 1px solid var(--border-color); padding: 4px 10px; border-radius: 6px; } .nav-logout { - color: #ef4444 !important; + color: var(--danger) !important; font-size: 0.85rem !important; } .nav-links a.nav-admin { - background: rgba(251, 191, 36, 0.12); + background: var(--accent-gold-bg); color: var(--accent-gold); padding: 6px 12px; border-radius: 6px; - font-weight: 600; - border: 1px solid rgba(251, 191, 36, 0.25); + font-weight: 700; + border: 1px solid rgba(180, 83, 9, 0.25); +} + +.nav-links a.nav-einkauf-link { + background: var(--accent-light); + color: var(--accent); + padding: 6px 12px; + border-radius: 6px; + font-weight: 700; + border: 1px solid var(--border-color); } .nav-links a.nav-login { - background: rgba(56, 189, 248, 0.12); - color: var(--accent); + background: var(--accent); + color: #ffffff !important; padding: 6px 14px; border-radius: 6px; - font-weight: 600; - border: 1px solid rgba(56, 189, 248, 0.25); + font-weight: 700; } .main-content { @@ -415,7 +501,7 @@ body { display: flex; align-items: center; gap: 8px; - background: rgba(11, 15, 25, 0.8); + background: var(--bg-card-subtle); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 4px; @@ -424,7 +510,7 @@ body { .qty-btn { width: 34px; height: 34px; - background: rgba(255, 255, 255, 0.05); + background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); border-radius: 6px; @@ -438,7 +524,7 @@ body { .qty-btn:hover:not(:disabled) { background: var(--accent); - color: #0b0f19; + color: #ffffff; } .qty-btn:disabled { @@ -469,6 +555,7 @@ body { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); padding: 24px; position: sticky; top: 90px; @@ -478,8 +565,8 @@ body { display: flex; align-items: center; gap: 12px; - background: rgba(11, 15, 25, 0.6); - border: 1px solid rgba(56, 189, 248, 0.25); + background: var(--accent-light); + border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 12px 14px; color: var(--text-main); @@ -488,8 +575,8 @@ body { } .guest-order-box { - background: rgba(11, 15, 25, 0.6); - border: 1px solid rgba(251, 191, 36, 0.3); + background: var(--accent-gold-bg); + border: 1px solid rgba(180, 83, 9, 0.25); border-radius: var(--radius-md); padding: 16px; margin-bottom: 18px; @@ -507,17 +594,19 @@ body { color: var(--text-muted); } -.input-group input, .input-group select { +.input-group input, .input-group select, .input-group textarea { width: 100%; padding: 10px 14px; - background: rgba(11, 15, 25, 0.8); + background: var(--bg-input); border: 1px solid var(--border-color); border-radius: var(--radius-md); color: var(--text-main); font-size: 0.95rem; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); + transition: border-color 0.15s ease; } -.input-group input:focus, .input-group select:focus { +.input-group input:focus, .input-group select:focus, .input-group textarea:focus { outline: none; border-color: var(--accent); } @@ -555,7 +644,7 @@ body { } .readonly-input { - background: rgba(255, 255, 255, 0.05) !important; + background: var(--bg-card-subtle) !important; color: var(--text-muted) !important; cursor: not-allowed; } @@ -1231,6 +1320,8 @@ body { .empty-icon { font-size: 3rem; margin-bottom: 16px; +} + /* Demo Toggle & Credentials Box */ .demo-toggle-wrapper { margin: 16px 0 12px; @@ -1544,6 +1635,199 @@ body { } } +.hide-mobile { + display: inline; +} + +@media (max-width: 768px) { + .hide-mobile { + display: none !important; + } +} + +/* ================= EINKÄUFER- & KASSEN-ZENTRALE (/einkauf) ================= */ +.buyer-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; +} + +.buyer-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.btn-whatsapp { + display: inline-flex; + align-items: center; + gap: 6px; + background: #25d366; + color: #ffffff; + font-weight: 700; + padding: 10px 16px; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.15s ease, transform 0.1s ease; +} + +.btn-whatsapp:hover { + background: #1eb857; + transform: translateY(-1px); +} + +.btn-print { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--accent); + color: #ffffff; + font-weight: 700; + padding: 10px 16px; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.15s ease, transform 0.1s ease; +} + +.btn-print:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.buyer-grid { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: 24px; + margin-top: 20px; +} + +@media (max-width: 900px) { + .buyer-grid { + grid-template-columns: 1fr; + } +} + +.event-chips-scroll { + display: flex; + gap: 10px; + overflow-x: auto; + padding: 4px 2px 10px; + margin-bottom: 20px; +} + +.event-chip { + display: inline-flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 8px 14px; + text-decoration: none; + color: var(--text-main); + white-space: nowrap; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.event-chip:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.event-chip.active { + border-color: var(--accent); + background: var(--accent-light); + border-width: 2px; +} + +.event-chip.completed { + opacity: 0.65; +} + +.chip-date { + font-size: 0.75rem; + font-weight: 700; + color: var(--accent); +} + +.chip-title { + font-size: 0.88rem; + font-weight: 600; +} + +.buyer-whatsapp-box { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + box-shadow: var(--shadow-card); +} + +.empty-cell { + text-align: center; + color: var(--text-muted); + padding: 20px !important; +} + +/* Bavarian Light Theme Overrides */ +[data-theme="light"] .banner { + background: linear-gradient(135deg, #005ea6 0%, #00447c 100%); + color: #ffffff; + border-color: #00447c; +} + +[data-theme="light"] .banner h1, +[data-theme="light"] .banner p, +[data-theme="light"] .banner .banner-sub, +[data-theme="light"] .banner .banner-date { + color: #ffffff; +} + +[data-theme="light"] .banner .banner-sponsor { + background: rgba(255, 255, 255, 0.22); + color: #fef08a; + border: 1px solid rgba(255, 255, 255, 0.35); +} + +[data-theme="light"] .banner-badge { + background: rgba(255, 255, 255, 0.2); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.4); +} + +[data-theme="light"] .demo-cred-item { + background: #f8fafc; + border-color: #cbd5e1; +} + +[data-theme="light"] .demo-credentials-card { + background: #eef6fc; + border-color: #b9d7f2; +} + +[data-theme="light"] .events-ribbon { + background: #ffffff; + border-color: var(--border-color); + box-shadow: var(--shadow-card); +} + +[data-theme="light"] .data-table tbody tr:hover { + background: rgba(0, 102, 178, 0.04); +} + /* ================= PRINT MEDIA QUERY (1-PAGE A4) ================= */ .print-only { display: none; diff --git a/app/templates/admin.html b/app/templates/admin.html index 65cfe75..d1e9d1f 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -87,15 +87,17 @@
Portal: {{ tenant.name }}
| ☐ | +Kollege / Name | +Bestellte Artikel | +Betrag | +Kasse | +
|---|---|---|---|---|
| ☐ | ++ {{ order.display_name }} + {% if order.user and order.user.role == 'guest' %}(Gast){% endif %} + | ++ {% for item in order.items %} + {{ item.quantity }}x {{ item.product.name }}{% if not loop.last %}, {% endif %} + {% endfor %} + | ++ {% if event and event.sponsor_name %} + Sponsor + {% else %} + {{ "%.2f"|format(order.total_price) }} € + {% endif %} + | ++ {% if event and event.sponsor_name %} + Übernommen + {% elif order.is_paid %} + Bezahlt ✓ + {% else %} + Offen + {% endif %} + | +
| Keine Bestellungen vorhanden. | ||||
+ Termin: {{ event.event_date.strftime('%d.%m.%Y') if event else '' }} | + Bestellschluss: {{ event.deadline.strftime('%d.%m. um %H:%M Uhr') if event else '' }} + {% if event and event.buyer %} + | 🏃 Einkäufer: {{ event.buyer.first_name }} {{ event.buyer.last_name }} + {% endif %} +
+Sponsor / Anlass: {{ event.sponsor_name }} übernimmt die Gesamtrechnung von {{ "%.2f"|format(total_revenue) }} € für das gesamte Team.
+Das musst du für die gesamte Runde besorgen:
+ +| Artikel | +Gesamtmenge | +
|---|---|
| {{ name }} | ++ {{ data.quantity }} {{ data.unit }} + | +
| Noch keine Bestellungen eingegangen. | |
Tippe auf den Status, um bezahlt/offen umzuschalten:
+ +| Kollege / Gast | +Bestellung | +Betrag | +Status | +
|---|---|---|---|
| + {{ order.display_name }} + {% if order.user and order.user.role == 'guest' %} + Gast + {% elif not order.user and order.guest_display_name %} + Gast + {% endif %} + | +
+
|
+
+ {{ "%.2f"|format(order.total_price) }} €
+ {% if event.sponsor_name %} von Sponsor{% endif %} + |
+ + {% if event.sponsor_name %} + ✓ Gesponsert + {% else %} + + {% endif %} + | +
| Noch keine Bestellungen vorhanden. | |||
Aktuell ist keine Brotzeit geplant.
+