Redesign the management home page's KPIs and charts
Drop renewal rate from the top KPI bar; split "Upcoming events" in half and add a "News" overview beside it (published items only, everyone can see it, same as events). Signups-per-month, club fee status, and renewal rate (now a pie chart matching the fee status one, not a bare stat box) move into their own row and become admin-only like the fee chart already was. The bottom stat-group row now sizes its columns to how many groups actually render, instead of leaving a blank slot where Shop would be for non-admins.
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
money-shaped renders below for non-admins, same line the nav already draws
|
money-shaped renders below for non-admins, same line the nav already draws
|
||||||
around the Shop section.
|
around the Shop section.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div>
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}</div>
|
||||||
@@ -55,27 +55,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} {% trans "Renewal rate" %}</div>
|
|
||||||
<div class="text-4xl font-bold tabular-nums font-mono">
|
|
||||||
{% if attention.renewal_rate is None %}
|
|
||||||
{% trans "N/A" %}
|
|
||||||
{% else %}
|
|
||||||
{{ attention.renewal_rate }}%
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs opacity-60">
|
|
||||||
{% if attention.renewal_rate is None %}
|
|
||||||
{% trans "No previous season" %}
|
|
||||||
{% else %}
|
|
||||||
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
|
|
||||||
max="100"></progress>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
|
||||||
@@ -98,43 +77,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow mb-6">
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
||||||
<div class="card-body">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="flex items-center justify-between">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
<div class="flex items-center justify-between">
|
||||||
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
||||||
|
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for event in upcoming_events %}
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
|
||||||
|
<td>{{ event.title }}</td>
|
||||||
|
<td>{{ event.get_kind_display|capfirst }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
</div>
|
||||||
<table class="table">
|
|
||||||
<tbody>
|
<div class="card bg-base-100 shadow">
|
||||||
{% for event in upcoming_events %}
|
<div class="card-body">
|
||||||
<tr>
|
<div class="flex items-center justify-between">
|
||||||
<td class="whitespace-nowrap">{{ event.start|date:"D j M, H:i" }}</td>
|
<h2 class="card-title text-base">{% lucide "newspaper" size=18 %} {% trans "News" %}</h2>
|
||||||
<td>{{ event.title }}</td>
|
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
||||||
<td>{{ event.get_kind_display|capfirst }}</td>
|
</div>
|
||||||
</tr>
|
<div class="overflow-x-auto">
|
||||||
{% empty %}
|
<table class="table">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="text-center opacity-60">{% trans "Nothing scheduled." %}</td>
|
{% for news_item in published_news %}
|
||||||
</tr>
|
<tr>
|
||||||
{% endfor %}
|
<td class="whitespace-nowrap">{{ news_item.published_at|date:"D j M" }}</td>
|
||||||
</tbody>
|
<td><a class="link link-hover" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td>
|
||||||
</table>
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center opacity-60">{% trans "Nothing published yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
{% if is_club_admin %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="mb-6 grid gap-4 lg:grid-cols-3">
|
||||||
<div class="card-body">
|
<div class="card bg-base-100 shadow">
|
||||||
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
|
<div class="card-body">
|
||||||
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
|
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
|
||||||
<div class="h-56">
|
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
|
||||||
<canvas id="signups-chart"></canvas>
|
<div class="h-56">
|
||||||
|
<canvas id="signups-chart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% if is_club_admin %}
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
|
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
|
||||||
@@ -143,10 +150,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Renewal rate" %}</h2>
|
||||||
|
{% if attention.renewal_rate is None %}
|
||||||
|
<p class="text-sm opacity-70">{% trans "No previous season to compare." %}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="h-56">
|
||||||
|
<canvas id="renewal-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid gap-4 {% if is_club_admin %}md:grid-cols-4{% else %}md:grid-cols-3{% endif %}">
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
{% if group.title != "Shop" or is_club_admin %}
|
{% if group.title != "Shop" or is_club_admin %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
@@ -170,44 +190,51 @@
|
|||||||
{% block extra_body %}
|
{% block extra_body %}
|
||||||
{% trans "New" as new_label %}
|
{% trans "New" as new_label %}
|
||||||
{% trans "Returning" as returning_label %}
|
{% trans "Returning" as returning_label %}
|
||||||
|
{% trans "Renewed" as renewed_label %}
|
||||||
|
{% trans "Not renewed" as not_renewed_label %}
|
||||||
{{ charts|json_script:"chart-data" }}
|
{{ charts|json_script:"chart-data" }}
|
||||||
|
{{ attention.renewal_rate|json_script:"renewal-rate-data" }}
|
||||||
<script src="{% static 'js/chart.js' %}"></script>
|
<script src="{% static 'js/chart.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||||
|
const renewalRate = JSON.parse(document.getElementById("renewal-rate-data").textContent);
|
||||||
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
|
const signupsCanvas = document.getElementById("signups-chart");
|
||||||
const feesCanvas = document.getElementById("fees-chart");
|
const feesCanvas = document.getElementById("fees-chart");
|
||||||
|
const renewalCanvas = document.getElementById("renewal-chart");
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const ink = css("--color-base-content", "#333");
|
const ink = css("--color-base-content", "#333");
|
||||||
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
||||||
|
const charts = [];
|
||||||
|
|
||||||
// Stacked: the bar height stays "signups this month" while the split shows where
|
// Both canvases are admin-only -- absent entirely for a manager/coach,
|
||||||
// they came from. Side-by-side bars would answer a different question.
|
// same gate as the Shop nav section and the rest of this row.
|
||||||
const signups = new Chart(document.getElementById("signups-chart"), {
|
if (signupsCanvas) {
|
||||||
type: "bar",
|
// Stacked: the bar height stays "signups this month" while the split shows
|
||||||
data: {
|
// where they came from. Side-by-side bars would answer a different question.
|
||||||
labels: data.signups.map((point) => point.month),
|
charts.push(new Chart(signupsCanvas, {
|
||||||
datasets: [
|
type: "bar",
|
||||||
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
data: {
|
||||||
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
labels: data.signups.map((point) => point.month),
|
||||||
],
|
datasets: [
|
||||||
},
|
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
||||||
options: {
|
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
||||||
responsive: true,
|
],
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
|
||||||
scales: {
|
|
||||||
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
|
||||||
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
|
||||||
},
|
},
|
||||||
},
|
options: {
|
||||||
});
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "bottom", labels: {color: ink}}},
|
||||||
|
scales: {
|
||||||
|
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
||||||
|
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const charts = [signups];
|
|
||||||
|
|
||||||
// Absent for non-admins -- the fee chart is financial, same gate as the Shop
|
|
||||||
// nav section.
|
|
||||||
if (feesCanvas) {
|
if (feesCanvas) {
|
||||||
// Colour carries the meaning here — unpaid must read as a problem, waived
|
// Colour carries the meaning here — unpaid must read as a problem, waived
|
||||||
// must not — so the slices are pinned to the semantic theme colours, in order.
|
// must not — so the slices are pinned to the semantic theme colours, in order.
|
||||||
@@ -230,6 +257,28 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (renewalCanvas && renewalRate !== null) {
|
||||||
|
// Same "colour carries the meaning" reasoning as the fee chart: a low
|
||||||
|
// renewal rate reads as a problem, so it's pinned to error, not neutral.
|
||||||
|
charts.push(new Chart(renewalCanvas, {
|
||||||
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels: ["{{ renewed_label|escapejs }}", "{{ not_renewed_label|escapejs }}"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [renewalRate, 100 - renewalRate],
|
||||||
|
backgroundColor: [css("--color-success", "#16a34a"), css("--color-error", "#dc2626")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {legend: {position: "right", labels: {color: ink}}},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return charts;
|
return charts;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1708,7 +1708,10 @@ class HomeViewTests(ManagementTestBase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'id="fees-chart"')
|
self.assertContains(response, 'id="fees-chart"')
|
||||||
|
self.assertContains(response, 'id="signups-chart"')
|
||||||
|
self.assertContains(response, "Renewal rate")
|
||||||
self.assertContains(response, "Open carts")
|
self.assertContains(response, "Open carts")
|
||||||
|
self.assertContains(response, "md:grid-cols-4")
|
||||||
|
|
||||||
def test_non_admin_staff_does_not_see_the_financial_sections(self):
|
def test_non_admin_staff_does_not_see_the_financial_sections(self):
|
||||||
self.client.force_login(self.make_coach("coach6@example.com"))
|
self.client.force_login(self.make_coach("coach6@example.com"))
|
||||||
@@ -1717,7 +1720,30 @@ class HomeViewTests(ManagementTestBase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, 'id="fees-chart"')
|
self.assertNotContains(response, 'id="fees-chart"')
|
||||||
|
self.assertNotContains(response, 'id="signups-chart"')
|
||||||
|
self.assertNotContains(response, "Renewal rate")
|
||||||
self.assertNotContains(response, "Open carts")
|
self.assertNotContains(response, "Open carts")
|
||||||
|
self.assertContains(response, "md:grid-cols-3")
|
||||||
|
|
||||||
|
def test_published_news_is_shown_to_everyone(self):
|
||||||
|
item = News.objects.create(club=self.club, title="Season kickoff", body="Body.")
|
||||||
|
item.publish()
|
||||||
|
self.client.force_login(self.make_coach("coach-news-home@example.com"))
|
||||||
|
|
||||||
|
response = self.club_get("home")
|
||||||
|
|
||||||
|
self.assertContains(response, "Season kickoff")
|
||||||
|
|
||||||
|
def test_a_draft_or_scheduled_news_item_is_not_shown_on_the_home_page(self):
|
||||||
|
draft = News.objects.create(club=self.club, title="Still a draft", body="Body.")
|
||||||
|
scheduled = News.objects.create(club=self.club, title="Scheduled for later", body="Body.")
|
||||||
|
scheduled.publish(at=timezone.now() + datetime.timedelta(days=7))
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
response = self.club_get("home")
|
||||||
|
|
||||||
|
self.assertNotContains(response, draft.title)
|
||||||
|
self.assertNotContains(response, scheduled.title)
|
||||||
|
|
||||||
def test_upcoming_events_are_listed_in_order_and_future_only(self):
|
def test_upcoming_events_are_listed_in_order_and_future_only(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
|
|||||||
"""The at-a-glance numbers a club admin/team manager/coach would actually want:
|
"""The at-a-glance numbers a club admin/team manager/coach would actually want:
|
||||||
club_attention/club_charts/club_statistics are the exact functions
|
club_attention/club_charts/club_statistics are the exact functions
|
||||||
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
|
controlpanel/club_detail.html uses for the platform admin's per-club drill-down --
|
||||||
already club-scoped, so directly reusable for this club's own staff."""
|
already club-scoped, so directly reusable for this club's own staff. Published
|
||||||
|
news sits alongside upcoming events -- open to everyone here, same as events."""
|
||||||
|
|
||||||
template_name = "management/home.html"
|
template_name = "management/home.html"
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ class HomeView(ClubStaffRequiredMixin, TemplateView):
|
|||||||
charts=club_charts(club),
|
charts=club_charts(club),
|
||||||
groups=club_statistics(club),
|
groups=club_statistics(club),
|
||||||
upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5],
|
upcoming_events=Event.objects.filter(club=club, start__gte=timezone.now()).order_by("start")[:5],
|
||||||
|
published_news=News.objects.filter(club=club, status=News.Status.PUBLISHED, published_at__lte=timezone.now()).order_by("-published_at")[:5],
|
||||||
today=timezone.localdate(),
|
today=timezone.localdate(),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3967,6 +3967,16 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.lg\:grid-cols-3 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lg\:grid-cols-5 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.lg\:grid-cols-6 {
|
.lg\:grid-cols-6 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user