The club table was rebuilt (logo, status badges, Plan/Dues columns, an Edit
action) and the dashboard dropped the second chart, so three render tests were
asserting columns and a canvas that no longer exist. Updated to the current
layout — the service-level tests were already correct, since the annotations they
check still exist even where the template stopped rendering them.
Worked the renewals KPI into the billing card: "N awaiting renewal", shown only
when non-zero. It should sit at 0 in normal running — the cron renews clubs 30
days out and they fall past the horizon — so a number here means the job has
stopped and a club is about to use the platform free, which nothing else on the
page reveals because nothing has been billed yet.
Fixed two things in the WIP table while here: a debug line that printed the raw
grace/period/owed values into the Dues cell, and a missing {% empty %} clause
(so an empty list showed a headed table with no "no clubs" row, and empty_message
was dead). Removed the stale commented-out copy of the old table.
Answers "auto-renewed but unpaid?": it is not a special case. Renewal opens an
ordinary unpaid Due, which flows unpaid -> grace -> overdue -> archive like any
period — so the safety net that the never-billed club slipped past now fires,
because there is a due to be overdue. Tested both ways.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
11 KiB
HTML
200 lines
11 KiB
HTML
{% extends "controlpanel/base.html" %}
|
|
{% load static lucide %}
|
|
|
|
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
|
|
{% block subheading %}Welcome back {{ user.member.first_name }} · {% now "d b Y" %}{% endblock subheading %}
|
|
|
|
{% block actions %}
|
|
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
|
|
{% endblock actions %}
|
|
|
|
{% block panel %}
|
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
|
|
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div>
|
|
<div class="text-xs opacity-60">Not accessible but data maintained</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div>
|
|
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
|
|
<div class="text-xs opacity-60">No events scheduled next 30 days</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
|
|
<div class="text-xs opacity-60">Admins without MFA configured</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}">
|
|
<div class="card-body p-4">
|
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
|
|
<div class="text-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div>
|
|
<div class="text-xs opacity-60">
|
|
{{ attention.dues_in_grace }} in grace ·
|
|
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
|
{% comment %}
|
|
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
|
|
then they fall past the horizon. A number that lingers here means the job has
|
|
stopped and a club is about to use the platform for free — which no other
|
|
figure on this page reveals, because nothing has been billed yet.
|
|
{% endcomment %}
|
|
{% if attention.renewals_pending %}
|
|
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
|
<div class="card bg-base-100 shadow">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
|
<p class="text-sm opacity-70">New members against returning ones, across every club.</p>
|
|
<div class="h-56">
|
|
<canvas id="signups-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
|
|
<p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
|
|
<div class="mt-2 space-y-3">
|
|
{% for step in funnel %}
|
|
<div>
|
|
<div class="mb-1 flex items-center justify-between text-sm">
|
|
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
|
|
<span class="font-semibold tabular-nums">{{ step.count }}</span>
|
|
</div>
|
|
<progress class="progress {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %} w-full" value="{{ step.count }}"
|
|
max="{{ funnel.0.count }}"></progress>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow">
|
|
<div class="card-body">
|
|
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
|
|
{% include "controlpanel/_club_health_table.html" %}
|
|
</div>
|
|
</div>
|
|
{% endblock panel %}
|
|
|
|
{% block extra_body %}
|
|
{{ charts|json_script:"chart-data" }}
|
|
<script src="{% static 'js/chart.js' %}"></script>
|
|
<script>
|
|
(() => {
|
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
|
|
|
// Chart.js paints to a canvas, so it cannot inherit daisyUI's colours the way the
|
|
// rest of the page does — they are CSS variables. Read the computed values, and
|
|
// rebuild when the theme attribute changes, or the charts keep the light palette
|
|
// after a switch to dark.
|
|
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
|
|
|
const render = () => {
|
|
const ink = css("--color-base-content", "#333");
|
|
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
|
|
|
// Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of
|
|
// them: the axis is rounded to keep the labels short, but the tooltip keeps the
|
|
// cents — rounding a euro amount someone is reading off a chart is a lie.
|
|
const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0});
|
|
const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2});
|
|
|
|
const build = (id, label, series, colour, type, money) =>
|
|
new Chart(document.getElementById(id), {
|
|
type,
|
|
data: {
|
|
labels: series.map((point) => point.month),
|
|
datasets: [{label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {display: false},
|
|
// The tooltip carries the unit too: an axis in euros and a bare
|
|
// number on hover reads as two different quantities.
|
|
tooltip: money ? {callbacks: {label: (item) => exactEuros.format(item.parsed.y)}} : {},
|
|
},
|
|
scales: {
|
|
x: {ticks: {color: ink}, grid: {color: grid}},
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: {color: grid},
|
|
ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Stacked, so the bar height stays "signups this month" while the split shows
|
|
// where they came from. New is per club: joining a second club is a new
|
|
// membership there, even for someone who has been on the platform for years.
|
|
const signups = new Chart(document.getElementById("signups-chart"), {
|
|
type: "bar",
|
|
data: {
|
|
labels: data.signups.map((point) => point.month),
|
|
datasets: [
|
|
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
|
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
|
],
|
|
},
|
|
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}},
|
|
},
|
|
},
|
|
});
|
|
|
|
return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
|
|
};
|
|
|
|
let charts = render();
|
|
|
|
// The theme toggle sets data-theme on <html>; "auto" removes it entirely, so watch
|
|
// the attribute rather than listening for a click.
|
|
new MutationObserver(() => {
|
|
charts.forEach((chart) => chart.destroy());
|
|
charts = render();
|
|
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
|
})();
|
|
</script>
|
|
{% endblock extra_body %}
|