Add the management app: club-facing UI + real fee-payment tracking

Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
This commit is contained in:
2026-08-03 17:01:15 +02:00
parent 5bea8a4a6c
commit 062da00bb9
44 changed files with 5095 additions and 11 deletions

View File

@@ -0,0 +1,92 @@
{% load lucide ui i18n %}
{% comment %}
Shared by family_detail.html and member_detail.html's own Family panel -- one
table design for "everyone in this family", included with `group` (a dict from
management.views.group_by_family: family/guardians/children/others/all) and
`family_role_choices` (FamilyMembership.FamilyRole.choices, for the role dropdown).
`next_url`, if passed, is where a role change redirects back to -- member_detail.html
passes its own URL so admins land back on the member they were viewing; family_detail.html
leaves it unset, since staying on the family page is already the right place there.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Type" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for person in group.all %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}</a></td>
<td>{{ person.first_name }}</td>
<td>{{ person.contact_email|default:"-" }}</td>
<td>
{% if is_club_admin %}
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="role" class="select select-bordered select-sm" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}">
{% for value, label in family_role_choices %}
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
{% if person.grant_login_form %}
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
</button>
{% endif %}
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
{% lucide "user-x" size=14 %} {% trans "Remove" %}
</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}">
{% lucide "trash-2" size=14 %} {% trans "Delete" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% trans "Grant login" as grant_login_title %}
{% trans "Grant" as grant_login_submit_label %}
{% trans "They'll be able to sign in with this email once you confirm." as grant_login_blurb %}
{% with family_pk_str=group.family.pk|stringformat:"s" %}
{% for person in group.all %}
{% with person_pk_str=person.pk|stringformat:"s" %}
{% url 'management:member_delete' person.pk as member_delete_url %}
{% url 'management:member_detach_family' person.pk group.family.pk as detach_family_url %}
{% url 'management:member_grant_login' person.pk as grant_login_url %}
{% blocktrans with full_name=person.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% blocktrans with full_name=person.get_full_name family_name=group.family asvar detach_family_body %}Remove {{ full_name }} from family {{ family_name }}? They become a standalone member - nothing else about them changes.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% include "controlpanel/_confirm_modal.html" with modal_id="remove_family_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
{% if person.grant_login_form %}
{% include "controlpanel/_modal_form.html" with modal_id="grant_login_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=grant_login_title form=person.grant_login_form action_url=grant_login_url submit_label=grant_login_submit_label submit_icon="key-round" blurb=grant_login_blurb %}
{% endif %}
{% endwith %}
{% endfor %}
{% endwith %}
{% endif %}

View File

@@ -0,0 +1,33 @@
{% extends "management/base.html" %}
{% load i18n %}
{% comment %}
Shared placeholder for every entity that doesn't have its own list template yet
(see management.views.StubListMixin) -- a plain one-column table of __str__, just
enough to prove the query scoping and permission gate work. Not meant to stay this
bare once each section gets its own page.
{% endcomment %}
{% block heading %}{{ page_title }}{% endblock heading %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for object in object_list %}
<tr>
<td>{{ object }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">{% trans "Nothing here yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,49 @@
{% load i18n lucide %}
{% comment %}
The management nav, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
just so the nav never shows a link they can't follow.
`nav` (management.context_processors.active_nav_section) is the current page's
section, derived from the resolved URL name -- `menu-active` is daisyUI's active
state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
{% endcomment %}
<li>
<a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a>
</li>
<li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
{% endif %}
{% if is_club_admin %}
<li class="menu-title">{% trans "Club setup" %}</li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
{% endif %}
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% if is_club_admin %}
<li class="menu-title">{% trans "Shop" %}</li>
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
<li class="menu-title">{% trans "Forms" %}</li>
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
{% endif %}

View File

@@ -0,0 +1,42 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% comment %}
The club-staff shell: team managers, coaches and admins only (never parents or
players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's
block structure, extending the club's own skin instead of the platform's.
{% endcomment %}
{% block head_title %}
{% block section_title %}{% trans "Management" %}{% endblock section_title %}
{% endblock head_title %}
{% block menu %}
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
<ul class="menu w-full gap-1 p-3 mt-4">
{% include "management/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
{% block main %}
<div class="mb-6 flex flex-wrap flex-row items-center justify-between gap-3">
<div class="flex flex-col gap-2 grow">
<h1 class="text-3xl font-bold">
{% block heading %}{% trans "Management" %}{% endblock heading %}
</h1>
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
</div>
<div class="flex gap-2">
{% block actions %}{% endblock actions %}
</div>
</div>
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
{% include "management/_nav_items.html" %}
</ul>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load lucide i18n %}
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
{% block actions %}
{% if is_club_admin %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
{% include "management/_family_members_table.html" with group=group %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family.pk as add_parent_url %}
{% url 'management:family_add_child' family.pk as add_child_url %}
{% trans "Add parent" as add_parent_title %}
{% trans "Add child" as add_child_title %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,43 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Add family" %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<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 %}
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2>
<p class="text-sm opacity-70">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
{% form_field form.parent_email %}
</div>
<div class="divider"></div>
<h2 class="card-title text-base">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
<p class="text-sm opacity-70">{% trans "No login of their own -- the parent above manages things for them." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
{% form_field form.child_date_of_birth %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,245 @@
{% extends "management/base.html" %}
{% load i18n static lucide %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}{% trans "Management" %}{% endblock subheading %}
{% block panel %}
{% if attention.no_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>
{% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}
</span>
</div>
{% endif %}
{% comment %}
The club's own numbers that should be zero -- same attention/chart/stat-group
data controlpanel/club_detail.html shows a platform admin drilling into this
club from outside; club_attention/club_charts/club_statistics are already
club-scoped, so this is that same data for the club's own staff. Nothing
money-shaped renders below for non-admins, same line the nav already draws
around the Shop section.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">{% trans "Teams nobody can pick a squad for" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">{% trans "Active members on no team" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} {% trans "Pending" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">{% trans "Memberships awaiting approval" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} {% trans "New members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
<div class="text-xs opacity-60">{% trans "First season at this club" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} {% trans "Renewal rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.renewal_rate is None %}
{% trans "N/A" %}
{% else %}
{{ attention.renewal_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.renewal_rate is None %}
{% trans "No previous season" %}
{% else %}
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
max="100"></progress>
{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
{% trans "N/A" %}
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
{% trans "No events this season" %}
{% else %}
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for event in upcoming_events %}
<tr>
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
<td>{{ event.title }}</td>
<td>{{ event.get_kind_display|capfirst }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
<div class="h-56">
<canvas id="signups-chart"></canvas>
</div>
</div>
</div>
{% if is_club_admin %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</div>
</div>
{% endif %}
</div>
<div class="grid gap-4 md:grid-cols-4">
{% for group in groups %}
{% if group.title != "Shop" or is_club_admin %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endblock panel %}
{% block extra_body %}
{% trans "New" as new_label %}
{% trans "Returning" as returning_label %}
{{ charts|json_script:"chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const feesCanvas = document.getElementById("fees-chart");
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
// Stacked: the bar height stays "signups this month" while the split shows where
// they came from. Side-by-side bars would answer a different question.
const signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
},
});
const charts = [signups];
// Absent for non-admins -- the fee chart is financial, same gate as the Shop
// nav section.
if (feesCanvas) {
// Colour carries the meaning here — unpaid must read as a problem, waived
// must not — so the slices are pinned to the semantic theme colours, in order.
charts.push(new Chart(feesCanvas, {
type: "pie",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
{
data: data.fees.map((slice) => slice.value),
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink}}},
},
}));
}
return charts;
};
let charts = render();
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})();
</script>
{% endblock extra_body %}

View File

@@ -0,0 +1,157 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
<dd class="font-semibold">{{ member.date_of_birth|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Phone" %}</dt>
<dd class="font-semibold">{{ member.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt>
<dd class="font-semibold">{{ member.emergency_phone.as_international|default:"-" }}</dd>
</div>
</dl>
<div class="flex flex-row gap-2 items-center mt-2">
{% if member.phone %}
<a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
{% endif %}
</div>
</div>
</div>
{% comment %}
A Member has no club of its own -- ClubMembership is the only thing that
actually ties this person to *this* club, so it gets its own panel: the
current season's standing, plus every season they've been part of.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
<dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
</div>
</dl>
{% else %}
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>
{% endif %}
{% if membership_history %}
<div class="divider"></div>
<h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Season" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in membership_history %}
<tr>
<td>{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
<td>{{ membership.get_status_display }}</td>
<td>{{ membership.get_fee_status_display }}</td>
<td>{{ membership.license|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %}
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% for family_group in family_groups %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">
{% lucide "users" size=18 %}
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
</h2>
{% if is_club_admin %}
<div class="flex gap-2">
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
</div>
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family_group.family.pk as add_parent_url %}
{% url 'management:family_add_child' family_group.family.pk as add_child_url %}
{% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %}
{% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %}
{% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
{% endif %}
{% endfor %}
{% if is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
{% endif %}
</div>
</div>
{% url 'management:member_attach_family' member.pk as attach_family_url %}
{% trans "Add to family" as add_to_family_title %}
{% trans "Add" as add_label %}
{% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,62 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% if update_view %}{% blocktrans with name=object %}Edit {{ name }}{% endblocktrans %}{% else %}{% trans "New member" %}{% endif %}{% endblock heading %}
{% block actions %}
{% if update_view %}
<button class="btn btn-outline btn-error gap-2" type="button" onclick="document.getElementById('member_delete_modal').showModal()">{% lucide "trash-2" size=16 %} {% trans "Delete" %}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<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 %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
{% if membership_form %}
<div class="divider"></div>
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
{% for error in membership_form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in membership_form %}
{% form_field field %}
{% endfor %}
</div>
{% endif %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% if update_view %}
{% url 'management:member_delete' object.pk as member_delete_url %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% blocktrans with full_name=object.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,39 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download template" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %}
</p>
<form method="post" enctype="multipart/form-data">
{% 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 %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,68 @@
{% extends "management/base.html" %}
{% load lucide i18n %}
{% block heading %}{% trans "Review import" %}{% endblock heading %}
{% block panel %}
{% if not season %}
<div class="alert alert-warning mb-4">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "No active season — members will still be created, but won't be rostered for one until it exists." %}</span>
</div>
{% endif %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Row" %}</th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
<tbody>
{% for result in results %}
<tr>
<td>{{ result.line_number }}</td>
<td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td>
<td>{{ result.raw.email }}</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
{% else %}
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
<div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if valid_count %}
<form method="post" action="{% url 'management:member_import_confirm' %}">
{% csrf_token %}
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,102 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% block heading %}{% trans "Members" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}" xmlns:management="http://www.w3.org/1999/xhtml">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
{% if is_club_admin %}
<a class="btn btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full max-w-xs">
</label>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} {% trans "Search" %}</button>
{% if search %}
<a class="btn btn-neutral gap-2" href="{% url "management:member_list" %}">{% lucide "x" size=16 %} {% trans "Clear filter" %}</a>
{% endif %}
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}</a></td>
<td>{{ member.first_name }}</td>
<td>{{ member.contact_email|default:"-" }}</td>
<td>
{% if member.family_memberships_display %}
<div class="flex flex-col gap-1">
{% for fm in member.family_memberships_display %}
<div>
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
</div>
{% endfor %}
</div>
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
{% if member.current_membership %}
<span class="badge badge-sm
{% if member.current_membership.status == "active" %}badge-success
{% elif member.current_membership.status == "pending" %}badge-warning
{% elif member.current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ member.current_membership.get_status_display }}
</span>
{% else %}
<span class="badge badge-sm badge-neutral">{% trans "unknown" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% for member in members %}
{% url 'management:member_delete' member.pk as member_delete_url %}
{% blocktrans with full_name=member.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id=member.pk|dom_id:"member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
{% endfor %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,226 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Memberships" %}{% endblock heading %}
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
{% block actions %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
{% block panel %}
{% if not current_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
</div>
{% else %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "users" size=16 %} {% trans "Registered" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
<div class="text-xs opacity-60">{% trans "This season" %}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_paid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_partial %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_partial }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_unpaid %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-x" size=16 %} {% trans "Unpaid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_unpaid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-neutral">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Waived" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_waived }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-error{% elif kpi_paid_rate < 85 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "percent" size=16 %} {% trans "Paid rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<select name="season" class="select select-bordered">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="fee_status" class="select select-bordered">
<option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option>
{% for value, label in fee_status_choices %}
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
</select>
<select name="status" class="select select-bordered">
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="team" class="select select-bordered">
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
{% for team in teams %}
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
{% endfor %}
</select>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn btn-neutral gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
</div>
</form>
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}</a></td>
<td>{{ membership.member.first_name }}</td>
<td>{{ membership.member.contact_email|default:"-" }}</td>
<td>
{% if membership.member.family_memberships_display %}
{% for fm in membership.member.family_memberships_display %}
{{ fm.family }} {% if not forloop.last %},{% endif %}
{% endfor %}
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_status_display }}
</span>
</td>
<td>
<span class="badge badge-sm
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</td>
<td class="tabular-nums">&euro; {{ membership.fee_amount }}</td>
<td class="tabular-nums">
&euro; {{ membership.amount_paid }}
{% if membership.record_payment_form and membership.fee_amount %}
<div class="text-xs opacity-60">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
{% endif %}
</td>
<td>{{ membership.license|default:"-" }}</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center opacity-60">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</form>
{% trans "Record payment" as record_payment_title %}
{% trans "Record" as record_payment_submit_label %}
{% for membership in memberships %}
{% if membership.record_payment_form %}
{# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #}
<form method="post" action="{% url 'management:membership_mark_fully_paid' membership.pk %}" id="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
</form>
{% url 'management:membership_record_payment' membership.pk as record_payment_url %}
{% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %}
{% endif %}
{% endfor %}
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
const selectAll = document.getElementById("select-all-memberships");
const rowCheckboxes = document.querySelectorAll(".membership-row-checkbox");
if (!selectAll) return;
selectAll.addEventListener("change", () => {
rowCheckboxes.forEach((checkbox) => { checkbox.checked = selectAll.checked; });
});
})();
</script>
{% endblock extra_body %}

View File

@@ -0,0 +1,99 @@
{% load i18n %}
{% comment %}
Rendered by WeasyPrint, not a browser: a standalone document with its own print
stylesheet, same convention as billing/templates/billing/invoice.html -- it
deliberately doesn't pull in app.css (daisyUI's dark theme/flex layouts mean
nothing on paper).
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% trans "Memberships" %}</title>
<style>
@page {
size: A4;
margin: 18mm;
@bottom-center {
content: "{{ club.name }} — {% trans "memberships" %} — " counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 9.5pt; color: #111; }
h1 { font-size: 18pt; margin: 0 0 2mm; }
.muted { color: #666; }
.header { display: flex; justify-content: space-between; margin-bottom: 8mm; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #999; padding: 2mm 2mm 1mm 0; }
td { padding: 1.5mm 2mm 1.5mm 0; border-bottom: 1px solid #eee; vertical-align: top; }
.badge { display: inline-block; padding: 0.5mm 2mm; border-radius: 2mm; font-size: 8pt; }
.badge-success { background: #dcfce7; color: #15803d; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-error { background: #fee2e2; color: #b91c1c; }
.badge-neutral { background: #e5e7eb; color: #374151; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>{{ club.name }}</h1>
<div class="muted">{% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}</div>
</div>
<div class="muted">
{% blocktrans with generated=generated_at|date:"j F Y, H:i" %}Generated {{ generated }}{% endblocktrans %}<br>
{% blocktrans count counter=memberships|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
</div>
</div>
<table>
<thead>
<tr>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td>{{ membership.member.last_name }}</td>
<td>{{ membership.member.first_name }}</td>
<td>{{ membership.member.contact_email|default:"—" }}</td>
<td>
{% for fm in membership.member.family_memberships_display %}{{ fm.family }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
</td>
<td>
<span class="badge
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">{{ membership.get_status_display }}</span>
</td>
<td>
<span class="badge
{% if membership.fee_status == "paid" %}badge-success
{% elif membership.fee_status == "partially_paid" %}badge-warning
{% elif membership.fee_status == "unpaid" %}badge-error
{% else %}badge-neutral{% endif %}">{{ membership.get_fee_status_display }}</span>
</td>
<td>{{ membership.fee_amount }}</td>
<td>{{ membership.amount_paid }}</td>
<td>{{ membership.license|default:"—" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Grant role" %}{% endblock heading %}
{% block panel %}
<div class="card w-full max-w-xl bg-base-100 shadow">
<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 %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:role_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,44 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Roles" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:role_create' %}">{% lucide "plus" size=16 %} {% trans "Grant role" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Role" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.get_role_display }}</td>
<td class="text-right">
<form method="post" action="{% url 'management:role_revoke' role.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Revoke" %}</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No roles granted yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,19 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{{ team.name }}{% endblock heading %}
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:team_update' team.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="opacity-70">{% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}</p>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New team" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<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 %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:team_detail" object.pk %}{% else %}{% url "management:team_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,39 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Teams" %}{% endblock heading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-primary gap-2" href="{% url 'management:team_create' %}">{% lucide "plus" size=16 %} {% trans "New team" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Short name" %}</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
<td>{{ team.short_name }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No teams yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}