A Billing tab (tiers, their dated prices, and everything we are owed), a billing panel on each club (plan, periods, payment history, invoice), and the dues on the dashboard and the club tables. Every state change goes through the billing service, and a BillingError surfaces as a message rather than a 500 -- so "that period is waived", "no price in force", "already billed for that period" and a missing PDF library all explain themselves instead of crashing. The dashboard now separates the two pots of money that were previously one word. "Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is never ours -- sitting on our dashboard under a label that implied it was income. It is now "Platform dues per month" (what clubs paid us) with the club-shop series renamed club_revenue, and the club tables carry a Plan column and what each club owes us, annotated in the same single query. Rate changes are add-only in the UI as well as the model: the price form creates a dated row and never edits the last one, and a test asserts that raising the rate leaves an already-open period at the amount it was billed at. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
4.6 KiB
HTML
84 lines
4.6 KiB
HTML
{% load lucide %}
|
|
|
|
{% comment %}
|
|
The club table, shared by the dashboard and the clubs list so the two cannot drift apart.
|
|
Health, not vanity: a member total says nothing you can act on, while "no coach",
|
|
"nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and
|
|
fix. Every column is annotated by clubs_with_health() in a single query.
|
|
|
|
Expects: clubs (from clubs_with_health), and optionally empty_message.
|
|
{% endcomment %}
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Club</th>
|
|
<th class="text-right">Members</th>
|
|
<th class="text-right">Unpaid</th>
|
|
<th class="text-right">Owed</th>
|
|
<th>Plan</th>
|
|
<th class="text-right">Dues</th>
|
|
<th class="text-right">Teams</th>
|
|
<th class="text-right">Upcoming</th>
|
|
<th class="text-right">Admins</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for club in clubs %}
|
|
<tr>
|
|
<td>
|
|
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
|
<div class="mt-1 flex flex-wrap items-center gap-1">
|
|
<span class="text-xs opacity-60">{{ club.slug }}</span>
|
|
{% if club.is_archived %}
|
|
{% comment %}
|
|
An archived club's subdomain does not resolve, so "dormant" and
|
|
"no season" would be noise: of course nothing is scheduled.
|
|
{% endcomment %}
|
|
<span class="badge badge-warning badge-xs gap-1">{% lucide "archive" size=10 %} Archived</span>
|
|
{% else %}
|
|
{% if not club.has_season %}<span class="badge badge-warning badge-xs gap-1">{% lucide "calendar-x" size=10 %} No season</span>{% endif %}
|
|
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
|
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
|
|
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
|
|
<td>
|
|
{% if club.tier_name %}
|
|
<span class="text-sm">{{ club.tier_name }}</span>
|
|
{% else %}
|
|
<span class="badge badge-warning badge-xs">Not billed</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-right tabular-nums">
|
|
{% if not club.dues_owed %}
|
|
{% if club.tier_name %}<span class="badge badge-success badge-xs">Paid</span>{% endif %}
|
|
{% else %}
|
|
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
|
|
{% if club.dues_grace_until < today %}
|
|
<span class="badge badge-error badge-xs">Overdue</span>
|
|
{% elif club.dues_period_end < today %}
|
|
<span class="badge badge-warning badge-xs">Grace</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-right tabular-nums">
|
|
{{ club.team_count }}
|
|
{% if club.teams_without_coach %}
|
|
<span class="badge badge-error badge-xs ml-1" title="Teams with nobody able to pick the squad">{{ club.teams_without_coach }} no coach</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
|
|
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|