Files
RosterChief/controlpanel/templates/controlpanel/dashboard.html
Bernard Siebens adf1120358 Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend
Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-19 23:34:43 +02:00

246 lines
15 KiB
HTML

{% extends "controlpanel/base.html" %}
{% load static lucide %}
{% block panel_title %}Platform health{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">platform</span>
<span class="text-edge">|</span>
<span>{{ totals.clubs }} club{{ totals.clubs|pluralize }} live</span>
<span class="text-edge">|</span>
<span>{{ totals.members }} member{{ totals.members|pluralize }}</span>
{% endblock breadcrumb %}
{% block strip_right %}
{% if failed_jobs %}
<span class="text-club-dark">{{ failed_jobs|length }} job failure{{ failed_jobs|length|pluralize }} · 24h</span>
{% endif %}
{% endblock strip_right %}
{% block actions %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=14 %} Create club</a>
{% endblock actions %}
{% block panel %}
{# --- KPI row ------------------------------------------------------- #}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">clubs live</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.clubs }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.archived_clubs }} archived</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">members</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.members }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.admins }} club admin{{ totals.admins|pluralize }}</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dues owed</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">€{{ attention.dues_owed|floatformat:0 }}</div>
<div class="mt-1.5 font-mono text-[11px] {% if attention.dues_overdue %}text-club-dark{% else %}text-muted{% endif %}">{{ attention.dues_in_grace }} in grace · {{ attention.dues_overdue }} overdue</div>
</div>
<div class="card p-3.5 {% if attention.clubs_without_season %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">no season</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if attention.clubs_without_season %}text-warn{% else %}text-ink{% endif %}">{{ attention.clubs_without_season }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">can't take signups</div>
</div>
<div class="card p-3.5 {% if attention.dormant_clubs %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dormant clubs</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if attention.dormant_clubs %}text-warn{% else %}text-ink{% endif %}">{{ attention.dormant_clubs }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">no events 30d</div>
</div>
<div class="card p-3.5 {% if failed_jobs %}border-club{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">failed jobs</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if failed_jobs %}text-club{% else %}text-ink{% endif %}">{{ failed_jobs|length }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">last 24h · <a class="link link-hover" href="{% url 'controlpanel:jobs' %}">jobs</a></div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[1.6fr_1fr]">
{# --- left column ------------------------------------------------ #}
<div class="flex flex-col gap-4">
<div class="card p-4">
<div class="mb-3 flex items-center">
{# No hand-written colour key here: Chart.js renders its own real, correctly-swatched legend below the chart -- a second, uncoloured text hint duplicating it just reads as broken. #}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Signups · {{ charts.signups|length }} months</span>
</div>
<div class="h-44">
<canvas id="signups-chart"></canvas>
</div>
</div>
<div class="card flex flex-col">
<div class="flex items-center border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Club health</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">sorted by risk</span>
</div>
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Active members</th>
<th>Events 30d</th>
<th>Plan</th>
<th>Risk</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td><a class="link link-hover font-semibold text-ink" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a></td>
<td class="font-mono tabular-nums">{{ club.active_members }}</td>
<td class="font-mono tabular-nums {% if not club.upcoming_events %}text-club-dark{% endif %}">{{ club.upcoming_events }}</td>
<td class="font-mono">{{ club.plan_name|default:"—" }}</td>
<td>
{% if club.risk == "high" %}
<span class="badge badge-error" title="{{ club.risk_reason }}">high</span>
{% elif club.risk == "watch" %}
<span class="badge badge-warning" title="{{ club.risk_reason }}">watch</span>
{% else %}
<span class="badge badge-success" title="{{ club.risk_reason }}">ok</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">No clubs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Onboarding</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{% for step in funnel %}
<div>
<div class="mb-1 flex items-center gap-1.5 font-mono text-[11px] text-muted">{% lucide step.icon size=12 %} {{ step.label }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink tabular-nums">{{ step.count }}</div>
<progress class="progress mt-1.5 {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %}" value="{{ step.count }}" max="{{ funnel.0.count|default:1 }}"></progress>
</div>
{% endfor %}
</div>
</div>
</div>
{# --- right column ------------------------------------------------ #}
<div class="flex flex-col gap-4">
<div class="rounded bg-ink p-4 text-white">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] uppercase">Alerts</div>
<div class="flex flex-col gap-2.5 font-mono text-xs">
{% if attention.renewals_pending %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ attention.renewals_pending }} subscription{{ attention.renewals_pending|pluralize }} awaiting renewal</div>
<div class="text-on-dark-dim">renew_subscriptions may be stalled</div>
</div>
{% endif %}
{% if attention.dues_overdue %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ attention.dues_overdue }} club{{ attention.dues_overdue|pluralize }} overdue on platform fees</div>
<div class="text-on-dark-dim">past grace — see billing</div>
</div>
{% endif %}
{% for run in job_log %}
{% if run.status == "failure" %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ run.name }} failed</div>
<div class="text-on-dark-dim">{{ run.started_at|date:"H:i" }} · {{ run.error|truncatechars:60|default:"see the Jobs tab" }}</div>
</div>
{% endif %}
{% endfor %}
{% if attention.admins_pending_mfa %}
<div class="border-l-2 border-warn pl-2.5">
<div>{{ attention.admins_pending_mfa }} admin{{ attention.admins_pending_mfa|pluralize }} without MFA</div>
<div class="text-on-dark-dim">locked out until they enrol</div>
</div>
{% endif %}
{% if attention.clubs_unbilled %}
<div class="border-l-2 border-warn pl-2.5">
<div>{{ attention.clubs_unbilled }} active club{{ attention.clubs_unbilled|pluralize }} on no plan</div>
<div class="text-on-dark-dim">using the platform for free</div>
</div>
{% endif %}
{% if not attention.renewals_pending and not attention.dues_overdue and not attention.admins_pending_mfa and not attention.clubs_unbilled and not failed_jobs %}
<div class="text-on-dark-dim">Nothing needs attention.</div>
{% endif %}
</div>
</div>
<div class="card p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Feature adoption</div>
<div class="flex flex-col gap-2.5 font-mono text-[11px] text-slate">
{% for flag in flags %}
<div>
<div class="mb-1 flex justify-between">
<span>{{ flag.name }}</span>
<span>{% if flag.overridden %}{{ flag.everyone|yesno:"everyone,off" }}{% else %}{{ flag.clubs }}/{{ totals.clubs }}{% endif %}</span>
</div>
<div class="h-1.5 bg-rule">
<div class="h-full bg-ink" style="width:{% if flag.overridden %}100{% else %}{% widthratio flag.clubs totals.clubs 100 %}{% endif %}%"></div>
</div>
</div>
{% empty %}
<div class="text-muted">No feature flags yet.</div>
{% endfor %}
</div>
</div>
<div class="card flex-1 p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Job log</div>
<div class="flex flex-col gap-1.5 font-mono text-[11px]">
{% for run in job_log %}
<div class="flex gap-2">
<span class="text-dim">{{ run.started_at|date:"H:i" }}</span>
{% if run.status == "success" %}
<span class="text-ok-text">ok</span>
{% elif run.status == "failure" %}
<span class="text-club-dark">err</span>
{% else %}
<span class="text-info-text">···</span>
{% endif %}
<span class="text-slate">{{ run.name }}{% if run.detail %} · {{ run.detail|truncatechars:40 }}{% endif %}</span>
</div>
{% empty %}
<div class="text-muted">No job runs recorded yet.</div>
{% endfor %}
</div>
<a class="mt-3 self-start font-mono text-[11px] text-club-dark hover:underline" href="{% url 'controlpanel:jobs' %}">View all jobs →</a>
</div>
</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);
const ink = "#3A4658";
const grid = "rgba(58,70,88,.12)";
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: "#0B1220"},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: "#14B8E8"},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
scales: {
x: {stacked: true, ticks: {color: ink, font: {family: "IBM Plex Mono", size: 10}}, grid: {display: false}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0, font: {family: "IBM Plex Mono", size: 10}}, grid: {color: grid}},
},
},
});
})();
</script>
{% endblock extra_body %}