Add a season workload chart to the referee management dashboard

Games refereed per referee this season, as a chart.js bar chart plus a
small table with fees paid per referee, alongside season-at-a-glance KPIs
(active referees, total games refereed, average per referee) -- separate
from the existing "games in view" KPIs, which track the upcoming range
picked by the filter strip rather than season-to-date workload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 17:25:09 +02:00
parent 747514f1ff
commit cf1a2f0f5a
4 changed files with 168 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
{% extends "management/base.html" %} {% extends "management/base.html" %}
{% load i18n lucide ui %} {% load i18n lucide static ui %}
{% block heading %}{% trans "Referee management" %}{% endblock heading %} {% block heading %}{% trans "Referee management" %}{% endblock heading %}
{% block topbar_context %}<span class="text-sm text-muted">{% trans "Every upcoming home game that needs a club-arranged referee." %}</span>{% endblock topbar_context %} {% block topbar_context %}<span class="text-sm text-muted">{% trans "Every upcoming home game that needs a club-arranged referee." %}</span>{% endblock topbar_context %}
@@ -46,6 +46,53 @@
</div> </div>
</div> </div>
{% 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 %}
<div class="grid grid-cols-1 gap-3.5 lg:grid-cols-3">
<div class="card p-4.5 lg:col-span-2">
<div class="mb-1 font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Games refereed per referee" %}</div>
<p class="mb-3 text-sm text-muted">{% trans "This season." %}</p>
{% if referee_stats %}
<div class="h-56">
<canvas id="referee-games-chart"></canvas>
</div>
{% else %}
<p class="py-6 text-center text-sm text-muted">{% trans "No referee assignments recorded yet this season." %}</p>
{% endif %}
</div>
<div class="card p-4.5">
<div class="mb-3 font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Season at a glance" %}</div>
<dl>
<div class="flex items-center justify-between py-1.5">
<dt class="text-[13px] text-muted">{% trans "Active referees" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ kpi_active_referees }}</dd>
</div>
<div class="flex items-center justify-between py-1.5">
<dt class="text-[13px] text-muted">{% trans "Games refereed" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ kpi_total_assignments }}</dd>
</div>
<div class="flex items-center justify-between py-1.5">
<dt class="text-[13px] text-muted">{% trans "Average per referee" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ kpi_avg_games_per_referee }}</dd>
</div>
</dl>
{% if referee_stats %}
<div class="mt-2 max-h-40 overflow-y-auto border-t border-rule">
{% for row in referee_stats %}
<div class="flex items-center justify-between gap-3 py-1.5 {% if not forloop.last %}border-b border-rule{% endif %}">
<span class="truncate text-[13px] text-ink">{{ row.member__first_name }} {{ row.member__last_name }}</span>
<span class="shrink-0 font-mono text-xs text-muted">{% blocktrans count counter=row.games %}{{ counter }} game{% plural %}{{ counter }} games{% endblocktrans %}{% if row.total_fees %} &middot; &euro;{{ row.total_fees|floatformat:2 }}{% endif %}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% regroup games by start.date as day_groups %} {% regroup games by start.date as day_groups %}
{% for day in day_groups %} {% for day in day_groups %}
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{{ day.grouper|date:"l j F" }}</div> <div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{{ day.grouper|date:"l j F" }}</div>
@@ -104,3 +151,40 @@
</div> </div>
{% endfor %} {% endfor %}
{% endblock panel %} {% endblock panel %}
{% block extra_body %}
{% trans "Games" as games_label %}
{{ charts|json_script:"referee-chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("referee-chart-data").textContent);
// Same pattern as management/home.html's own charts -- Chart.js paints to a
// canvas, so it needs the club accent resolved, not the CSS variable itself.
const clubColor = getComputedStyle(document.documentElement).getPropertyValue("--color-club").trim() || "#E4002B";
const ink = "#3A4658";
const grid = "#EEF0F3";
const gamesCanvas = document.getElementById("referee-games-chart");
if (gamesCanvas && data.referee_games.labels.length) {
new Chart(gamesCanvas, {
type: "bar",
data: {
labels: data.referee_games.labels,
datasets: [{label: "{{ games_label|escapejs }}", data: data.referee_games.games, backgroundColor: clubColor}],
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
plugins: {legend: {display: false}},
scales: {
x: {beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
y: {ticks: {color: ink}, grid: {display: false}},
},
},
});
}
})();
</script>
{% endblock extra_body %}

View File

@@ -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.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.rbihf_import import RBIHFImportError from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences 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.bulk_import import TEMPLATE_COLUMNS
from management.email_previews import EMAIL_PREVIEWS from management.email_previews import EMAIL_PREVIEWS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf 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") 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): class FeatureGatedSectionsTests(ManagementTestBase):
"""The Shop and Forms sections are still stubs (StubListMixin) and, on top """The Shop and Forms sections are still stubs (StubListMixin) and, on top

View File

@@ -2,7 +2,7 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from django.db import IntegrityError, transaction 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.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse 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): def games_missing_referees_count(club, limit=10):
"""How many of the next `limit` upcoming club-managed home games have nobody """How many of the next `limit` upcoming club-managed home games have nobody
assigned yet -- the same games RefereeManagementDashboardView's own 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) candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
game.referee_candidates.append(candidate) 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( return super().get_context_data(
games=games, games=games,
range_choice=range_choice, range_choice=range_choice,
@@ -2773,6 +2808,11 @@ class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
kpi_understaffed=kpi_understaffed, kpi_understaffed=kpi_understaffed,
kpi_fully_staffed=kpi_fully_staffed, kpi_fully_staffed=kpi_fully_staffed,
kpi_fees_pending=kpi_fees_pending, 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, **kwargs,
) )

View File

@@ -4013,6 +4013,9 @@
.h-screen { .h-screen {
height: 100vh; height: 100vh;
} }
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-60 { .max-h-60 {
max-height: calc(var(--spacing) * 60); max-height: calc(var(--spacing) * 60);
} }
@@ -5682,6 +5685,11 @@
line-height: var(--tw-leading, var(--text-4xl--line-height)); 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 { .lg\:flex {
@media (width >= 64rem) { @media (width >= 64rem) {
display: flex; display: flex;