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.
227 lines
14 KiB
HTML
227 lines
14 KiB
HTML
{% 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">€ {{ membership.fee_amount }}</td>
|
|
<td class="tabular-nums">
|
|
€ {{ 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 %}
|