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:
@@ -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 %}<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>
|
||||
|
||||
{% 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 %} · €{{ row.total_fees|floatformat:2 }}{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% regroup games by start.date as 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>
|
||||
@@ -104,3 +151,40 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user