Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms

Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
This commit is contained in:
2026-07-16 16:44:37 +02:00
parent 83caa233d7
commit 127d0e338e
25 changed files with 873 additions and 417 deletions

View File

@@ -1,40 +0,0 @@
{% load lucide ui %}
{% comment %}
The shell every billing form uses: card, fields, cancel + submit. Included with
`heading`, `blurb`, `submit_label`, `submit_icon` and `cancel_url`.
{% endcomment %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
{% if blurb %}<p class="text-sm opacity-70">{{ blurb }}</p>{% endif %}
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 block text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{{ cancel_url }}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,43 @@
{% load lucide %}
{% comment %}
Club-scoped admins, and the form to add one. Included with `club`, `admins` already
in context.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_admin_add' club.pk %}">{% lucide "user-plus" size=16 %} Add admin</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-error btn-outline btn-sm gap-1" type="submit">{% lucide "trash-2" size=14 %} Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,124 @@
{% load lucide ui %}
{% comment %}
What the platform bills this club: plan, periods, and the modals for changing plan,
opening a period, and recording a payment. Included with `club`, `subscription`,
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</button>
{% if subscription %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</button>
{% endif %}
</div>
</div>
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
{% else %}
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
{% endif %}
{% if subscription.auto_archive %}
Archived automatically when a period goes unpaid past its grace period.
{% else %}
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Period</th>
<th class="text-right">Billed</th>
<th class="text-right">Paid</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for due in dues %}
<tr>
<td>
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
<td class="text-right">
{% if due.status == "paid" %}
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
{% elif due.status == "waived" %}
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
{% elif due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right flex flex-row gap-2 justify-end">
{% if due.is_owing %}
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
{% if not due.payments.all %}
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
</form>
{% endif %}
{% endif %}
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% for payment in due.payments.all %}
<tr class="text-xs opacity-70">
<td colspan="2" class="pl-8">
{% lucide "corner-down-right" size=12 %}
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
</td>
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
<td colspan="2"></td>
</tr>
{% endfor %}
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in dues %}
{% if due.is_owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %}
{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
{% if subscription %}
{% url 'controlpanel:club_open_period' club.pk as open_period_url %}
{% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %}
{% endif %}

View File

@@ -0,0 +1,45 @@
{% load lucide %}
{% comment %}
Which feature flags apply to this club. Included with `club`, `flags` (from
`flags_for_club`) already in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -100,7 +100,7 @@
{% elif club.covered_until %}
<span class="badge badge-success">paid</span>
{% else %}
<span class="badge badge-ghost badge-outline">&mdash;</span>
-
{% endif %}
</div>
{% else %}

View File

@@ -0,0 +1,29 @@
{% load ui %}
{% comment %}
The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled
widget, help text, errors — with checkboxes laid out label-beside-input instead of
label-above. Included with `form`.
{% endcomment %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-xs block text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt text-xs mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}

View File

@@ -0,0 +1,28 @@
{% load lucide %}
{% comment %}
A daisyUI native <dialog> modal wrapping a Django form that posts straight to
`action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label`
and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form
tag (linked via the `form` attribute) so it can share the `modal-action` row with the
dialog-closing Cancel button without nesting one <form> inside another.
{% endcomment %}
<dialog id="{{ modal_id }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ title }}</h3>
{% if blurb %}<p class="py-2 text-sm opacity-70">{{ blurb }}</p>{% endif %}
<form method="post" action="{{ action_url }}" id="{{ modal_id }}-form">
{% csrf_token %}
{% include "controlpanel/_form_fields.html" %}
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} Cancel</button>
</form>
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:"Save" }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% load lucide %}
{% block heading %}Grant platform access{% endblock heading %}
@@ -11,24 +11,7 @@
</div>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2"><span>{{ error }}</span></div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}"><span class="label-text">{{ field.label }}</span></label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
{% include "controlpanel/_form_fields.html" %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:admins' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>

View File

@@ -1,28 +1,30 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% load lucide ui %}
{% block heading %}Billing{% endblock heading %}
{% block subheading %}<p class="text-sm opacity-70">What the platform charges its clubs.</p>{% endblock subheading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:tier_create' %}">{% lucide "plus" size=16 %} New tier</a>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('tier_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:tier_create' as tier_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Tiers</h2>
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
{% comment %}
Prices are dated, not edited. A rate change is a new row with a future
active_from; every period already opened keeps the amount it was billed at,
so raising the price cannot rewrite an invoice you have already sent.
{% endcomment %}
<p class="text-sm opacity-70">A rate change is a new dated price. Periods already billed keep the amount they were issued at.</p>
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Tier</th>
<th>Plan</th>
<th class="text-right">Clubs</th>
<th>Prices</th>
<th></th>
@@ -34,7 +36,8 @@
<td>
<div class="font-medium">{{ tier.name }}</div>
{% if not tier.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if tier.description %}<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
{% if tier.description %}
<div class="text-xs opacity-60">{{ tier.description }}</div>{% endif %}
</td>
<td class="text-right tabular-nums">{{ tier.club_count }}</td>
<td>
@@ -48,14 +51,14 @@
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
{% endfor %}
</td>
<td class="text-right">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_price_create' tier.pk %}">{% lucide "euro" size=14 %} New price</a>
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:tier_update' tier.pk %}">{% lucide "pencil" size=14 %} Edit</a>
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">No tiers yet.</td>
<td colspan="4" class="text-center opacity-60">No plans yet.</td>
</tr>
{% endfor %}
</tbody>
@@ -64,6 +67,15 @@
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for tier in tiers %}
{% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %}
{% url 'controlpanel:tier_update' tier.pk as tier_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
@@ -74,7 +86,7 @@
<th>Club</th>
<th>Period</th>
<th class="text-right">Owed</th>
<th>Status</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
@@ -90,18 +102,18 @@
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
<td>
<td class="text-right">
{% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right">
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Record payment</a>
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-text" size=14 %} Invoice</a>
<td class="text-right flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% empty %}
@@ -114,4 +126,10 @@
</div>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% endfor %}
{% endblock panel %}

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% load lucide %}
{% block heading %}Add an admin to {{ club }}{% endblock heading %}
@@ -11,21 +11,7 @@
</div>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
{% include "controlpanel/_form_fields.html" %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_detail' club.pk %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary" type="submit">Grant admin</button>

View File

@@ -181,183 +181,9 @@
</div>
{% endfor %}
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
<div class="flex flex-wrap gap-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:club_subscribe' club.pk %}">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</a>
{% if subscription %}
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_open_period' club.pk %}">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</a>
{% endif %}
</div>
</div>
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a tier to start.</p>
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.tier.name }}</strong>.
{% if subscription.auto_renew %}
Renews automatically 30 days before the period ends.
{% else %}
<span class="badge badge-warning badge-sm">Auto-renew off</span> — you must open each period by hand, or this club uses the platform for free.
{% endif %}
{% if subscription.auto_archive %}
Archived automatically when a period goes unpaid past its grace period.
{% else %}
<span class="badge badge-warning badge-sm">Auto-archive off</span> — it will never be archived for non-payment.
{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Period</th>
<th class="text-right">Billed</th>
<th class="text-right">Paid</th>
<th class="text-right">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for due in dues %}
<tr>
<td>
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
<td class="text-right">
{% if due.status == "paid" %}
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
{% elif due.status == "waived" %}
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
{% elif due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right">
{% if due.is_owing %}
<a class="btn btn-primary btn-xs gap-1" href="{% url 'controlpanel:due_pay' due.pk %}">{% lucide "banknote" size=14 %} Pay</a>
{% if not due.payments.all %}
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1" type="submit">{% lucide "ban" size=14 %} Waive</button>
</form>
{% endif %}
{% endif %}
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% for payment in due.payments.all %}
<tr class="text-xs opacity-70">
<td colspan="2" class="pl-8">
{% lucide "corner-down-right" size=12 %}
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
</td>
<td class="text-right tabular-nums">€{{ payment.amount|floatformat:2 }}</td>
<td colspan="2"></td>
</tr>
{% endfor %}
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
<a class="btn btn-primary btn-sm gap-2" href="{% url 'controlpanel:club_admin_add' club.pk %}">{% lucide "user-plus" size=16 %} Add admin</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-error btn-outline btn-sm gap-1" type="submit">{% lucide "trash-2" size=14 %} Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% endblock panel %}
{% block extra_body %}

View File

@@ -16,14 +16,21 @@
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
<div class="card-body">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2>
<div class="w-full">
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
{% if maintenance.is_active %}
<p class="text-sm">
<div class="flex flex-row gap-2 text-sm">
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}.
</p>
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% endif %}
<div>&middot;</div>
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
</div>
{% if maintenance.message %}
<div class="my-4">
<div class="font-semibold mb-1">Message</div>
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-70">
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
@@ -35,12 +42,12 @@
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
{% if not maintenance.is_active %}
<div class="form-control my-2 w-full max-w-xl">
<div class="form-control my-2 w-full pb-2">
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
<span class="label-text">{{ maintenance_form.message.label }}</span>
</label>
{{ maintenance_form.message|daisy }}
<span class="label-text-alt mt-1 block text-base-content/70">{{ maintenance_form.message.help_text }}</span>
<span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span>
</div>
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
{% else %}

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% load lucide %}
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %}
@@ -8,21 +8,7 @@
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
{% include "controlpanel/_form_fields.html" %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:features' %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>

View File

@@ -1,14 +0,0 @@
{% extends "controlpanel/base.html" %}
{% block heading %}Record payment — {{ due.club }}{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">
{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }} · €{{ due.amount|floatformat:2 }} billed · €{{ due.balance|floatformat:2 }} outstanding
</p>
{% endblock subheading %}
{% block panel %}
{% url 'controlpanel:club_detail' due.club.pk as club_url %}
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Record payment" submit_icon="banknote" blurb="Part payments are fine: they accumulate against the period until it is settled." %}
{% endblock panel %}

View File

@@ -1,12 +0,0 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.</p>
{% endblock subheading %}
{% block panel %}
{% url 'controlpanel:club_detail' club.pk as club_url %}
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Open period" submit_icon="calendar-plus" blurb="By default the period continues from the end of the last one, so a lapsed year is still owed. Pick a start date to forgive the gap." %}
{% endblock panel %}

View File

@@ -1,8 +0,0 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{% if subscription %}Change plan{% else %}Start billing{% endif %} — {{ club }}{% endblock heading %}
{% block panel %}
{% url 'controlpanel:club_detail' club.pk as club_url %}
{% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %}
{% endblock panel %}

View File

@@ -1,8 +0,0 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{% if object %}Edit {{ object }}{% else %}New tier{% endif %}{% endblock heading %}
{% block panel %}
{% url 'controlpanel:billing' as billing_url %}
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Save tier" submit_icon="layers" blurb="A tier has no price until you add one. A tier with no price cannot be billed." %}
{% endblock panel %}

View File

@@ -1,8 +0,0 @@
{% extends "controlpanel/base.html" %}
{% block heading %}New price for {{ tier }}{% endblock heading %}
{% block panel %}
{% url 'controlpanel:billing' as billing_url %}
{% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Add price" submit_icon="euro" blurb="Prices are dated, never edited: periods already opened keep the amount they were billed at, so this cannot rewrite an invoice you have already sent." %}
{% endblock panel %}