Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export

Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 deletions

View File

@@ -19,11 +19,17 @@
{% 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>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
<li><a class="{% if nav == 'group_list' %}menu-active{% endif %}" href="{% url 'management:group_list' %}">{% lucide "users-round" size=16 %} {% trans "Groups" %}</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 == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
{% endif %}
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>

View File

@@ -0,0 +1,90 @@
{% load i18n lucide ui %}
{% comment %}
Shared by the event detail page and the referee management dashboard --
context: event, referees (EventReferee rows, each with a .fee_form when
can_manage_referees), referee_candidates, referees_full,
can_manage_referees, and an optional next_url to return to after a POST
(defaults to the event detail page when blank).
{% endcomment %}
<div class="flex items-center justify-between">
<h2 class="card-title text-base">
{% lucide "flag" size=18 %} {% trans "Referees" %}
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
{{ referees|length }} / {{ event.max_referees }}
</span>
</h2>
{% if can_manage_referees %}
<a class="btn btn-outline btn-sm gap-1" href="{% url 'management:event_referee_form_pdf' event.pk %}">{% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %}</a>
{% endif %}
</div>
<ul class="divide-y divide-base-200">
{% for referee in referees %}
<li class="flex items-center justify-between py-2 gap-2">
<span>
{{ referee.display_name }}
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
{% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
{% if referee.total_payable %}<span class="text-xs opacity-60">— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}</span>{% endif %}
</span>
{% if can_manage_referees %}
<div class="flex gap-1 shrink-0">
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ referee.pk|dom_id:"referee_fee_modal" }}').showModal()">{% lucide "euro" size=12 %} {% trans "Fee" %}</button>
<form method="post" action="{% url 'management:event_referee_remove' event.pk referee.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<button class="btn btn-xs btn-outline btn-error gap-1" type="submit">{% lucide "x" size=12 %} {% trans "Remove" %}</button>
</form>
</div>
{% endif %}
</li>
{% empty %}
<li class="py-2 text-sm opacity-60">{% trans "No referees assigned yet." %}</li>
{% endfor %}
</ul>
{% if can_manage_referees %}
{% if referees_full %}
<p class="text-sm opacity-60 mt-2">{% trans "This game already has its maximum number of referees." %}</p>
{% else %}
{% if referee_candidates %}
<form method="post" action="{% url 'management:event_referee_assign' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="member" class="select select-bordered select-sm">
{% for candidate in referee_candidates %}
<option value="{{ candidate.pk }}" {% if candidate.has_conflict %}title="{% blocktrans with events=candidate.conflict_titles %}Also expected at: {{ events }}{% endblocktrans %}"{% endif %}>
{{ candidate }}{% if candidate.has_conflict %} ⚠{% endif %}
</option>
{% endfor %}
</select>
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
</form>
<p class="text-xs opacity-60 mt-1">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
{% else %}
<p class="text-sm opacity-60 mt-2">
{% trans "No eligible referees for this team yet." %}
<a class="link" href="{% url 'management:referee_level_list' %}">{% trans "Link a referee level to this team." %}</a>
</p>
{% endif %}
<form method="post" action="{% url 'management:event_referee_add_external' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<input type="text" name="name" class="input input-bordered input-sm" placeholder="{% trans 'External referee name' %}">
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Add external" %}</button>
</form>
{% endif %}
{% endif %}
{% if can_manage_referees %}
{% trans "Referee fee" as fee_title %}
{% trans "Save" as save_label %}
{% with encoded_next=next_url|urlencode %}
{% for referee in referees %}
{% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %}
{% with fee_action_url=fee_action_url|add:"?next="|add:encoded_next %}
{% include "controlpanel/_modal_form.html" with modal_id=referee.pk|dom_id:"referee_fee_modal" title=fee_title form=referee.fee_form action_url=fee_action_url submit_label=save_label submit_icon="save" %}
{% endwith %}
{% endfor %}
{% endwith %}
{% endif %}

View File

@@ -128,6 +128,21 @@
</div>
</div>
{% endif %}
{% if event.is_home_game and not referee_management_needed %}
<div class="alert alert-info mb-6">
{% lucide "info" size=20 %}
<span>{% trans "Referees for this game are managed by the federation, not the club." %}</span>
</div>
{% endif %}
{% if referee_management_needed %}
<div class="mb-6 card bg-base-100 shadow">
<div class="card-body">
{% include "management/_referee_assignment_panel.html" %}
</div>
</div>
{% endif %}
{% endblock panel %}
{% block extra_body %}

View File

@@ -49,22 +49,22 @@
<div id="game-details-section" class="game-only">
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.competition %}
{% form_field form.external_game_id %}
</div>
{% if update_view %}
{% comment %}
{% form_field form.max_referees %}
{% if update_view %}
{% comment %}
Score/live status only exist to record once a game has
actually been created -- the add form leaves them out
entirely (see EventForm's editing=False path).
{% endcomment %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% endcomment %}
{% form_field form.score_for %}
{% form_field form.score_against %}
{% form_field form.is_live %}
</div>
{% endif %}
{% endif %}
</div>
</div>
<div class="card-actions justify-start pt-2 mt-2">

View File

@@ -0,0 +1,90 @@
{% load i18n %}
{% comment %}
Rendered by WeasyPrint -- modeled directly on the club's existing paper
referee payment form ("Leeg Document RefereesCompetitie25-26.pdf"): a
3-line header (club name/address), match details, referee names, a
payment sentence, a fee+km breakdown per referee, and blank signature
lines. Header uses club.official_name (legal_name if set, else name);
the payment sentence uses the plain club name -- the original document
does the same, using a longer legal form up top and a shorter one in the
body text.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% trans "Referee form" %}</title>
<style>
@page {
size: A4;
margin: 25mm 20mm;
@bottom-center {
content: "{{ club.name }} — {% trans "referee form" %} — " counter(page) " / " counter(pages);
font-size: 8pt;
color: #666;
}
}
body { font-family: sans-serif; font-size: 10.5pt; color: #111; line-height: 1.5; }
.header { text-align: center; margin-bottom: 14mm; }
.header div { margin: 0; }
.field { margin-bottom: 4mm; }
.field .label { display: inline-block; min-width: 32mm; }
.fill-line { display: block; border-bottom: 1px solid #333; min-height: 5mm; margin: 2mm 0 4mm; }
.section-title { margin: 8mm 0 2mm; }
.fee-row { border-bottom: 1px solid #333; padding-bottom: 1mm; margin-bottom: 4mm; min-height: 5mm; }
.signature-block { margin-top: 12mm; }
.signature-line { border-bottom: 1px solid #333; min-height: 16mm; margin-top: 2mm; }
.collected-line { margin-top: 12mm; }
.collected-line .fill { display: inline-block; border-bottom: 1px solid #333; min-width: 90mm; }
</style>
</head>
<body>
<div class="header">
<div>{{ club.official_name }}</div>
{% if home_location %}
<div>{{ home_location.address }}</div>
<div>{{ home_location.zip_code }} {{ home_location.city }}</div>
{% endif %}
</div>
<div class="field">
<span class="label">{% trans "Regarding: Game" %}</span>
{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if event.opponent %}{% trans "vs" %} {{ event.opponent }}{% endif %}
{% if event.external_game_id %}({% trans "ID" %}: {{ event.external_game_id }}){% endif %}
</div>
<div class="field"><span class="label">{% trans "Date" %}</span> {{ event.start|date:"j F Y" }}</div>
<div class="field"><span class="label">{% trans "Time" %}</span> {{ event.start|date:"H:i" }}</div>
<div class="field">
<span class="label">{% trans "Referee names" %}</span>
{% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
</div>
<div class="fill-line"></div>
<p>{% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}</p>
<div class="section-title">{% trans "Referees and fee, plus any kilometers" %}</div>
{% for referee in referees %}
<div class="fee-row">
{{ referee.display_name }} —
{% blocktrans with fee=referee.fee %}fee: €{{ fee }}{% endblocktrans %}
{% if referee.km %}
{% blocktrans with km=referee.km rate=referee.km_rate total=referee.km_total %}+ {{ km }} km × €{{ rate }}/km = €{{ total }}{% endblocktrans %}
{% endif %}
— {% blocktrans with total=referee.total_payable %}total: €{{ total }}{% endblocktrans %}
</div>
{% empty %}
<div class="fee-row"></div>
{% endfor %}
<div class="signature-block">
<div>{% trans "Referee signature" %}</div>
<div class="signature-line"></div>
</div>
<div class="collected-line">
{% trans "Collected by team manager" %}: <span class="fill">&nbsp;</span>
</div>
</body>
</html>

View File

@@ -0,0 +1,65 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
{% block subheading %}{{ group.name }}{% endblock subheading %}
{% block panel %}
<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>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post">
{% csrf_token %}
<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=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>{% trans "Member" %}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>
{% if member.already_in_group %}
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
{% else %}
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
{% endif %}
</td>
<td>{{ member }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-4">
<a class="btn btn-outline gap-2" href="{% url 'management:group_detail' group.pk %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
</div>
</form>
{% endblock panel %}

View File

@@ -0,0 +1,51 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{{ group.name }}{% endblock heading %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:group_update' group.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Members" %}</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:group_bulk_add' group.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member }}</a></td>
<td class="text-right">
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_member_modal" }}').showModal()">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% trans "Remove from group" as remove_member_title %}
{% trans "Remove" as remove_label %}
{% for membership in memberships %}
{% blocktrans with member=membership.member group_name=group.name asvar remove_member_body %}Remove {{ member }} from “{{ group_name }}”?{% endblocktrans %}
{% url 'management:group_member_remove' group.pk membership.pk as remove_member_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=membership.pk|dom_id:"remove_member_modal" title=remove_member_title body=remove_member_body action_url=remove_member_url submit_label=remove_label submit_icon="user-minus" %}
{% endfor %}
{% endblock panel %}

View File

@@ -0,0 +1,35 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New group" %}{% 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 gap-2" href="{% if update_view %}{% url "management:group_detail" object.pk %}{% else %}{% url "management:group_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 %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -0,0 +1,53 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Groups" %}{% endblock heading %}
{% block subheading %}{% trans "Named collections of members -- coaches, team managers, referee pools, committees..." %}{% endblock subheading %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:group_create' %}">{% lucide "plus" size=16 %} {% trans "New group" %}</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 "Name" %}</th>
<th>{% trans "Members" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td><a class="link link-hover" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
<td>{{ group.member_count }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:group_update' group.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('{{ group.pk|dom_id:"group_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No groups yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% trans "Delete group" as delete_group_title %}
{% trans "Delete" as delete_label %}
{% for group in groups %}
{% url 'management:group_delete' group.pk as group_delete_url %}
{% blocktrans with name=group.name asvar delete_group_body %}Delete “{{ name }}”? Its members are removed from it, and any teams it's linked to as a referee source lose that link. This cannot be undone.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id=group.pk|dom_id:"group_delete_modal" title=delete_group_title body=delete_group_body action_url=group_delete_url submit_label=delete_label submit_icon="trash-2" %}
{% endfor %}
{% endblock panel %}

View File

@@ -1,5 +1,5 @@
{% extends "management/base.html" %}
{% load lucide ui i18n %}
{% load lucide ui i18n static %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
@@ -127,6 +127,66 @@
</div>
</div>
{% if referee_profile or 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 "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
{% if is_club_admin %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('referee_eligibility_modal').showModal()">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
{% endif %}
</div>
{% if referee_profile and referee_profile.level %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Level" %}</dt>
<dd class="font-semibold">{{ referee_profile.level }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Valid until" %}</dt>
<dd class="font-semibold flex items-center gap-2">
{{ referee_profile.valid_until|default:"—" }}
{% if referee_profile.is_currently_valid %}
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
{% elif referee_profile.valid_until %}
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
{% else %}
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
{% endif %}
</dd>
</div>
</dl>
{% if referee_profile.is_eligible %}
<div class="text-xs font-semibold uppercase opacity-60 mt-2 mb-1">{% trans "Can referee for" %}</div>
<div class="flex flex-wrap gap-2">
{% for team in referee_profile.eligible_teams %}
<span class="badge badge-outline">{{ team.name }}</span>
{% empty %}
<span class="text-sm opacity-60">{% trans "This level has no teams linked yet." %}</span>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mt-2">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %}</span>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-60">{% trans "Not eligible to referee for any team." %}</p>
{% endif %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:member_referee_eligibility_update' member.pk as referee_eligibility_url %}
{% trans "Referee eligibility" as referee_eligibility_title %}
{% trans "Save" as save_label %}
{% include "controlpanel/_modal_form.html" with modal_id="referee_eligibility_modal" title=referee_eligibility_title form=referee_eligibility_form action_url=referee_eligibility_url submit_label=save_label submit_icon="save" %}
{% endif %}
{% endif %}
{% 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 %}
@@ -184,3 +244,7 @@
{% 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 %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -15,6 +15,9 @@
here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %}
</p>
<p class="text-sm opacity-70">
{% blocktrans %}To register a family, give matching rows the same <code>family_group</code> value (any label works, e.g. a surname) and set each row's <code>family_role</code>. A parent/guardian row with an email gets a login; a child row doesn't — grant one later from their member page if they need it.{% endblocktrans %}
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}

View File

@@ -26,6 +26,7 @@
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
@@ -36,6 +37,14 @@
<td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td>
<td>{{ result.raw.email }}</td>
<td>
{% if result.family_group %}
{{ result.family_group }}
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
@@ -47,7 +56,7 @@
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
<td colspan="6" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -0,0 +1,35 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New referee level" %}{% 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 items-start">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url "management:referee_level_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 %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -0,0 +1,54 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Referee levels" %}{% endblock heading %}
{% block subheading %}{% trans "Which teams' home games each level qualifies a referee for." %}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline gap-2" href="{% url 'management:referee_level_create' %}">{% lucide "plus" size=16 %} {% trans "New level" %}</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 "Ordering" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Qualifies for" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for level in levels %}
<tr>
<td>{{ level.ordering }}</td>
<td>{{ level.name }}</td>
<td>
{% for team in level.teams.all %}
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
{% empty %}
<span class="opacity-40"></span>
{% endfor %}
</td>
<td class="text-right">
{% if is_club_admin %}
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:referee_level_update' level.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">{% trans "No referee levels yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,60 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Referees" %}{% endblock heading %}
{% block subheading %}{% trans "Every club referee, their level, and whether their qualification is currently valid." %}{% endblock subheading %}
{% 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 "Referee" %}</th>
<th>{% trans "Level" %}</th>
<th>{% trans "Can referee for" %}</th>
<th>{% trans "Valid until" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for referee in referees %}
<tr>
<td><a class="link link-hover" href="{% url 'management:member_detail' referee.pk %}">{{ referee }}</a></td>
<td>{{ referee.referee_profile.level|default:"—" }}</td>
<td>
{% if referee.referee_profile.is_eligible %}
{% for team in referee.referee_profile.eligible_teams %}
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
{% empty %}
<span class="opacity-40"></span>
{% endfor %}
{% else %}
<span class="opacity-40"></span>
{% endif %}
</td>
<td>{{ referee.referee_profile.valid_until|default:"—" }}</td>
<td>
{% if referee.referee_profile.is_eligible %}
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
{% elif not referee.referee_profile.level %}
<span class="badge badge-warning badge-sm">{% trans "No level" %}</span>
{% elif not referee.referee_profile.valid_until %}
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
{% else %}
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No referees yet -- set a level and validity from a member's own page." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,99 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Referee management" %}{% endblock heading %}
{% block subheading %}{% trans "Every upcoming home game that needs a club-arranged referee." %}{% endblock subheading %}
{% block panel %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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 "calendar" size=16 %} {% trans "Games in view" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_no_referee %}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-alert" size=16 %} {% trans "Without a referee" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_no_referee }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_understaffed %}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 staffed" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_understaffed }}</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 "Fully staffed" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_fully_staffed }}</div>
</div>
</div>
</div>
<div class="join mb-4 w-full flex-wrap">
<a href="?range=week" class="btn join-item flex-1 {% if range_choice == "week" %}btn-primary{% endif %}">{% trans "This week" %}</a>
<a href="?range=two_weeks" class="btn join-item flex-1 {% if range_choice == "two_weeks" %}btn-primary{% endif %}">{% trans "This + next week" %}</a>
<a href="?range=10" class="btn join-item flex-1 {% if range_choice == "10" %}btn-primary{% endif %}">{% trans "Next 10" %}</a>
<a href="?range=25" class="btn join-item flex-1 {% if range_choice == "25" %}btn-primary{% endif %}">{% trans "Next 25" %}</a>
<a href="?range=50" class="btn join-item flex-1 {% if range_choice == "50" %}btn-primary{% endif %}">{% trans "Next 50" %}</a>
</div>
{% regroup games by start.date as day_groups %}
{% for day in day_groups %}
<h3 class="font-semibold text-sm opacity-70 mt-4 mb-2">{{ day.grouper|date:"l j F" }}</h3>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mb-2">
{% for game in day.list %}
<div class="card bg-base-100 shadow-sm border {% if not game.referee_rows %}border-error{% elif not game.referees_full %}border-warning{% else %}border-base-300{% endif %}">
<div class="card-body p-4 gap-2">
<a class="link link-hover font-semibold" href="{% url 'management:event_detail' game.pk %}">
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
</a>
<div class="text-xs opacity-70">{{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}</div>
<div class="text-xs">
{% for referee in game.referee_rows %}
<span class="badge badge-ghost badge-sm">{{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %}</span>
{% empty %}
<span class="opacity-60">{% trans "No referees assigned yet." %}</span>
{% endfor %}
</div>
<div class="flex items-center justify-between">
<span class="badge badge-sm {% if not game.referee_rows %}badge-error{% elif not game.referees_full %}badge-warning{% else %}badge-success{% endif %}">
{{ game.referee_rows|length }} / {{ game.max_referees }}
</span>
<div class="flex gap-1">
<a class="btn btn-xs btn-outline" href="{% url 'management:event_referee_form_pdf' game.pk %}">
{% lucide "file-down" size=12 %} {% trans "Referee form" %}
</a>
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ game.pk|dom_id:"referee_game_modal" }}').showModal()">
{% lucide "flag" size=12 %} {% trans "Manage" %}
</button>
</div>
</div>
</div>
</div>
<dialog id="{{ game.pk|dom_id:"referee_game_modal" }}" class="modal">
<div class="modal-box max-w-2xl">
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button></form>
<h3 class="font-bold text-lg mb-1">
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
</h3>
<div class="text-sm opacity-70 mb-4">{{ game.start|date:"j M Y H:i" }}</div>
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full can_manage_referees=True next_url=request.get_full_path %}
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endfor %}
</div>
{% empty %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-center opacity-60">{% trans "No upcoming home games need a club-arranged referee." %}</p>
</div>
</div>
{% endfor %}
{% endblock panel %}

View File

@@ -0,0 +1,125 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
{% block subheading %}{{ team.name }}{% endblock subheading %}
{% block panel %}
<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>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post">
{% csrf_token %}
<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=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div>
<p class="text-sm opacity-70 mb-2">
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Player" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Jersey #" %}</th>
<th>{% trans "Staff" %}</th>
<th>{% trans "Position" %}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>{{ member }}</td>
<td>
{% if member.already_player %}
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
{% else %}
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_player %}
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in player_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td>
{% if not member.already_player %}
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
{% endif %}
</td>
<td>
{% if member.already_staff %}
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
{% else %}
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_staff %}
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in staff_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-4">
<a class="btn btn-outline gap-2" href="{% url 'management:team_detail' team.pk %}?season={{ season.pk }}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
</div>
</form>
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
const pairs = [
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
];
pairs.forEach(({checkboxClass, fieldClasses}) => {
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
const memberId = checkbox.dataset.member;
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
checkbox.addEventListener("change", () => {
fields.forEach((field) => { field.disabled = !checkbox.checked; });
});
});
});
})();
</script>
{% endblock extra_body %}

View File

@@ -149,7 +149,10 @@
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
<div class="flex gap-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:team_bulk_add' team.pk selected_season.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
</div>
{% endif %}
</div>
<div class="overflow-x-auto">
@@ -198,7 +201,10 @@
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
<div class="flex gap-2">
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:team_bulk_add' team.pk selected_season.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
</div>
{% endif %}
</div>
<div class="overflow-x-auto">
@@ -235,6 +241,26 @@
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Eligible referees" %}</h2>
{% if eligible_referees is None %}
<p class="text-sm opacity-70">{% trans "Referees for this team's home games are managed by the federation, not the club -- nothing to configure here." %}</p>
{% else %}
<p class="text-sm opacity-70">{% trans "Members who can be assigned to referee this team's home games. Set from each member's own page." %}</p>
{% if eligible_referees %}
<div class="flex flex-wrap gap-2">
{% for referee in eligible_referees %}
<a class="badge badge-outline hover:badge-neutral" href="{% url 'management:member_detail' referee.pk %}">{{ referee }}</a>
{% endfor %}
</div>
{% else %}
<p class="text-sm opacity-60">{% trans "No one yet." %}</p>
{% endif %}
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">