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" %}
|
{% 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 %} · €{{ 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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user