fix: hamburger menu vertical stack, lock paid orders, order cancellation with balance refund, deduplicate admin drawer

This commit is contained in:
2026-09-22 11:27:46 +02:00
parent 896f1d7d08
commit 970f294c9b
6 changed files with 247 additions and 66 deletions
+92 -1
View File
@@ -1,6 +1,7 @@
import os import os
import re import re
import secrets import secrets
import urllib.parse
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from typing import Optional, Dict, List, Set from typing import Optional, Dict, List, Set
from decimal import Decimal from decimal import Decimal
@@ -130,7 +131,14 @@ def seed_demo_users_if_needed(tenant: Tenant, db: Session):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request, event_id: Optional[int] = None, token: Optional[str] = None, db: Session = Depends(get_db)): def index(
request: Request,
event_id: Optional[int] = None,
token: Optional[str] = None,
success: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db) tenant = resolve_tenant(request, db)
if not tenant: if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={}) return templates.TemplateResponse(request, "not_found_tenant.html", context={})
@@ -207,6 +215,8 @@ def index(request: Request, event_id: Optional[int] = None, token: Optional[str]
"existing_order": existing_order, "existing_order": existing_order,
"user_items": user_items, "user_items": user_items,
"order_count": order_count, "order_count": order_count,
"success": success,
"error": error,
"now": now "now": now
}) })
@@ -456,6 +466,11 @@ async def place_order(
).first() ).first()
if existing_order: if existing_order:
if existing_order.is_paid:
raise HTTPException(
status_code=400,
detail="Diese Bestellung wurde bereits als bezahlt verbucht und kann nicht mehr geändert werden. Bitte wende dich an deinen Organisator."
)
db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete() db.query(OrderItem).filter(OrderItem.order_id == existing_order.id).delete()
existing_order.total_price = total_price existing_order.total_price = total_price
existing_order.guest_display_name = guest_name_str existing_order.guest_display_name = guest_name_str
@@ -484,6 +499,43 @@ async def place_order(
return RedirectResponse(url=f"/order/{order_id}/success", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/order/{order_id}/success", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/order/cancel")
def cancel_order(
request: Request,
event_id: int = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
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.status != "open" or event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Frist ist abgelaufen. Bestellungen können nicht mehr storniert werden.")
order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
Order.user_id == current_user.id
).first()
if not order:
return RedirectResponse(url=f"/?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER)
if order.is_paid:
raise HTTPException(status_code=400, detail="Bereits bezahlte Bestellungen können nur vom Organisator storniert werden.")
db.delete(order)
db.commit()
success_msg = urllib.parse.quote("Deine Bestellung wurde erfolgreich storniert")
return RedirectResponse(url=f"/?event_id={event.id}&success={success_msg}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/order/{order_id}/success", response_class=HTMLResponse) @app.get("/order/{order_id}/success", response_class=HTMLResponse)
def order_success(order_id: int, request: Request, db: Session = Depends(get_db)): def order_success(order_id: int, request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db) tenant = resolve_tenant(request, db)
@@ -1522,6 +1574,45 @@ def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
return RedirectResponse(url=f"/admin?event_id={ev_id}&tab=orders", 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.post("/admin/orders/{order_id}/delete")
def admin_delete_order(
order_id: int,
request: Request,
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
actor = require_organizer_or_admin(request, db)
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")
ev_id = order.event_id
# Falls per Guthaben bezahlt, Betrag automatisch zurückerstatten!
if order.is_paid and 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=tenant.id,
user_id=order.user.id,
order_id=order.id,
amount=order.total_price,
transaction_type="refund",
description=f"Rückerstattung wegen Stornierung: {ev_title}",
created_by_id=actor.id
)
db.add(tx)
db.delete(order)
db.commit()
referer = request.headers.get("referer", "")
if "/einkauf" in referer:
return RedirectResponse(url=f"/einkauf?event_id={ev_id}&success=Bestellung erfolgreich storniert und ggf. erstattet", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url=f"/admin?event_id={ev_id}&tab=orders&success=Bestellung erfolgreich storniert und ggf. erstattet", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok", "app": "Schmankerl"} return {"status": "ok", "app": "Schmankerl"}
+18 -1
View File
@@ -133,12 +133,29 @@ body {
transform: translateY(-1px); transform: translateY(-1px);
} }
.hamburger-icon {
display: flex;
flex-direction: column;
justify-content: center;
gap: 3.5px;
width: 16px;
height: 14px;
flex-shrink: 0;
}
.hamburger-icon .bar,
.menu-toggle-btn .bar { .menu-toggle-btn .bar {
display: block; display: block;
width: 16px; width: 100%;
height: 2px; height: 2px;
background-color: var(--text-main); background-color: var(--text-main);
border-radius: 2px; border-radius: 2px;
transition: all 0.2s ease;
}
.menu-toggle-btn:hover .hamburger-icon .bar,
.menu-toggle-btn:hover .bar {
background-color: var(--accent);
} }
.menu-btn-text { .menu-btn-text {
+19 -9
View File
@@ -295,15 +295,25 @@
{% if event.sponsor_name %}<br><small class="text-muted">von Sponsor</small>{% endif %} {% if event.sponsor_name %}<br><small class="text-muted">von Sponsor</small>{% endif %}
</td> </td>
<td> <td>
{% if event.sponsor_name %} <div style="display: flex; align-items: center; justify-content: space-between; gap: 6px;">
<span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span> {% if event.sponsor_name %}
{% else %} <span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span>
<form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0;"> {% else %}
<button type="submit" class="badge-btn {% if order.is_paid %}paid{% else %}unpaid{% endif %}"> <form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0;">
{% if order.is_paid %}✓ Bezahlt{% else %}⏳ Offen{% endif %} <button type="submit" class="badge-btn {% if order.is_paid %}paid{% else %}unpaid{% endif %}" title="Klick um Bezahlstatus umzuschalten (Guthaben wird automatisch erstattet)">
</button> {% if order.is_paid %}
</form> {% if order.payment_method == 'balance' %}✓ Guthaben{% else %}✓ Bezahlt{% endif %}
{% endif %} {% else %}
⏳ Offen
{% endif %}
</button>
</form>
{% endif %}
<form action="/admin/orders/{{ order.id }}/delete" method="POST" style="margin: 0;" onsubmit="return confirm('Bestellung für {{ order.display_name|escape }} wirklich stornieren? Bereits bezahltes Guthaben wird sofort erstattet.');">
<button type="submit" class="btn-revoke-small" title="Bestellung stornieren / löschen" style="padding: 2px 6px; font-size: 0.8rem;">🗑️</button>
</form>
</div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
+23 -21
View File
@@ -23,9 +23,11 @@
<!-- LEFT: Menu Toggle + Brand --> <!-- LEFT: Menu Toggle + Brand -->
<div class="nav-left"> <div class="nav-left">
<button class="menu-toggle-btn" id="drawerToggleBtn" onclick="openDrawer()" aria-label="Menü öffnen" aria-expanded="false"> <button class="menu-toggle-btn" id="drawerToggleBtn" onclick="openDrawer()" aria-label="Menü öffnen" aria-expanded="false">
<span class="bar"></span> <span class="hamburger-icon">
<span class="bar"></span> <span class="bar"></span>
<span class="bar"></span> <span class="bar"></span>
<span class="bar"></span>
</span>
<span class="menu-btn-text">Menü</span> <span class="menu-btn-text">Menü</span>
</button> </button>
@@ -121,14 +123,6 @@
<span class="nav-item-text">Brotzeit bestellen</span> <span class="nav-item-text">Brotzeit bestellen</span>
</a> </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' %} {% if current_user and current_user.role != 'guest' %}
<div class="nav-group-title">KASSE & GUTHABEN</div> <div class="nav-group-title">KASSE & GUTHABEN</div>
<a href="/kasse" class="drawer-nav-item" onclick="closeDrawer()"> <a href="/kasse" class="drawer-nav-item" onclick="closeDrawer()">
@@ -138,7 +132,16 @@
{% endif %} {% endif %}
{% if current_user and current_user.role in ['admin', 'organizer'] %} {% if current_user and current_user.role in ['admin', 'organizer'] %}
<div class="nav-group-title">ORGANISATION</div> <div class="nav-group-title">VERWALTUNG & ORGA</div>
<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">Theke</span>
</a>
<a href="/admin?tab=orders" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">📋</span>
<span class="nav-item-text">Bestellungen & Abrechnung</span>
</a>
<a href="/admin?tab=event" class="drawer-nav-item" onclick="closeDrawer()"> <a href="/admin?tab=event" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">📅</span> <span class="nav-item-icon">📅</span>
<span class="nav-item-text">Termine & Runden</span> <span class="nav-item-text">Termine & Runden</span>
@@ -147,18 +150,17 @@
<span class="nav-item-icon">🥨</span> <span class="nav-item-icon">🥨</span>
<span class="nav-item-text">Sortiment & Preise</span> <span class="nav-item-text">Sortiment & Preise</span>
</a> </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()"> <a href="/admin?tab=team" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">👥</span> <span class="nav-item-icon">👥</span>
<span class="nav-item-text">Team- & Rollenverwaltung</span> <span class="nav-item-text">{% if current_user.role == 'admin' %}Team, Rollen & Einladungen{% else %}Team & Einladungen{% endif %}</span>
{% if current_user.role == 'admin' %}
<span class="nav-badge-pill admin">Admin</span> <span class="nav-badge-pill admin">Admin</span>
{% endif %}
</a>
{% elif current_user and current_user.role == 'user' %}
<a href="/einkauf" class="drawer-nav-item" onclick="closeDrawer()">
<span class="nav-item-icon">🛒</span>
<span class="nav-item-text">Einkaufsliste (Theke)</span>
</a> </a>
{% endif %} {% endif %}
+29 -23
View File
@@ -247,34 +247,40 @@
{% if event.sponsor_name %}<br><small class="text-muted">von Sponsor</small>{% endif %} {% if event.sponsor_name %}<br><small class="text-muted">von Sponsor</small>{% endif %}
</td> </td>
<td> <td>
{% if event.sponsor_name %} <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
<span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span> {% if event.sponsor_name %}
{% elif order.is_paid %} <span class="badge-btn paid" style="cursor: default;">✓ Gesponsert</span>
<form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0; display: inline;"> {% elif order.is_paid %}
<button type="submit" class="badge-btn paid" title="Klick um Bezahlung zurückzusetzen"> <form action="/admin/toggle-paid/{{ order.id }}" method="POST" style="margin: 0; display: inline;">
{% if order.payment_method == 'balance' %} <button type="submit" class="badge-btn paid" title="Klick um Bezahlung zurückzusetzen (Guthaben wird automatisch erstattet)">
✓ Bezahlt (Guthaben) {% if order.payment_method == 'balance' %}
{% else %} ✓ Bezahlt (Guthaben)
✓ Bezahlt (Bar) {% else %}
{% endif %} ✓ Bezahlt (Bar)
</button> {% endif %}
</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> </button>
</form> </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 %} {% 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"> <form action="/admin/orders/{{ order.id }}/delete" method="POST" style="margin: 0; display: inline;" onsubmit="return confirm('Bestellung für {{ order.display_name|escape }} wirklich stornieren? Bereits bezahltes Guthaben wird sofort erstattet.');">
💵 Bar bezahlt <button type="submit" class="btn-revoke-small" title="Bestellung stornieren / löschen" style="padding: 2px 6px; font-size: 0.8rem;">🗑️</button>
</button>
</form> </form>
</div> </div>
{% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}
+66 -11
View File
@@ -3,6 +3,18 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% if success %}
<div class="alert-success" style="margin-bottom: 20px;">
✓ {{ success }}
</div>
{% endif %}
{% if error %}
<div class="alert-error" style="margin-bottom: 20px;">
⚠️ {{ error }}
</div>
{% endif %}
<!-- Multi-Event Picker (wenn mehrere anstehende Runden offen sind) --> <!-- Multi-Event Picker (wenn mehrere anstehende Runden offen sind) -->
{% if open_events|length > 1 %} {% if open_events|length > 1 %}
<div class="multi-event-banner"> <div class="multi-event-banner">
@@ -53,6 +65,8 @@
</div> </div>
{% endif %} {% endif %}
{% set is_locked = is_closed or (existing_order and existing_order.is_paid) %}
<!-- Order Form --> <!-- Order Form -->
<form action="/order" method="POST" class="order-form" id="orderForm"> <form action="/order" method="POST" class="order-form" id="orderForm">
<input type="hidden" name="event_id" value="{{ event.id }}"> <input type="hidden" name="event_id" value="{{ event.id }}">
@@ -65,7 +79,7 @@
<div class="product-list"> <div class="product-list">
{% for product in products %} {% for product in products %}
{% set current_qty = user_items.get(product.id, 0) %} {% set current_qty = user_items.get(product.id, 0) %}
<div class="product-card {% if is_closed %}disabled{% endif %}" data-price="{{ product.price }}" data-id="{{ product.id }}"> <div class="product-card {% if is_locked %}disabled{% endif %}" data-price="{{ product.price }}" data-id="{{ product.id }}">
<div class="product-info"> <div class="product-info">
<h3>{{ product.name }}</h3> <h3>{{ product.name }}</h3>
<p class="product-desc">{{ product.description }}</p> <p class="product-desc">{{ product.description }}</p>
@@ -79,9 +93,9 @@
</div> </div>
</div> </div>
<div class="quantity-control"> <div class="quantity-control">
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)" {% if is_closed %}disabled{% endif %}>-</button> <button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, -1)" {% if is_locked %}disabled{% endif %}>-</button>
<input type="number" id="qty_{{ product.id }}" name="qty_{{ product.id }}" value="{{ current_qty }}" min="0" max="50" class="qty-input" onchange="updateTotal()" {% if is_closed %}disabled{% endif %}> <input type="number" id="qty_{{ product.id }}" name="qty_{{ product.id }}" value="{{ current_qty }}" min="0" max="50" class="qty-input" onchange="updateTotal()" {% if is_locked %}disabled{% endif %}>
<button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, 1)" {% if is_closed %}disabled{% endif %}>+</button> <button type="button" class="qty-btn" onclick="changeQty({{ product.id }}, 1)" {% if is_locked %}disabled{% endif %}>+</button>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -125,11 +139,22 @@
</div> </div>
{% endif %} {% endif %}
{% set is_locked = is_closed or (existing_order and existing_order.is_paid) %}
{% if existing_order %} {% if existing_order %}
<div class="notice-box"> {% if existing_order.is_paid %}
ℹ️ Du hast bereits für diese Runde bestellt ({% if existing_order.is_paid %}<strong>Bereits bezahlt ✓</strong>{% else %}Noch offen{% endif %}). <div class="alert-success" style="margin-bottom: 16px; border-left: 4px solid var(--success); font-size: 0.88rem; line-height: 1.4;">
{% if not is_closed %}Du kannst deine Mengen anpassen und aktualisieren.{% endif %} 🔒 <strong>Bestellung bereits bezahlt ({{ "%.2f"|format(existing_order.total_price) }} €)</strong><br>
</div> Deine Bestellung wurde vom Organisator bereits als bezahlt verbucht
({% if existing_order.payment_method == 'balance' %}Guthaben abgebucht{% else %}Barzahlung vermerkt{% endif %}).
Änderungen sind gesperrt. Wende dich an deinen Organisator für Anpassungen.
</div>
{% else %}
<div class="notice-box" style="margin-bottom: 14px;">
ℹ️ Du hast bereits für diese Runde bestellt (Status: <strong>Noch offen</strong>).
{% if not is_closed %}Du kannst deine Mengen bis zum Fristende anpassen oder die Bestellung stornieren.{% endif %}
</div>
{% endif %}
{% endif %} {% endif %}
<hr class="divider"> <hr class="divider">
@@ -145,8 +170,10 @@
</div> </div>
{% endif %} {% endif %}
<button type="submit" class="btn-submit" id="submitBtn" {% if is_closed %}disabled{% endif %}> <button type="submit" class="btn-submit" id="submitBtn" {% if is_locked %}disabled{% endif %}>
{% if is_closed %} {% if existing_order and existing_order.is_paid %}
🔒 Bestellung bereits bezahlt & gesperrt
{% elif is_closed %}
🔒 Bestellschluss vorüber 🔒 Bestellschluss vorüber
{% elif existing_order %} {% elif existing_order %}
🥨 Bestellung aktualisieren 🥨 Bestellung aktualisieren
@@ -155,8 +182,18 @@
{% endif %} {% endif %}
</button> </button>
{% if not is_closed %} {% if existing_order and not existing_order.is_paid and not is_closed %}
<div style="margin-top: 10px;">
<button type="button" onclick="cancelMyOrder()" class="btn-secondary" style="width: 100%; border-color: var(--danger); color: var(--danger); font-size: 0.85rem; padding: 8px 12px; cursor: pointer;">
✕ Bestellung komplett stornieren
</button>
</div>
{% endif %}
{% if not is_closed and not (existing_order and existing_order.is_paid) %}
<small class="form-hint">Du kannst deine Bestellung bis zum Bestellschluss jederzeit hier anpassen.</small> <small class="form-hint">Du kannst deine Bestellung bis zum Bestellschluss jederzeit hier anpassen.</small>
{% elif existing_order and existing_order.is_paid %}
<small class="form-hint">Bereits abgerechnet – Änderungen nur über den Organisator.</small>
{% else %} {% else %}
<small class="form-hint text-warning">Die Frist ist abgelaufen.</small> <small class="form-hint text-warning">Die Frist ist abgelaufen.</small>
{% endif %} {% endif %}
@@ -261,6 +298,24 @@
if (timerEl) timerEl.innerText = "Frist beendet"; if (timerEl) timerEl.innerText = "Frist beendet";
{% endif %} {% endif %}
function cancelMyOrder() {
if (!confirm('Möchtest du deine Bestellung für diese Runde wirklich komplett stornieren?')) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/order/cancel';
const evInput = document.createElement('input');
evInput.type = 'hidden';
evInput.name = 'event_id';
evInput.value = '{{ event.id if event else "" }}';
form.appendChild(evInput);
document.body.appendChild(form);
form.submit();
}
document.addEventListener('DOMContentLoaded', updateTotal); document.addEventListener('DOMContentLoaded', updateTotal);
</script> </script>
{% endblock %} {% endblock %}