diff --git a/management/templates/management/referee_management.html b/management/templates/management/referee_management.html
index 2eb9a11..0cc35bc 100644
--- a/management/templates/management/referee_management.html
+++ b/management/templates/management/referee_management.html
@@ -1,5 +1,5 @@
{% extends "management/base.html" %}
-{% load i18n lucide ui %}
+{% load i18n lucide static ui %}
{% block heading %}{% trans "Referee management" %}{% endblock heading %}
{% block topbar_context %}{% trans "Every upcoming home game that needs a club-arranged referee." %}{% endblock topbar_context %}
@@ -46,6 +46,53 @@
+ {% comment %}
+ Season workload -- separate from the "games in view" KPIs above
+ (which track the upcoming range picked by the filter strip), this is
+ about who's actually done the refereeing this season, all-time
+ within it, not just what's still upcoming.
+ {% endcomment %}
+
+
+
+
{% trans "This season." %}
+ {% if referee_stats %}
+
+
+
+ {% else %}
+
{% trans "No referee assignments recorded yet this season." %}
+ {% endif %}
+
+
+
+
+
+
- {% trans "Active referees" %}
+ - {{ kpi_active_referees }}
+
+
+
- {% trans "Games refereed" %}
+ - {{ kpi_total_assignments }}
+
+
+
- {% trans "Average per referee" %}
+ - {{ kpi_avg_games_per_referee }}
+
+
+ {% if referee_stats %}
+
+ {% for row in referee_stats %}
+
+ {{ row.member__first_name }} {{ row.member__last_name }}
+ {% blocktrans count counter=row.games %}{{ counter }} game{% plural %}{{ counter }} games{% endblocktrans %}{% if row.total_fees %} · €{{ row.total_fees|floatformat:2 }}{% endif %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
{% regroup games by start.date as day_groups %}
{% for day in day_groups %}
{{ day.grouper|date:"l j F" }}
@@ -104,3 +151,40 @@
{% endfor %}
{% endblock panel %}
+
+{% block extra_body %}
+ {% trans "Games" as games_label %}
+ {{ charts|json_script:"referee-chart-data" }}
+
+
+{% endblock extra_body %}
diff --git a/management/tests.py b/management/tests.py
index ec3d2f2..a484d63 100644
--- a/management/tests.py
+++ b/management/tests.py
@@ -26,6 +26,7 @@ from club.services.onboarding import mark_complete
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences
+from events.services.referees import add_external_referee
from management.bulk_import import TEMPLATE_COLUMNS
from management.email_previews import EMAIL_PREVIEWS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
@@ -6587,6 +6588,39 @@ class RefereeManagementDashboardTests(ManagementTestBase):
self.assertContains(response, "Self sign-up")
+ def test_workload_stats_count_games_refereed_this_season(self):
+ game_one = self.make_game()
+ game_two = self.make_game(title="Second game", start=timezone.now() + datetime.timedelta(days=2))
+ EventReferee.objects.create(event=game_one, member=self.referee, assigned_by=self.admin_member, fee=Decimal("20.00"))
+ EventReferee.objects.create(event=game_two, member=self.referee, assigned_by=self.admin_member, fee=Decimal("15.00"))
+ self.client.force_login(self.admin_user)
+
+ response = self.club_get("referee_management")
+
+ self.assertEqual(response.context["kpi_active_referees"], 1)
+ self.assertEqual(response.context["kpi_total_assignments"], 2)
+ stats = response.context["referee_stats"]
+ self.assertEqual(stats[0]["games"], 2)
+ self.assertEqual(stats[0]["total_fees"], Decimal("35.00"))
+ self.assertContains(response, "Games refereed per referee")
+
+ def test_workload_stats_exclude_external_referees(self):
+ game = self.make_game()
+ add_external_referee(game, "Guest Ref", assigned_by=self.admin_member)
+ self.client.force_login(self.admin_user)
+
+ response = self.club_get("referee_management")
+
+ self.assertEqual(response.context["kpi_active_referees"], 0)
+
+ def test_workload_stats_empty_state(self):
+ self.client.force_login(self.admin_user)
+
+ response = self.club_get("referee_management")
+
+ self.assertEqual(response.context["referee_stats"], [])
+ self.assertContains(response, "No referee assignments recorded yet this season.")
+
class FeatureGatedSectionsTests(ManagementTestBase):
"""The Shop and Forms sections are still stubs (StubListMixin) and, on top
diff --git a/management/views.py b/management/views.py
index 1c41ae7..ed1fae1 100644
--- a/management/views.py
+++ b/management/views.py
@@ -2,7 +2,7 @@ from datetime import date, timedelta
from decimal import Decimal
from django.db import IntegrityError, transaction
-from django.db.models import Count, ProtectedError, Q
+from django.db.models import Count, ProtectedError, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -2693,6 +2693,28 @@ def upcoming_games_needing_referee_management(club):
)
+def referee_workload_stats(club):
+ """Games refereed (and fees paid) per member this season -- external
+ referees excluded (member is None for those, nothing to group by).
+ Current season only, same scope as everything else on this dashboard
+ being "the season we're in", not all-time history. Ordered by games
+ descending -- the dashboard's own bar chart and table both read off
+ this directly, no separate sort."""
+ season = current_season(club)
+ queryset = EventReferee.objects.filter(event__club=club, member__isnull=False)
+ if season is not None:
+ # By date range, not event__season=season -- most events are created
+ # with that field left blank (help_text: "derived from the start
+ # date when left blank"), so matching the FK directly would silently
+ # exclude almost everything.
+ queryset = queryset.filter(event__start__date__gte=season.start_date, event__start__date__lte=season.end_date)
+ return list(
+ queryset.values("member__id", "member__first_name", "member__last_name")
+ .annotate(games=Count("id"), total_fees=Sum("fee"))
+ .order_by("-games", "member__last_name")
+ )
+
+
def games_missing_referees_count(club, limit=10):
"""How many of the next `limit` upcoming club-managed home games have nobody
assigned yet -- the same games RefereeManagementDashboardView's own
@@ -2765,6 +2787,19 @@ class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
game.referee_candidates.append(candidate)
+ stats = referee_workload_stats(club)
+ kpi_active_referees = len(stats)
+ kpi_total_assignments = sum(row["games"] for row in stats)
+ kpi_avg_games_per_referee = round(kpi_total_assignments / kpi_active_referees, 1) if kpi_active_referees else 0
+ # Top 15 for the chart's own readability -- the table below it lists
+ # every referee, so nothing is actually hidden, just not plotted.
+ charts = {
+ "referee_games": {
+ "labels": [f"{row['member__first_name']} {row['member__last_name']}" for row in stats[:15]],
+ "games": [row["games"] for row in stats[:15]],
+ }
+ }
+
return super().get_context_data(
games=games,
range_choice=range_choice,
@@ -2773,6 +2808,11 @@ class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
kpi_understaffed=kpi_understaffed,
kpi_fully_staffed=kpi_fully_staffed,
kpi_fees_pending=kpi_fees_pending,
+ referee_stats=stats,
+ kpi_active_referees=kpi_active_referees,
+ kpi_total_assignments=kpi_total_assignments,
+ kpi_avg_games_per_referee=kpi_avg_games_per_referee,
+ charts=charts,
**kwargs,
)
diff --git a/static/css/app.css b/static/css/app.css
index ed504f7..5b65cc5 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -4013,6 +4013,9 @@
.h-screen {
height: 100vh;
}
+ .max-h-40 {
+ max-height: calc(var(--spacing) * 40);
+ }
.max-h-60 {
max-height: calc(var(--spacing) * 60);
}
@@ -5682,6 +5685,11 @@
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
}
+ .lg\:col-span-2 {
+ @media (width >= 64rem) {
+ grid-column: span 2 / span 2;
+ }
+ }
.lg\:flex {
@media (width >= 64rem) {
display: flex;