From 913aa646eaff78d415987b322c5468dfe9879763 Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Mon, 21 Sep 2026 13:29:15 +0200 Subject: [PATCH] feat: Compact 1-page print view, event lifecycle & conflict warnings, bulk email invites, guest pass link and post-event guest cleanup --- app/main.py | 200 ++++++++++++++++--- app/models.py | 11 +- app/static/style.css | 355 +++++++++++++++++++++++++--------- app/templates/admin.html | 407 ++++++++++++++++++++++++++------------- app/templates/index.html | 31 ++- 5 files changed, 741 insertions(+), 263 deletions(-) diff --git a/app/main.py b/app/main.py index 4cde5ed..c35b4f0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ import os +import re import secrets from datetime import datetime, timedelta, date from typing import Optional, Dict, List @@ -98,7 +99,7 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session): @app.get("/", response_class=HTMLResponse) -def index(request: Request, db: Session = Depends(get_db)): +def index(request: Request, event_id: Optional[int] = None, db: Session = Depends(get_db)): tenant = resolve_tenant(request, db) if not tenant: return templates.TemplateResponse(request, "not_found_tenant.html", context={}) @@ -106,16 +107,25 @@ def index(request: Request, db: Session = Depends(get_db)): current_user = get_current_user(request, db) now = datetime.now() - # Aktuelle offene Runde suchen - event = db.query(Event).filter( + # Alle offenen Events laden + open_events = db.query(Event).filter( Event.tenant_id == tenant.id, Event.status == "open" - ).order_by(Event.deadline.asc()).first() + ).order_by(Event.event_date.asc(), Event.deadline.asc()).all() - # Wenn keine offene, das neueste anzeigen + # Gewünschtes Event wählen + event = None + if event_id: + event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not event and open_events: + event = open_events[0] if not event: + # Neuestes Event überhaupt anzeigen event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first() + # Gast-Modus + is_guest_mode = (request.query_params.get("guest") == "1") + # Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login if not current_user: if not event or not event.allow_guests: @@ -157,6 +167,8 @@ def index(request: Request, db: Session = Depends(get_db)): "tenant": tenant, "current_user": current_user, "event": event, + "open_events": open_events, + "is_guest_mode": is_guest_mode, "is_closed": is_closed, "seconds_remaining": seconds_remaining, "products": products, @@ -177,7 +189,6 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[ if current_user: return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - # Prüfen, ob aktives Event Gäste erlaubt active_event = db.query(Event).filter( Event.tenant_id == tenant.id, Event.status == "open", @@ -190,6 +201,7 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[ "error": error, "success": success, "allow_guests": allow_guests, + "active_event": active_event, "current_user": None }) @@ -328,7 +340,6 @@ def process_join( ) db.add(new_user) - # Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen! if not invitation.is_reusable: invitation.used_at = datetime.now() @@ -360,16 +371,17 @@ async def place_order( if event.deadline < datetime.now(): raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!") - # Falls nicht eingeloggt -> Gast-Bestellung prüfen + guest_name_str = None if not current_user: if not event.allow_guests: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) if not guest_first_name or not guest_first_name.strip(): - raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Vornamen an!") + raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Namen an!") fname = guest_first_name.strip() lname = guest_last_name.strip() if guest_last_name else "(Gast)" + guest_name_str = f"{fname} {lname}" clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local") guest_user = User( @@ -386,6 +398,8 @@ async def place_order( db.refresh(guest_user) current_user = guest_user request.session["user_id"] = guest_user.id + else: + guest_name_str = f"{current_user.first_name} {current_user.last_name}" form_data = await request.form() order_items = [] @@ -411,7 +425,6 @@ async def place_order( if not order_items: 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( Order.tenant_id == tenant.id, Order.event_id == event.id, @@ -421,6 +434,7 @@ async def place_order( if existing_order: db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete() existing_order.total_price = total_price + existing_order.guest_display_name = guest_name_str for item in order_items: item.order_id = existing_order.id db.add(item) @@ -431,6 +445,7 @@ async def place_order( tenant_id=tenant.id, event_id=event.id, user_id=current_user.id, + guest_display_name=guest_name_str, total_price=total_price, is_paid=False ) @@ -470,6 +485,9 @@ def admin_dashboard( request: Request, event_id: Optional[int] = None, tab: str = "orders", + warn_active_id: Optional[int] = None, + success: Optional[str] = None, + bulk_count: Optional[int] = None, db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) @@ -483,14 +501,21 @@ def admin_dashboard( raise HTTPException(status_code=403, detail="Zugriff nur für Administratoren") # Alle Events des Mandanten - all_events = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).all() + 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 + # Ausgewähltes Event bestimmen: Wenn event_id übergeben, nehmen wir das, sonst das erste offene, sonst das neueste 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] + + if not selected_event: + # Bevorzuge aktives offenes 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 = {} @@ -511,19 +536,33 @@ def admin_dashboard( 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() - - # Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft) + # Teammitglieder laden (Gäste filtern oder am Ende aufführen) + team_members = db.query(User).filter( + User.tenant_id == tenant.id, + User.role != "guest" + ).order_by(User.role.asc(), User.first_name.asc()).all() + + # Aktive Gäste laden + active_guests = db.query(User).filter( + User.tenant_id == tenant.id, + User.role == "guest" + ).all() + + # Offene Einladungen now = datetime.now() pending_invitations = db.query(Invitation).filter( Invitation.tenant_id == tenant.id, (Invitation.expires_at == None) | (Invitation.expires_at > now) ).order_by(Invitation.created_at.desc()).all() - # Produkte des Mandanten laden + # Produkte laden products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all() + # Warnung bei existierendem aktivem Event + active_warning_event = None + if warn_active_id: + active_warning_event = db.query(Event).filter(Event.id == warn_active_id, Event.tenant_id == tenant.id).first() + return templates.TemplateResponse(request, "admin.html", context={ "tenant": tenant, "current_user": current_user, @@ -534,9 +573,13 @@ def admin_dashboard( "total_revenue": total_revenue, "total_paid": total_paid, "team_members": team_members, + "active_guests": active_guests, "pending_invitations": pending_invitations, "products": products, "active_tab": tab, + "warn_active_event": active_warning_event, + "success": success, + "bulk_count": bulk_count, "now": now }) @@ -580,20 +623,25 @@ def admin_delete_user( user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first() if user: + # Eventuelle Bestellungen von user_id entkoppeln, damit Beträge im Archiv nicht verloren gehen + orders = db.query(Order).filter(Order.user_id == user.id).all() + for ord_obj in orders: + ord_obj.guest_display_name = f"{user.first_name} {user.last_name}" + ord_obj.user_id = None db.delete(user) db.commit() return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) -# --- ADMIN INVITATIONS --- +# --- ADMIN INVITATIONS (EINZELN & BULK) --- @app.post("/admin/invitations/create") def admin_create_invitation( request: Request, email: Optional[str] = Form(None), role: str = Form("user"), - valid_days: str = Form("14"), + valid_days: str = Form("unlimited"), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) @@ -602,7 +650,6 @@ def admin_create_invitation( token = secrets.token_urlsafe(20) clean_email = email.strip().lower() if email and email.strip() else None - # Unbegrenzt prüfen if valid_days in ["0", "unlimited", "forever"]: expires_at = None is_reusable = True @@ -626,6 +673,50 @@ def admin_create_invitation( return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) +@app.post("/admin/invitations/bulk") +def admin_bulk_invitations( + request: Request, + emails_text: str = Form(...), + role: str = Form("user"), + valid_days: str = Form("14"), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + admin = require_admin(request, db) + + # E-Mails per Regex extrahieren (unterstützt Komma, Semikolon, Umbrüche etc.) + 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)])) + + if not found_emails: + raise HTTPException(status_code=400, detail="Keine gültigen E-Mail-Adressen gefunden.") + + expires_at = None + is_reusable = False + if valid_days in ["0", "unlimited", "forever"]: + is_reusable = True + else: + expires_at = datetime.now() + timedelta(days=int(valid_days)) + + count = 0 + for em in found_emails: + token = secrets.token_urlsafe(20) + inv = Invitation( + tenant_id=tenant.id, + token=token, + email=em, + role=role, + created_by=admin.id, + expires_at=expires_at, + is_reusable=is_reusable + ) + db.add(inv) + count += 1 + + db.commit() + return RedirectResponse(url=f"/admin?tab=team&bulk_count={count}", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/admin/invitations/{inv_id}/revoke") def admin_revoke_invitation( inv_id: int, @@ -654,6 +745,7 @@ def admin_create_event( deadline_time: str = Form(...), sponsor_name: Optional[str] = Form(None), allow_guests: bool = Form(True), + force: bool = Form(False), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) @@ -666,6 +758,20 @@ def admin_create_event( except ValueError as e: raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + # Prüfung: Gibt es bereits ein aktives offenes Event in der Zukunft? + if not force: + active_existing = db.query(Event).filter( + Event.tenant_id == tenant.id, + Event.status == "open", + Event.event_date >= date.today() + ).first() + if active_existing: + # Weiterleitung mit Warn-Hinweis + return RedirectResponse( + url=f"/admin?tab=event&warn_active_id={active_existing.id}", + status_code=status.HTTP_303_SEE_OTHER + ) + new_event = Event( tenant_id=tenant.id, title=title.strip(), @@ -679,7 +785,7 @@ def admin_create_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) + return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=orders", status_code=status.HTTP_303_SEE_OTHER) @app.post("/admin/events/{event_id}/edit") @@ -720,8 +826,40 @@ def admin_edit_event( 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( +@app.post("/admin/events/{event_id}/complete-and-cleanup") +def admin_complete_event_and_cleanup_guests( + event_id: int, + request: Request, + db: Session = Depends(get_db) +): + """Schließt das Event ab und bereinigt temporäre Gast-Konten.""" + 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 = "completed" + + # Gast-Bestellungen sichern und Gast-Konten entfernen + orders = db.query(Order).filter(Order.event_id == event.id).all() + cleaned_count = 0 + for ord_obj in orders: + if ord_obj.user and ord_obj.user.role == "guest": + ord_obj.guest_display_name = f"{ord_obj.user.first_name} {ord_obj.user.last_name}" + guest_user = ord_obj.user + ord_obj.user_id = None + db.delete(guest_user) + cleaned_count += 1 + + db.commit() + msg = f"Event erfolgreich abgeschlossen. {cleaned_count} Gastkonten bereinigt." + return RedirectResponse(url=f"/admin?event_id={event.id}&tab=orders&success={msg}", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/events/{event_id}/delete") +def admin_delete_event( event_id: int, request: Request, db: Session = Depends(get_db) @@ -730,13 +868,11 @@ def admin_toggle_event_status( 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") + if event: + db.delete(event) + db.commit() - 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) + return RedirectResponse(url="/admin?tab=event", status_code=status.HTTP_303_SEE_OTHER) # --- ADMIN PRODUCT CATALOG MANAGEMENT --- @@ -885,7 +1021,7 @@ 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="/admin", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url=f"/admin?event_id={order.event_id}", status_code=status.HTTP_303_SEE_OTHER) @app.get("/health") diff --git a/app/models.py b/app/models.py index 99934f0..cf86ac9 100644 --- a/app/models.py +++ b/app/models.py @@ -35,7 +35,7 @@ class User(Base): created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="users") - orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") + orders = relationship("Order", back_populates="user") class Invitation(Base): @@ -93,7 +93,8 @@ class Order(Base): id = Column(Integer, primary_key=True, index=True) tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + 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) created_at = Column(DateTime, default=func.now()) @@ -103,6 +104,12 @@ class Order(Base): user = relationship("User", back_populates="orders") items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + @property + def display_name(self) -> str: + if self.user: + return f"{self.user.first_name} {self.user.last_name}" + return self.guest_display_name or "Gast" + class OrderItem(Base): __tablename__ = "order_items" diff --git a/app/static/style.css b/app/static/style.css index 5b98945..94d0ffe 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -139,6 +139,47 @@ body { padding: 36px 0; } +/* Multi-Event Banner on Order Page */ +.multi-event-banner { + display: flex; + align-items: center; + gap: 12px; + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: var(--radius-md); + padding: 10px 16px; + margin-bottom: 18px; + font-size: 0.85rem; + flex-wrap: wrap; +} + +.multi-event-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.mini-chip { + padding: 4px 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-main); + text-decoration: none; + font-weight: 600; + transition: all 0.2s; +} + +.mini-chip:hover { + border-color: var(--accent); +} + +.mini-chip.active { + background: var(--accent); + color: #0b0f19; + border-color: var(--accent); +} + /* Banner */ .banner { background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.9) 100%); @@ -656,6 +697,131 @@ body { margin-bottom: 18px; } +.alert-warning-card { + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: var(--radius-md); + padding: 20px; + margin-bottom: 24px; +} + +.alert-warning-card h3 { + color: var(--warning); + font-size: 1.1rem; + margin-bottom: 6px; +} + +/* Events Ribbon in Admin */ +.events-ribbon { + display: flex; + align-items: center; + gap: 14px; + background: rgba(11, 15, 25, 0.6); + border: 1px solid var(--border-color); + padding: 12px 18px; + border-radius: var(--radius-md); + margin-bottom: 24px; + overflow-x: auto; +} + +.events-ribbon-title { + font-weight: 700; + font-size: 0.9rem; + color: var(--text-muted); + white-space: nowrap; +} + +.events-ribbon-list { + display: flex; + gap: 10px; + overflow-x: auto; +} + +.event-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-main); + text-decoration: none; + font-size: 0.85rem; + transition: all 0.2s; + white-space: nowrap; +} + +.event-chip:hover { + border-color: var(--accent); +} + +.event-chip.active { + background: rgba(56, 189, 248, 0.15); + border-color: var(--accent); +} + +.event-chip.completed { + opacity: 0.6; +} + +.chip-date { + font-weight: 700; + color: var(--accent); +} + +.chip-badge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; +} + +.chip-badge.open { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +.chip-badge.closed { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +.chip-badge.comp { + background: rgba(148, 163, 184, 0.2); + color: #94a3b8; +} + +/* Event Action Bar */ +.event-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(11, 15, 25, 0.6); + border: 1px solid var(--border-color); + padding: 16px 20px; + border-radius: var(--radius-md); + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} + +.btn-success-small { + padding: 8px 14px; + background: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.4); + color: #86efac; + border-radius: 6px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-success-small:hover { + background: rgba(34, 197, 94, 0.3); +} + /* Admin Dashboard Tabs */ .admin-header { display: flex; @@ -669,6 +835,7 @@ body { .admin-actions-top { display: flex; gap: 10px; + flex-wrap: wrap; } .admin-sub { @@ -703,26 +870,6 @@ body { 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; -} - .sponsor-highlight-card { display: flex; align-items: center; @@ -882,6 +1029,20 @@ body { color: #c084fc; } +/* Bulk Invite Box */ +.bulk-invite-box { + background: rgba(11, 15, 25, 0.7); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 20px; + margin-bottom: 24px; +} + +.bulk-invite-box h3 { + font-size: 1.05rem; + margin-bottom: 4px; +} + /* Invitations List */ .invitations-list { display: flex; @@ -986,35 +1147,6 @@ body { box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); } -/* Setup Wizard Steps */ -.setup-step-card { - display: flex; - gap: 16px; - background: rgba(11, 15, 25, 0.6); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 20px; - margin-bottom: 18px; -} - -.step-num { - width: 36px; - height: 36px; - border-radius: 50%; - background: var(--accent); - color: #0b0f19; - font-weight: 800; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.step-content h3 { - font-size: 1.05rem; - margin-bottom: 6px; -} - /* Footer */ .footer { border-top: 1px solid var(--border-color); @@ -1047,19 +1179,32 @@ body { margin-bottom: 16px; } -/* --- PRINT MEDIA QUERY --- */ +/* ================= PRINT MEDIA QUERY (1-PAGE A4) ================= */ .print-only { display: none; } +@page { + size: A4 portrait; + margin: 8mm 10mm; +} + @media print { + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + body { background: #ffffff !important; color: #000000 !important; - font-size: 11pt; + font-size: 8.5pt !important; + line-height: 1.2 !important; + margin: 0 !important; + padding: 0 !important; } - .navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn { + .navbar, .footer, .no-print, .btn-primary, .btn-secondary, .badge-btn, .events-ribbon, .admin-nav-tabs, .admin-header { display: none !important; } @@ -1071,47 +1216,85 @@ body { display: block !important; } - .print-header { + /* Single-line condensed header */ + .print-header-singleline { + display: flex; + justify-content: space-between; + align-items: baseline; border-bottom: 2px solid #000; - padding-bottom: 10px; - margin-bottom: 20px; - } - - .print-header h1 { - font-size: 18pt; - margin-bottom: 6px; - } - - .print-sponsor-box { - margin-top: 8px; - padding: 6px; - border: 1px dashed #000; - font-size: 10pt; - } - - .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; + margin-bottom: 6px; font-size: 10pt; + } + + .print-logo { + font-weight: 800; + font-size: 11pt; + margin-right: 6px; + } + + .print-sponsor { + font-weight: bold; color: #000; } + /* Compact Sammel-Box */ + .print-sammel-box { + background: #f1f5f9 !important; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 5px 8px; + margin-bottom: 8px; + font-size: 8.5pt; + line-height: 1.4; + } + + .print-sammel-title { + font-weight: 700; + margin-right: 8px; + text-decoration: underline; + } + + .print-item-pill { + display: inline-block; + background: #ffffff !important; + border: 1px solid #94a3b8; + border-radius: 3px; + padding: 1px 6px; + margin: 1px 3px 1px 0; + font-size: 8pt; + } + + /* Compact Ausgabeliste */ + .print-table { + width: 100%; + border-collapse: collapse; + font-size: 8.5pt; + margin-top: 4px; + } + + .print-table th, .print-table td { + border: 1px solid #64748b; + padding: 2.5px 5px !important; + text-align: left; + font-size: 8.5pt !important; + line-height: 1.15 !important; + color: #000 !important; + } + .print-table th { - background-color: #f2f2f2 !important; + background-color: #e2e8f0 !important; font-weight: bold; + font-size: 8.5pt; + } + + .print-items-cell { + font-size: 8pt !important; + } + + .guest-tag { + font-size: 7.5pt; + font-style: italic; + color: #475569; } } diff --git a/app/templates/admin.html b/app/templates/admin.html index 8feaa8d..b2ab48d 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -3,91 +3,87 @@ {% block content %}
- +