Add staff dashboard, payment flow, seed extra 2026 expenses

Self-service /suff/pay/ page lets users record their own payments
(cash/PayPal/bank/other) against their tab. Open balance is shown on
/suff/me/ alongside total and paid amount, with a Bezahlen button when
something is owed.

Staff-only /suff/dashboard/ replaces the drink_stats / total_balance /
user_stats CLI commands with a mobile-friendly festival view: overall
refinancing progress bar (Spenden vs. Ausgaben with Bilanz), drinks
refinancing bar (sales revenue vs. purchase cost with profit), per-drink
sold/total/balance, open balances per user, and fun facts (top
spender, top drink, busiest day, and top user per festival day).
Linked from /suff/me/ when the logged-in user is staff.

seed_drinks_2026 also creates the non-drink Payments we already know
about (toilets, drinks/equipment down payment, band fees per stage day),
idempotently keyed on (purpose, date).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:16:19 +02:00
parent b9c62babf1
commit 2056d5bbc7
7 changed files with 491 additions and 10 deletions
@@ -0,0 +1,64 @@
from datetime import date
from django.core.management import BaseCommand
from django.db import transaction
from gaehsnitz.models import Drink, Payment
PAYMENTS = [
# purpose, date, amount
("Toiletten", date(2026, 5, 6), 210.01),
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00),
("Band: Six Good Years", date(2026, 6, 12), 150.00),
("Band: Melo-Komplott", date(2026, 6, 12), 100.00),
("Band: Mörtel", date(2026, 6, 12), 150.00),
("Band: Kotpiloten", date(2026, 6, 13), 150.00),
("Band: Knast", date(2026, 6, 13), 150.00),
("Band: Quast", date(2026, 6, 13), 300.00),
]
DRINKS = [
# name, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
("Sterni", 12, 20, 0.5, 10.99, 3.10, 2.00),
("Krosti", 5, 20, 0.5, 16.49, 3.10, 2.50),
("Buddi", 5, 20, 0.5, 20.99, 3.10, 2.50),
("Helles", 5, 20, 0.5, 15.99, 4.50, 2.50),
("Radler", 2, 20, 0.5, 14.99, 3.10, 2.50),
("Lübzer 0,0", 1, 20, 0.5, 17.99, 3.10, 2.50),
("Freiberger 0,0", 4, 20, 0.5, 15.49, 3.10, 2.50),
("Mate", 2, 20, 0.5, 17.49, 4.50, 2.50),
("Vita Cola", 2, 12, 1.0, 10.99, 3.30, 2.50),
("Spezi", 2, 20, 0.5, 17.99, 3.10, 2.50),
("Wasser", 10, 12, 1.0, 5.99, 3.30, 1.50),
]
class Command(BaseCommand):
help = "Seed Drink rows for the 2026 festival from the supplier invoice."
@transaction.atomic
def handle(self, *args, **options):
for purpose, day, amount in PAYMENTS:
obj, created = Payment.objects.update_or_create(
purpose=purpose,
date=day,
defaults={"amount": amount},
)
self.stdout.write(f"{'created' if created else 'updated'}: {obj.purpose} ({obj.date})")
for name, crates, btl, size, price, deposit, sale in DRINKS:
obj, created = Drink.objects.update_or_create(
name=name,
year=2026,
defaults={
"crates_ordered": crates,
"crates_purchased": crates,
"crates_returned": 0,
"purchase_price_per_crate": price,
"deposit_per_crate": deposit,
"bottles_per_crate": btl,
"bottle_size": size,
"sale_price_per_bottle": sale,
},
)
self.stdout.write(f"{'created' if created else 'updated'}: {obj.name}")
+65 -2
View File
@@ -88,7 +88,9 @@ label {
font-size: 0.95rem;
}
input[type="text"] {
input[type="text"],
input[type="number"],
select {
background-color: rgba(80, 40, 10, 0.4);
color: #EEEEEE;
border: 2px solid #885522;
@@ -96,9 +98,24 @@ input[type="text"] {
padding: 14px 12px;
font-size: 1.1rem;
width: 100%;
min-height: 52px;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
font-family: inherit;
}
input[type="text"]:focus {
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path fill='%23EE9933' d='M0 0l6 8 6-8z'/></svg>");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 12px 8px;
padding-right: 40px;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus {
outline: none;
border-color: #EE9933;
}
@@ -377,3 +394,49 @@ section {
background-color: rgba(80, 40, 10, 0.4);
color: #FFCC77;
}
.progress-wrap {
position: relative;
width: 100%;
height: 28px;
background-color: rgba(80, 40, 10, 0.4);
border: 2px solid #885522;
border-radius: 6px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #EE9933;
box-shadow: 0 0 12px #CC6611;
transition: width 200ms ease;
}
.progress-label {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #161616;
font-weight: bold;
font-size: 0.95rem;
mix-blend-mode: screen;
}
.dash-row {
grid-template-columns: 1fr auto auto;
}
.link-btn-paypal {
background-color: #003087;
color: #FFFFFF;
}
.link-btn-paypal:hover, .link-btn-paypal:focus {
background-color: #0070BA;
color: #FFFFFF;
}
+160 -8
View File
@@ -2,6 +2,7 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
@@ -13,7 +14,7 @@ from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods
from gaehsnitz.models import Consumption, Drink
from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment, current_year
User = get_user_model()
@@ -60,17 +61,24 @@ def _normalize_name(raw):
def _tab_context(user):
consumption = user.consumption_list.select_related("drink").order_by("-created_at", "-id")
paid = (
user.paid_drinks.annotate(cost=F("amount") * F("drink__sale_price_per_bottle")).aggregate(total=Sum("cost"))[
"total"
]
year = current_year()
consumption = user.consumption_list.filter(drink__year=year).select_related("drink").order_by("-created_at", "-id")
total = (
user.paid_drinks.filter(drink__year=year)
.annotate(cost=F("amount") * F("drink__sale_price_per_bottle"))
.aggregate(total=Sum("cost"))["total"]
or 0
)
payments = user.user_payments.filter(created_at__year=year).order_by("-created_at")
paid = payments.aggregate(sum=Sum("amount"))["sum"] or 0
return {
"tab_user": user,
"consumption_list": consumption,
"total": paid,
"total": total,
"paid": paid,
"open_balance": total - paid,
"user_payments": payments,
"payment_methods": UserPayment.Method.choices,
}
@@ -165,7 +173,7 @@ def pin_view(request):
@require_http_methods(["GET"])
def me_view(request):
phase = _require_open(request)
drinks = Drink.objects.order_by("name") if phase == "booking" else Drink.objects.none()
drinks = Drink.objects.filter(year=current_year()).order_by("name") if phase == "booking" else Drink.objects.none()
booked_drink = None
booked_id = request.GET.get("booked")
if booked_id:
@@ -180,6 +188,7 @@ def me_view(request):
"drinks": drinks,
"current_day": _current_festival_day(),
"booked_drink": booked_drink,
"paid_toast": request.GET.get("paid") == "1",
}
)
return render(request, "suff/me.html", context)
@@ -208,6 +217,149 @@ def book_view(request):
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
@login_required
@require_http_methods(["GET", "POST"])
def pay_view(request):
phase = _require_open(request)
if phase == "before":
raise Http404
error = None
if request.method == "POST":
raw_amount = (request.POST.get("amount") or "").replace(",", ".").strip()
method = request.POST.get("method") or ""
note = (request.POST.get("note") or "").strip()[:64]
valid_methods = {m for m, _ in UserPayment.Method.choices}
try:
amount = float(raw_amount)
except ValueError:
amount = 0.0
if method not in valid_methods:
error = "Bitte eine Zahlungsmethode wählen."
elif amount <= 0:
error = "Bitte einen Betrag größer 0 eingeben."
else:
UserPayment.objects.create(
user=request.user,
amount=amount,
method=method,
note=note,
)
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
context = _tab_context(request.user)
context.update({"phase": phase, "error": error})
return render(request, "suff/pay.html", context)
DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
@staff_member_required
@require_http_methods(["GET"])
def dashboard_view(request):
year = current_year()
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
total_payments = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
total_balance = total_donations - total_payments
total_pct = int(round((total_donations / total_payments) * 100)) if total_payments else 0
total_pct_capped = min(total_pct, 100)
drinks = list(Drink.objects.filter(year=year).order_by("name"))
drink_rows = [
{
"name": d.name,
"sold": d.bottles_sold,
"total": d.bottles_total,
"balance": d.balance,
}
for d in drinks
]
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
drinks_profit = sum((d.balance for d in drinks), 0)
refinance_pct = int(round((sales_revenue / purchase_cost) * 100)) if purchase_cost else 0
refinance_pct_capped = min(refinance_pct, 100)
user_rows = []
for user in User.objects.all():
consumed = user.consumed_drinks_price
if not consumed:
continue
paid = user.paid_amount
user_rows.append(
{
"username": user.username,
"consumed": consumed,
"paid": paid,
"open": consumed - paid,
}
)
user_rows.sort(key=lambda r: r["open"], reverse=True)
top_spender = None
if user_rows:
top = max(user_rows, key=lambda r: r["consumed"])
top_spender = {"username": top["username"], "total": top["consumed"]}
drink_amounts = (
Consumption.objects.filter(drink__year=year, for_free=False)
.values("drink__name")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
top_drink = None
if drink_amounts:
top_drink = {"name": drink_amounts[0]["drink__name"], "amount": drink_amounts[0]["amount"]}
day_amounts = (
Consumption.objects.filter(drink__year=year, for_free=False)
.values("day")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
busiest_day = None
if day_amounts:
d = day_amounts[0]
busiest_day = {"label": DAY_LABELS.get(d["day"], "?"), "amount": d["amount"]}
top_per_day = []
for day_num, label in DAY_LABELS.items():
rows = (
Consumption.objects.filter(drink__year=year, day=day_num, for_free=False)
.values("user__username")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
if rows:
r = rows[0]
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
context = {
"year": year,
"total_donations": total_donations,
"total_payments": total_payments,
"total_balance": total_balance,
"total_pct": total_pct,
"total_pct_capped": total_pct_capped,
"drink_rows": drink_rows,
"sales_revenue": sales_revenue,
"purchase_cost": purchase_cost,
"drinks_profit": drinks_profit,
"refinance_pct": refinance_pct,
"refinance_pct_capped": refinance_pct_capped,
"user_rows": user_rows,
"top_spender": top_spender,
"top_drink": top_drink,
"busiest_day": busiest_day,
"top_per_day": top_per_day,
}
return render(request, "suff/dashboard.html", context)
@require_http_methods(["POST"])
def logout_view(request):
_require_open(request)
+4
View File
@@ -2,9 +2,11 @@ from django.urls import path
from gaehsnitz.suff import (
book_view,
dashboard_view,
logout_view,
me_view,
name_view,
pay_view,
pin_view,
)
@@ -15,5 +17,7 @@ urlpatterns = [
path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"),
path("book/", book_view, name="book"),
path("pay/", pay_view, name="pay"),
path("dashboard/", dashboard_view, name="dashboard"),
path("logout/", logout_view, name="logout"),
]
+102
View File
@@ -0,0 +1,102 @@
{% extends "suff/base.html" %}
{% load money %}
{% block content %}
<h2>Dashboard {{ year }}</h2>
<section>
<h3>Refinanzierung gesamt</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ total_pct_capped }}%;"></div>
<span class="progress-label">{{ total_pct }}%</span>
</div>
<p class="muted-left">
Spenden {{ total_donations|euro }} / Ausgaben {{ total_payments|euro }}
</p>
<p class="muted-left">
Bilanz: <b>{{ total_balance|euro }}</b>
</p>
</section>
<section>
<h3>Refinanzierung Getränke</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ refinance_pct_capped }}%;"></div>
<span class="progress-label">{{ refinance_pct }}%</span>
</div>
<p class="muted-left">
Verkaufserlös {{ sales_revenue|euro }} / Einkaufspreis {{ purchase_cost|euro }}
</p>
<p class="muted-left">
Aktueller Gewinn: <b>{{ drinks_profit|euro }}</b>
</p>
</section>
<section>
<h3>Getränke</h3>
<ul class="history">
{% for d in drink_rows %}
<li class="dash-row">
<span class="hist-what"><b>{{ d.name }}</b></span>
<span class="hist-when">{{ d.sold }}/{{ d.total }} verkauft</span>
<span class="hist-price">{{ d.balance|euro }}</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Offene Beträge</h3>
{% if user_rows %}
<ul class="history">
{% for u in user_rows %}
<li>
<span class="hist-when">{{ u.username }}</span>
<span class="hist-what">{{ u.consumed|euro }} {{ u.paid|euro }}</span>
<span class="hist-price">{{ u.open|euro }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted-left">Niemand hat etwas konsumiert.</p>
{% endif %}
</section>
<section>
<h3>Fun Facts</h3>
<ul class="history">
{% if top_spender %}
<li>
<span class="hist-when">Top-Zecher</span>
<span class="hist-what">{{ top_spender.username }}</span>
<span class="hist-price">{{ top_spender.total|euro }}</span>
</li>
{% endif %}
{% if top_drink %}
<li>
<span class="hist-when">Top-Getränk</span>
<span class="hist-what">{{ top_drink.name }}</span>
<span class="hist-price">{{ top_drink.amount }}x</span>
</li>
{% endif %}
{% if busiest_day %}
<li>
<span class="hist-when">Härtester Tag</span>
<span class="hist-what">{{ busiest_day.label }}</span>
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
</li>
{% endif %}
{% for f in top_per_day %}
<li>
<span class="hist-when">{{ f.label }}</span>
<span class="hist-what">{{ f.username }}</span>
<span class="hist-price">{{ f.amount }} Flaschen</span>
</li>
{% endfor %}
</ul>
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}
+22
View File
@@ -13,11 +13,27 @@
</div>
{% endif %}
{% if paid_toast %}
<div class="toast" role="status">
Zahlung gespeichert. Danke!
</div>
{% endif %}
<section class="total-box">
<span class="total-label">Deine Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
{% endif %}
</section>
{% if open_balance > 0 %}
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Bezahlen</a></p>
{% endif %}
{% if phase == "booking" %}
<section>
<h3>Neues Getränk buchen</h3>
@@ -66,6 +82,12 @@
{% endif %}
</section>
{% if request.user.is_staff %}
<div class="link-row">
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
</div>
{% endif %}
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
{% csrf_token %}
<button type="submit" class="btn-secondary">Logout</button>
+74
View File
@@ -0,0 +1,74 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Bezahlen</h2>
<p>
Bitte zahle deinen offenen Betrag mit deiner bevorzugten Methode
(z.&nbsp;B. Bar an der Kasse oder PayPal an Flo) und trage
den bezahlten Betrag anschließend hier ein.
</p>
<div class="link-row">
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
PayPal öffnen
</a>
</div>
<section class="total-box">
<span class="total-label">Deine Rechnung</span>
<span class="total-value">{{ total|floatformat:2 }} €</span>
{% if paid %}
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
{% endif %}
<span class="total-label">Offen</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
</section>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="{% url 'suff:pay' %}" class="pay-form">
{% csrf_token %}
<label>
Betrag (€)
<input type="number" name="amount" step="0.01" min="0.01"
value="{{ open_balance|floatformat:2 }}" required />
</label>
<label>
Methode
<select name="method" required>
<option value="">— wählen —</option>
{% for value, label in payment_methods %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
Notiz (optional)
<input type="text" name="note" maxlength="64" />
</label>
<button type="submit" class="btn-primary">Zahlung eintragen</button>
</form>
{% if user_payments %}
<section>
<h3>Bisherige Zahlungen</h3>
<ul class="history">
{% for p in user_payments %}
<li>
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} {{ p.note }}{% endif %}</span>
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
{% endblock %}