Add free-drink toggle, payment deletion, donation flow, expanded dashboard

- Crew can mark bookings as free (Gratis checkbox) on staff_user page;
  free anonymous bookings skip the auto-payment.
- Users (and crew) can delete their UserPayments with a confirmation page.
- Pay page redesigned around "Eintrittsspende": quick-pick amount buttons
  (drinks rounded to 5 € + 10/15/20/25/30), "Nur Drinks" escape, intro
  text, type=text input to dodge browser locale formatting.
- Me page: always-visible donate button, intro text linking drinks +
  Eintrittsspende.
- Day cutoff at 06:00 Berlin: festival day rolls at 06:00, applied to
  current_festival_day plus Frühaufsteher/Nachtschwärmer/Goldene Stunde.
- Dashboard: single Finanzen section (income vs costs) with breakdown,
  fun facts grouped (Trinker / Getränke / Zeit / Geld / Gratis).
- Offene Beträge filtered to open > 0.
- Staff list shows "(das bist du)" disabled row for own user.
- Renamed seed_drinks_2026 -> seed_2026, added Baumarkt 194 € entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:50:25 +02:00
parent 50fc32c577
commit 51d079a467
12 changed files with 653 additions and 83 deletions
@@ -9,6 +9,7 @@ PAYMENTS = [
# purpose, date, amount
("Toiletten", date(2026, 5, 6), 210.01),
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00),
("Baumarkt", date(2026, 5, 23), 194.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),
@@ -34,7 +35,7 @@ DRINKS = [
class Command(BaseCommand):
help = "Seed Drink rows for the 2026 festival from the supplier invoice."
help = "Seed Drink and Payment rows for the 2026 festival."
@transaction.atomic
def handle(self, *args, **options):
+127
View File
@@ -150,11 +150,100 @@ button:active {
text-align: center;
}
.intro {
font-size: 0.95rem;
color: #CCCCCC;
line-height: 1.4;
margin-bottom: 8px;
}
.intro strong {
color: #FFCC77;
}
.amount-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.amount-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 68px;
padding: 10px 8px;
border: 2px solid #885522;
border-radius: 6px;
background: #1f1f1f;
color: #FFCC77;
text-decoration: none;
transition: background-color 100ms ease, border-color 100ms ease;
}
.amount-main {
font-size: 1.2rem;
font-weight: bold;
line-height: 1.1;
}
.amount-sub {
font-size: 0.8rem;
color: #AAAAAA;
line-height: 1.1;
text-align: center;
}
.amount-btn:hover, .amount-btn:focus {
border-color: #EE9933;
background: #2a1f10;
}
.amount-btn-active {
border-color: #EE9933;
background: #2a1f10;
color: #EE9933;
}
.amount-btn-weak {
border-color: #553311;
background: #181818;
color: #AAAAAA;
opacity: 0.85;
}
.amount-btn-weak .amount-sub {
color: #888888;
}
.amount-link {
color: #FFCC77;
text-decoration: underline;
}
.muted-left {
font-size: 0.9rem;
color: #AAAAAA;
}
.user-self {
color: #888888;
cursor: not-allowed;
}
.muted-inline {
color: #888888;
font-size: 0.85rem;
font-style: italic;
}
.history-sub {
margin-top: 18px;
opacity: 0.85;
}
h3 {
font-size: 1.1rem;
font-weight: normal;
@@ -191,6 +280,40 @@ section {
text-shadow: 0 0 12px #CC6611;
}
.for-free-toggle {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px 20px;
margin-bottom: 12px;
min-height: 52px;
border: 2px solid #885522;
border-radius: 6px;
background: #1f1f1f;
color: #FFCC77;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
user-select: none;
transition: background-color 100ms ease, border-color 100ms ease;
}
.for-free-toggle input {
width: 22px;
height: 22px;
accent-color: #EE9933;
margin: 0;
cursor: pointer;
}
.for-free-toggle:has(input:checked) {
border-color: #EE9933;
background: #2a1f10;
color: #EE9933;
}
.drink-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -530,6 +653,10 @@ section {
grid-template-columns: 1fr auto auto;
}
.history li.fin-row {
grid-template-columns: 1fr auto;
}
.link-btn-paypal {
background-color: #003087;
color: #FFFFFF;
+227 -22
View File
@@ -1,4 +1,5 @@
from datetime import datetime
import math
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
@@ -6,7 +7,7 @@ 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
from django.db.models import Case, F, IntegerField, Sum, Value, When
from django.db.models import Case, Count, F, IntegerField, Sum, Value, When
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
@@ -28,6 +29,12 @@ BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
DAY_CUTOFF_HOUR = 6
def _shifted_local(dt):
"""Local Berlin time minus 6h — so 05:59 belongs to previous calendar day."""
return dt.astimezone(BERLIN) - timedelta(hours=DAY_CUTOFF_HOUR)
def _now():
@@ -54,7 +61,8 @@ def _require_open(request):
def _current_festival_day():
weekday = _now().weekday()
# Day rolls at 06:00 Berlin: bookings before 06:00 count as previous day.
weekday = _shifted_local(timezone.now()).weekday()
return DAY_BY_WEEKDAY.get(weekday, 4)
@@ -314,6 +322,31 @@ def delete_consumption_view(request, consumption_id):
)
@login_required
@require_http_methods(["GET", "POST"])
def delete_payment_view(request, payment_id):
redirect, phase = _require_open(request)
if redirect:
return redirect
if phase != "booking":
raise Http404
try:
payment = UserPayment.objects.get(pk=payment_id, user=request.user)
except UserPayment.DoesNotExist:
raise Http404
if request.method == "POST":
payment.delete()
return HttpResponseRedirect(reverse("suff:pay"))
return render(
request,
"suff/confirm_delete_payment.html",
{"phase": phase, "payment": payment},
)
@login_required
@require_http_methods(["GET", "POST"])
def pay_view(request):
@@ -347,7 +380,33 @@ def pay_view(request):
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
context = _tab_context(request.user)
context.update({"phase": phase, "error": error})
open_balance = max(float(context["open_balance"]), 0.0)
drinks_rounded = int(math.ceil(open_balance / 5.0) * 5) if open_balance > 0 else 0
suggestions = [
{"amount": drinks_rounded + d, "entry": round(drinks_rounded + d - open_balance, 2)}
for d in (10, 15, 20, 25, 30)
]
prefill_raw = request.GET.get("amount")
try:
prefill_amount = float(prefill_raw) if prefill_raw else None
if prefill_amount is not None and prefill_amount <= 0:
prefill_amount = None
except ValueError:
prefill_amount = None
if prefill_amount is None:
prefill_amount = float(suggestions[2]["amount"])
prefill_value = f"{prefill_amount:.2f}"
context.update(
{
"phase": phase,
"error": error,
"drinks_rounded": drinks_rounded,
"suggestions": suggestions,
"prefill_amount": prefill_amount,
"prefill_value": prefill_value,
"open_balance_url": f"{open_balance:.2f}",
}
)
return render(request, "suff/pay.html", context)
@@ -468,6 +527,7 @@ def staff_user_view(request, username):
"drinks": drinks,
"current_day": _current_festival_day(),
"booked_drink": booked_drink,
"booked_free": request.GET.get("free") == "1",
"paid_toast": request.GET.get("paid") == "1",
"is_anonymous_target": _is_anonymous(target),
}
@@ -486,6 +546,7 @@ def staff_book_view(request, username):
target = _get_staff_target(username)
drink_id = request.POST.get("drink_id")
for_free = request.POST.get("for_free") == "1"
try:
drink = Drink.objects.get(pk=int(drink_id))
except (Drink.DoesNotExist, TypeError, ValueError):
@@ -496,16 +557,17 @@ def staff_book_view(request, username):
drink=drink,
amount=1,
day=_current_festival_day(),
for_free=False,
for_free=for_free,
)
if _is_anonymous(target):
if _is_anonymous(target) and not for_free:
UserPayment.objects.create(
user=target,
amount=drink.sale_price_per_bottle,
method=UserPayment.Method.cash,
note=f"Auto: {drink.name}",
)
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}")
suffix = "&free=1" if for_free else ""
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}{suffix}")
@_staff_required
@@ -549,6 +611,32 @@ def staff_pay_view(request, username):
return render(request, "suff/staff_pay.html", context)
@_staff_required
@require_http_methods(["GET", "POST"])
def staff_delete_payment_view(request, username, payment_id):
redirect, phase = _require_open(request)
if redirect:
return redirect
if phase != "booking":
raise Http404
target = _get_staff_target(username)
try:
payment = UserPayment.objects.get(pk=payment_id, user=target)
except UserPayment.DoesNotExist:
raise Http404
if request.method == "POST":
payment.delete()
return HttpResponseRedirect(reverse("suff:staff_pay", args=[username]))
return render(
request,
"suff/staff_confirm_delete_payment.html",
{"phase": phase, "payment": payment, "tab_user": target},
)
@_staff_required
@require_http_methods(["GET", "POST"])
def staff_delete_consumption_view(request, username, consumption_id):
@@ -597,10 +685,8 @@ 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)
total_costs = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
user_payments_total = UserPayment.objects.filter(created_at__year=year).aggregate(s=Sum("amount"))["s"] or 0
drinks = list(Drink.objects.filter(year=year).order_by("name"))
drink_rows = [
@@ -614,9 +700,14 @@ def dashboard_view(request):
]
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)
free_drinks_value = sum((d.giveaway_purchase_value for d in drinks), 0)
unsold_purchase_value = sum((d.remaining_purchase_value for d in drinks), 0)
unsold_sale_value = sum((d.bottles_remaining * d.sale_price_per_bottle for d in drinks), 0)
income_total = total_donations + user_payments_total
finance_balance = income_total - total_costs
finance_pct = int(round((income_total / total_costs) * 100)) if total_costs else 0
finance_pct_capped = min(finance_pct, 100)
user_rows = []
for user in User.objects.all():
@@ -624,12 +715,15 @@ def dashboard_view(request):
if not consumed:
continue
paid = user.paid_amount
open_amount = consumed - paid
if open_amount <= 0:
continue
user_rows.append(
{
"username": user.username,
"consumed": consumed,
"paid": paid,
"open": consumed - paid,
"open": open_amount,
}
)
user_rows.sort(key=lambda r: r["open"], reverse=True)
@@ -672,24 +766,135 @@ def dashboard_view(request):
r = rows[0]
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
cons_qs = Consumption.objects.filter(drink__year=year)
paid_cons_qs = cons_qs.filter(for_free=False)
# Time facts
first_cons = cons_qs.exclude(created_at__isnull=True).order_by("created_at").select_related("user", "drink").first()
last_cons = cons_qs.exclude(created_at__isnull=True).order_by("-created_at").select_related("user", "drink").first()
# Day rolls at 06:00 Berlin: 05:59 counts as late "previous day", 07:00 = early "today".
# Compute time-of-day relative to 06:00 cutoff.
earliest_per_user = None
latest_per_user = None
earliest_key = None
latest_key = None
hour_buckets = {}
for c in cons_qs.exclude(created_at__isnull=True).values("user__username", "created_at", "amount"):
local = c["created_at"].astimezone(BERLIN)
# minutes after 06:00 cutoff (0 = 06:00, 1439 = 05:59 next morning)
shifted = (local.hour * 60 + local.minute - DAY_CUTOFF_HOUR * 60) % (24 * 60)
if earliest_key is None or shifted < earliest_key:
earliest_key = shifted
earliest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
if latest_key is None or shifted > latest_key:
latest_key = shifted
latest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
hour_buckets[local.hour] = hour_buckets.get(local.hour, 0) + (c["amount"] or 0)
golden_hour = None
if hour_buckets:
top_hour, top_amount = max(hour_buckets.items(), key=lambda kv: kv[1])
golden_hour = {
"label": f"{top_hour:02d} {(top_hour + 1) % 24:02d} Uhr",
"amount": top_amount,
}
# Drink mix / variety
variety_rows = (
paid_cons_qs.values("user__username")
.annotate(distinct=Count("drink", distinct=True))
.order_by("-distinct")
)
variety_champ = variety_rows[0] if variety_rows and variety_rows[0]["distinct"] > 1 else None
cat_rows = (
paid_cons_qs.values("drink__category")
.annotate(amount=Sum("amount"))
)
cat_total = sum(r["amount"] for r in cat_rows) or 0
beer_amount = sum(r["amount"] for r in cat_rows if r["drink__category"] == "beer")
beer_share = int(round(beer_amount / cat_total * 100)) if cat_total else 0
def _top_in_categories(cats, label):
rows = (
paid_cons_qs.filter(drink__category__in=cats)
.values("user__username")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
if rows:
return {"label": label, "username": rows[0]["user__username"], "amount": rows[0]["amount"]}
return None
alcfree_top = _top_in_categories(["alc_free_beer", "alc_free_radler"], "Alkoholfrei-Held")
radler_top = _top_in_categories(["radler"], "Radler-Fan")
water_top = _top_in_categories(["water"], "Wassertrinker")
# Money / behavior
overpay_rows = []
for user in User.objects.all():
consumed = user.consumed_drinks_price or 0
paid = user.paid_amount or 0
if paid > consumed and consumed > 0:
overpay_rows.append({"username": user.username, "tip": paid - consumed})
overpay_rows.sort(key=lambda r: r["tip"], reverse=True)
biggest_tip = overpay_rows[0] if overpay_rows else None
total_entry = sum(r["tip"] for r in overpay_rows)
method_rows = (
UserPayment.objects.filter(created_at__year=year)
.values("method")
.annotate(s=Sum("amount"))
)
method_split = {r["method"]: r["s"] for r in method_rows}
# Free drinks
free_recipient_rows = (
cons_qs.filter(for_free=True)
.values("user__username")
.annotate(amount=Sum("amount"))
.order_by("-amount")
)
top_free_recipient = free_recipient_rows[0] if free_recipient_rows else None
free_total_count = cons_qs.filter(for_free=True).aggregate(s=Sum("amount"))["s"] or 0
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,
"total_costs": total_costs,
"user_payments_total": user_payments_total,
"income_total": income_total,
"finance_balance": finance_balance,
"finance_pct": finance_pct,
"finance_pct_capped": finance_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,
"free_drinks_value": free_drinks_value,
"unsold_purchase_value": unsold_purchase_value,
"unsold_sale_value": unsold_sale_value,
"user_rows": user_rows,
"top_spender": top_spender,
"top_drink": top_drink,
"busiest_day": busiest_day,
"top_per_day": top_per_day,
"first_cons": first_cons,
"last_cons": last_cons,
"earliest_per_user": earliest_per_user,
"latest_per_user": latest_per_user,
"golden_hour": golden_hour,
"variety_champ": variety_champ,
"beer_share": beer_share,
"alcfree_top": alcfree_top,
"radler_top": radler_top,
"water_top": water_top,
"biggest_tip": biggest_tip,
"total_entry": total_entry,
"method_split": method_split,
"top_free_recipient": top_free_recipient,
"free_total_count": free_total_count,
}
return render(request, "suff/dashboard.html", context)
+8
View File
@@ -5,6 +5,7 @@ from gaehsnitz.suff import (
closed_view,
dashboard_view,
delete_consumption_view,
delete_payment_view,
logout_view,
me_view,
name_view,
@@ -12,6 +13,7 @@ from gaehsnitz.suff import (
pin_view,
staff_book_view,
staff_delete_consumption_view,
staff_delete_payment_view,
staff_index_view,
staff_pay_view,
staff_pin_reset_view,
@@ -29,6 +31,7 @@ urlpatterns = [
path("book/", book_view, name="book"),
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
path("pay/", pay_view, name="pay"),
path("pay/<int:payment_id>/delete/", delete_payment_view, name="delete_payment"),
path("dashboard/", dashboard_view, name="dashboard"),
path("staff/", staff_index_view, name="staff_index"),
path("staff/new/", staff_register_view, name="staff_register"),
@@ -36,6 +39,11 @@ urlpatterns = [
path("staff/u/<str:username>/pin/", staff_pin_reset_view, name="staff_pin_reset"),
path("staff/u/<str:username>/book/", staff_book_view, name="staff_book"),
path("staff/u/<str:username>/pay/", staff_pay_view, name="staff_pay"),
path(
"staff/u/<str:username>/pay/<int:payment_id>/delete/",
staff_delete_payment_view,
name="staff_delete_payment",
),
path(
"staff/u/<str:username>/book/<int:consumption_id>/delete/",
staff_delete_consumption_view,
@@ -0,0 +1,21 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Zahlung löschen?</h2>
<p>
Willst du wirklich diese Zahlung löschen?
</p>
<section class="total-box">
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} {{ payment.note }}{% endif %}</span>
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
</section>
<form method="post" action="{% url 'suff:delete_payment' payment.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:pay' %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
+175 -28
View File
@@ -5,31 +5,55 @@
<h2>Dashboard {{ year }}</h2>
<section>
<h3>Refinanzierung gesamt</h3>
<h3>Finanzen</h3>
<div class="progress-wrap">
<div class="progress-bar" style="width: {{ total_pct_capped }}%;"></div>
<span class="progress-label">{{ total_pct }}%</span>
<div class="progress-bar" style="width: {{ finance_pct_capped }}%;"></div>
<span class="progress-label">{{ finance_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>
<ul class="history">
<li class="fin-row">
<span class="hist-what"><b>Ausgaben gesamt</b></span>
<span class="hist-price"><b>{{ total_costs|euro }}</b></span>
</li>
<li class="fin-row">
<span class="hist-what"><b>Einnahmen gesamt</b></span>
<span class="hist-price"><b>{{ income_total|euro }}</b></span>
</li>
<li class="fin-row">
<span class="hist-what"><b>Bilanz</b></span>
<span class="hist-price"><b>{{ finance_balance|euro }}</b></span>
</li>
</ul>
<ul class="history history-sub">
<li class="fin-row">
<span class="hist-what">Spenden (Donations)</span>
<span class="hist-price">{{ total_donations|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Zahlungen (User-Tab)</span>
<span class="hist-price">{{ user_payments_total|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Einkaufspreis Getränke</span>
<span class="hist-price">{{ purchase_cost|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Verkaufserlös Getränke</span>
<span class="hist-price">{{ sales_revenue|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Gratis-Getränke (EK-Wert)</span>
<span class="hist-price">{{ free_drinks_value|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Unverkauft (EK-Wert)</span>
<span class="hist-price">{{ unsold_purchase_value|euro }}</span>
</li>
<li class="fin-row">
<span class="hist-what">Unverkauft (potenzieller VK)</span>
<span class="hist-price">{{ unsold_sale_value|euro }}</span>
</li>
</ul>
</section>
<section>
@@ -63,7 +87,7 @@
</section>
<section>
<h3>Fun Facts</h3>
<h3>Fun Facts: Trinker</h3>
<ul class="history">
{% if top_spender %}
<li>
@@ -72,6 +96,26 @@
<span class="hist-price">{{ top_spender.total|euro }}</span>
</li>
{% endif %}
{% if variety_champ %}
<li>
<span class="hist-when">Vielfalt-Champion</span>
<span class="hist-what">{{ variety_champ.user__username }}</span>
<span class="hist-price">{{ variety_champ.distinct }} Sorten</span>
</li>
{% endif %}
{% for f in top_per_day %}
<li>
<span class="hist-when">Top {{ f.label }}</span>
<span class="hist-what">{{ f.username }}</span>
<span class="hist-price">{{ f.amount }} Flaschen</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Fun Facts: Getränke</h3>
<ul class="history">
{% if top_drink %}
<li>
<span class="hist-when">Top-Getränk</span>
@@ -79,6 +123,73 @@
<span class="hist-price">{{ top_drink.amount }}x</span>
</li>
{% endif %}
<li>
<span class="hist-when">Bier-Anteil</span>
<span class="hist-what">am Konsum</span>
<span class="hist-price">{{ beer_share }}%</span>
</li>
{% if alcfree_top %}
<li>
<span class="hist-when">{{ alcfree_top.label }}</span>
<span class="hist-what">{{ alcfree_top.username }}</span>
<span class="hist-price">{{ alcfree_top.amount }} Flaschen</span>
</li>
{% endif %}
{% if radler_top %}
<li>
<span class="hist-when">{{ radler_top.label }}</span>
<span class="hist-what">{{ radler_top.username }}</span>
<span class="hist-price">{{ radler_top.amount }} Flaschen</span>
</li>
{% endif %}
{% if water_top %}
<li>
<span class="hist-when">{{ water_top.label }}</span>
<span class="hist-what">{{ water_top.username }}</span>
<span class="hist-price">{{ water_top.amount }} Flaschen</span>
</li>
{% endif %}
</ul>
</section>
<section>
<h3>Fun Facts: Zeit</h3>
<ul class="history">
{% if first_cons %}
<li>
<span class="hist-when">Erste Buchung</span>
<span class="hist-what">{{ first_cons.user.username }} {{ first_cons.drink.name }}</span>
<span class="hist-price">{{ first_cons.created_at|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if last_cons %}
<li>
<span class="hist-when">Letzter Schluck</span>
<span class="hist-what">{{ last_cons.user.username }} {{ last_cons.drink.name }}</span>
<span class="hist-price">{{ last_cons.created_at|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if earliest_per_user %}
<li>
<span class="hist-when">Frühaufsteher</span>
<span class="hist-what">{{ earliest_per_user.user__username }}</span>
<span class="hist-price">{{ earliest_per_user.t|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if latest_per_user %}
<li>
<span class="hist-when">Nachtschwärmer</span>
<span class="hist-what">{{ latest_per_user.user__username }}</span>
<span class="hist-price">{{ latest_per_user.t|date:"d.m. H:i" }}</span>
</li>
{% endif %}
{% if golden_hour %}
<li>
<span class="hist-when">Goldene Stunde</span>
<span class="hist-what">{{ golden_hour.label }}</span>
<span class="hist-price">{{ golden_hour.amount }} Flaschen</span>
</li>
{% endif %}
{% if busiest_day %}
<li>
<span class="hist-when">Härtester Tag</span>
@@ -86,16 +197,52 @@
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
</li>
{% endif %}
{% for f in top_per_day %}
</ul>
</section>
<section>
<h3>Fun Facts: Geld</h3>
<ul class="history">
{% if biggest_tip %}
<li>
<span class="hist-when">{{ f.label }}</span>
<span class="hist-what">{{ f.username }}</span>
<span class="hist-price">{{ f.amount }} Flaschen</span>
<span class="hist-when">Bigspender-Trinkgeld</span>
<span class="hist-what">{{ biggest_tip.username }}</span>
<span class="hist-price">{{ biggest_tip.tip|euro }}</span>
</li>
{% endif %}
<li>
<span class="hist-when">Eintrittsspenden gesamt</span>
<span class="hist-what">aus Überzahlungen</span>
<span class="hist-price">{{ total_entry|euro }}</span>
</li>
{% for method, sum in method_split.items %}
<li>
<span class="hist-when">Zahlungen {{ method }}</span>
<span class="hist-what"></span>
<span class="hist-price">{{ sum|euro }}</span>
</li>
{% endfor %}
</ul>
</section>
<section>
<h3>Fun Facts: Gratis</h3>
<ul class="history">
<li>
<span class="hist-when">Aufmerksamkeit des Hauses</span>
<span class="hist-what">Gratis-Flaschen</span>
<span class="hist-price">{{ free_total_count }}x</span>
</li>
{% if top_free_recipient %}
<li>
<span class="hist-when">Glücklicher Empfänger</span>
<span class="hist-what">{{ top_free_recipient.user__username }}</span>
<span class="hist-price">{{ top_free_recipient.amount }} gratis</span>
</li>
{% endif %}
</ul>
</section>
<div class="logout-form">
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
</div>
+8 -4
View File
@@ -3,6 +3,12 @@
{% block content %}
<h2>Hallo {{ tab_user.username }}</h2>
<p class="intro">
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
alles zusammen mit deiner <strong>Eintrittsspende</strong> bezahlen
bar oder per PayPal.
</p>
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}
@@ -26,9 +32,7 @@
{% endif %}
</section>
{% if open_balance > 0 %}
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Bezahlen</a></p>
{% endif %}
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Für Drinks und Eintritt spenden</a></p>
{% if phase == "booking" %}
<section>
@@ -84,7 +88,7 @@
{% if request.user.is_staff %}
<div class="link-row">
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Crew: Buchen für andere</a>
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Crew: Dashboard</a>
</div>
{% endif %}
+45 -16
View File
@@ -1,19 +1,8 @@
{% extends "suff/base.html" %}
{% load l10n %}
{% 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>
<h2>Spenden</h2>
<section class="total-box">
<span class="total-label">Deine Rechnung</span>
@@ -22,10 +11,41 @@
<span class="total-label">Bezahlt</span>
<span class="total-value">{{ paid|floatformat:2 }} €</span>
{% endif %}
<span class="total-label">Offen</span>
<span class="total-label">Offen (Drinks)</span>
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
</section>
<p class="intro">
Dein Beitrag deckt die <strong>Drinks</strong> und deine
<strong>Eintrittsspende</strong>. Die Vorschläge unten runden deinen
offenen Drink-Betrag auf die nächsten 5&nbsp;€ und legen 1030&nbsp;
Eintritt drauf.
</p>
<p class="intro">
Du darfst gerne <strong>weniger</strong> geben, wenn das gerade besser
passt kein Problem. Und wenn du <strong>mehr</strong> geben kannst,
hilft uns das sehr, die Kosten für Bands, Toiletten und Technik zu
decken. So oder so: <strong>danke, dass du da bist!</strong>
</p>
<section>
<h3>Vorschläge</h3>
<div class="amount-grid">
{% for s in suggestions %}
<a href="?amount={{ s.amount }}" class="amount-btn{% if prefill_amount == s.amount %} amount-btn-active{% endif %}">
<span class="amount-main">{{ s.amount }} €</span>
<span class="amount-sub">→ {{ s.entry|floatformat:2 }} € Eintritt</span>
</a>
{% endfor %}
{% if open_balance > 0 %}
<a href="?amount={{ open_balance_url }}" class="amount-btn amount-btn-weak">
<span class="amount-main">{{ open_balance|floatformat:2 }} €</span>
<span class="amount-sub">→ Nur Drinks</span>
</a>
{% endif %}
</div>
</section>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
@@ -34,8 +54,8 @@
{% csrf_token %}
<label>
Betrag (€)
<input type="number" name="amount" step="0.01" min="0.01"
value="{{ open_balance|floatformat:2 }}" required />
<input type="text" name="amount" inputmode="decimal" pattern="[0-9]+([.,][0-9]{1,2})?"
value="{% if prefill_value %}{{ prefill_value }}{% else %}{{ suggestions.0.amount }}{% endif %}" required />
</label>
<label>
Methode
@@ -53,6 +73,12 @@
<button type="submit" class="btn-primary">Zahlung eintragen</button>
</form>
<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>
{% if user_payments %}
<section>
<h3>Bisherige Zahlungen</h3>
@@ -62,6 +88,9 @@
<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>
{% if phase == "booking" %}
<a href="{% url 'suff:delete_payment' p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -0,0 +1,18 @@
{% extends "suff/base.html" %}
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Zahlung löschen für <span class="staff-target">{{ tab_user.username }}</span>?</h2>
<section class="total-box">
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} {{ payment.note }}{% endif %}</span>
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
</section>
<form method="post" action="{% url 'suff:staff_delete_payment' tab_user.username payment.id %}" class="confirm-actions">
{% csrf_token %}
<button type="submit" class="btn-danger">Ja, löschen</button>
<a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
</form>
{% endblock %}
+5 -1
View File
@@ -25,7 +25,11 @@
{% for u in users %}
<li>
<span class="hist-what">
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
{% if u.username == request.user.username %}
<span class="user-self">{{ u.username }} <span class="muted-inline">(das bist du)</span></span>
{% else %}
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
{% endif %}
</span>
</li>
{% endfor %}
+3
View File
@@ -51,6 +51,9 @@
<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>
{% if phase == "booking" %}
<a href="{% url 'suff:staff_delete_payment' tab_user.username p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
{% endif %}
</li>
{% endfor %}
</ul>
+14 -11
View File
@@ -2,11 +2,11 @@
{% block content %}
<p class="muted">Crew-Ansicht</p>
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span>{% if is_anonymous_target %} (Bar / Anonym){% endif %}</h2>
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span></h2>
{% if booked_drink %}
<div class="toast" role="status">
Gebucht: +1 {{ booked_drink.name }}{% if is_anonymous_target %} (bar bezahlt){% endif %}
Gebucht: +1 {{ booked_drink.name }}{% if booked_free %} (gratis){% elif is_anonymous_target %} (bar bezahlt){% endif %}
</div>
{% endif %}
@@ -38,19 +38,22 @@
{% if phase == "booking" %}
<section>
<h3>Getränk buchen</h3>
<div class="drink-grid">
{% for drink in drinks %}
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
{% csrf_token %}
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
<button type="submit" class="drink-btn drink-btn-{{ drink.category }}">
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
{% csrf_token %}
<label class="for-free-toggle">
<input type="checkbox" name="for_free" value="1" />
<span>Gratis (z.b. Artists am Spieltag)</span>
</label>
<div class="drink-grid">
{% for drink in drinks %}
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
<span class="drink-plus">+1</span>
<span class="drink-name">{{ drink.name }}</span>
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
</button>
</form>
{% endfor %}
</div>
{% endfor %}
</div>
</form>
</section>
{% endif %}