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:
92
management/templates/management/_family_members_table.html
Normal file
92
management/templates/management/_family_members_table.html
Normal 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 %}
|
||||
33
management/templates/management/_generic_list.html
Normal file
33
management/templates/management/_generic_list.html
Normal 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 %}
|
||||
49
management/templates/management/_nav_items.html
Normal file
49
management/templates/management/_nav_items.html
Normal 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 %}
|
||||
42
management/templates/management/base.html
Normal file
42
management/templates/management/base.html
Normal 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 %}
|
||||
31
management/templates/management/family_detail.html
Normal file
31
management/templates/management/family_detail.html
Normal 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 %}
|
||||
43
management/templates/management/family_form.html
Normal file
43
management/templates/management/family_form.html
Normal 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 %}
|
||||
245
management/templates/management/home.html
Normal file
245
management/templates/management/home.html
Normal 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" %}€{% 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 %}
|
||||
157
management/templates/management/member_detail.html
Normal file
157
management/templates/management/member_detail.html
Normal 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 %}
|
||||
62
management/templates/management/member_form.html
Normal file
62
management/templates/management/member_form.html
Normal 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 %}
|
||||
39
management/templates/management/member_import.html
Normal file
39
management/templates/management/member_import.html
Normal 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 %}
|
||||
68
management/templates/management/member_import_preview.html
Normal file
68
management/templates/management/member_import_preview.html
Normal 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 %}
|
||||
102
management/templates/management/member_list.html
Normal file
102
management/templates/management/member_list.html
Normal 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 %}
|
||||
226
management/templates/management/membership_list.html
Normal file
226
management/templates/management/membership_list.html
Normal 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">€ {{ 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 %}
|
||||
99
management/templates/management/membership_list_pdf.html
Normal file
99
management/templates/management/membership_list_pdf.html
Normal 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>
|
||||
29
management/templates/management/role_form.html
Normal file
29
management/templates/management/role_form.html
Normal 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 %}
|
||||
44
management/templates/management/role_list.html
Normal file
44
management/templates/management/role_list.html
Normal 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 %}
|
||||
19
management/templates/management/team_detail.html
Normal file
19
management/templates/management/team_detail.html
Normal 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 %}
|
||||
31
management/templates/management/team_form.html
Normal file
31
management/templates/management/team_form.html
Normal 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 %}
|
||||
39
management/templates/management/team_list.html
Normal file
39
management/templates/management/team_list.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user