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 "Games refereed per referee" %}
+

{% trans "This season." %}

+ {% if referee_stats %} +
+ +
+ {% else %} +

{% trans "No referee assignments recorded yet this season." %}

+ {% endif %} +
+
+
{% trans "Season at a glance" %}
+
+
+
{% 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;