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
243 lines
14 KiB
HTML
243 lines
14 KiB
HTML
{% extends "controlpanel/base.html" %}
|
|
{% load static lucide %}
|
|
|
|
{% block panel_title %}{{ club.name }}{% endblock panel_title %}
|
|
|
|
{% block breadcrumb %}
|
|
<a class="hover:text-ink" href="{% url 'controlpanel:club_list' %}">clubs</a>
|
|
<span>/</span>
|
|
<span class="text-ink">{{ club.slug }}</span>
|
|
<span>/</span>
|
|
<span>settings</span>
|
|
<div class="flex-1"></div>
|
|
<span>id {{ club.pk|stringformat:"s"|slice:":8" }}</span>
|
|
<span class="text-edge">|</span>
|
|
<span>created {{ club.created|date:"Y-m-d" }}</span>
|
|
{% endblock breadcrumb %}
|
|
|
|
{% block actions %}
|
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
|
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app" target="_blank" rel="noopener">{% lucide "external-link" size=14 %} Open</a>
|
|
{% if club.is_archived %}
|
|
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
|
{% csrf_token %}
|
|
<button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=14 %} Restore</button>
|
|
</form>
|
|
{% else %}
|
|
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
|
|
{% csrf_token %}
|
|
<button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=14 %} Archive</button>
|
|
</form>
|
|
{% endif %}
|
|
{% endblock actions %}
|
|
|
|
{% block panel %}
|
|
{# --- club header --------------------------------------------------- #}
|
|
<div class="flex items-start gap-4">
|
|
{% comment %}
|
|
The ring is the club's own primary colour, same as on the club's own subdomain --
|
|
but the control panel never injects a page-wide --color-primary override (it must
|
|
not dress itself up as the club), so it's set here as a locally-scoped custom
|
|
property instead: it only reaches this element, not the rest of the panel's
|
|
buttons and badges.
|
|
{% endcomment %}
|
|
{% if club.logo %}
|
|
<div class="crest ring-primary flex h-[68px] w-[68px] shrink-0 items-center justify-center overflow-hidden bg-ink" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}>
|
|
<img class="h-full w-full object-cover" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
|
</div>
|
|
{% else %}
|
|
<div class="crest flex h-[68px] w-[68px] shrink-0 items-center justify-center bg-ink">
|
|
<span class="font-display text-xl font-extrabold text-white">{{ club.initials }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="flex-1">
|
|
<div class="font-display text-2xl leading-none font-extrabold text-ink uppercase">{{ club.name }}</div>
|
|
<div class="mt-1.5 font-mono text-xs text-muted">
|
|
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %}
|
|
{% if club.is_archived %}
|
|
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if club.is_archived %}
|
|
<div class="alert alert-warning">
|
|
{% lucide "alert-triangle" size=18 %}
|
|
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if attention.no_season %}
|
|
<div class="alert alert-warning">
|
|
{% lucide "calendar-x" size=18 %}
|
|
<span>No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% comment %}
|
|
The club's own numbers that should be zero. Teams without a manager is a defect in
|
|
the club's setup, not a statistic: with nobody in a management position the access
|
|
service grants no authority over that team, so nobody can pick the squad.
|
|
{% endcomment %}
|
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
{% comment %}<div class="card p-3.5 border-club">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Outstanding</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-club tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
|
</div>{% endcomment %}
|
|
<div class="card p-3.5 {% if attention.teams_without_manager %}border-club{% endif %}">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Teams without coach</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.teams_without_manager %}text-club{% else %}text-ink{% endif %}">{{ attention.teams_without_manager }}</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">Teams nobody can pick a squad for</div>
|
|
</div>
|
|
|
|
<div class="card p-3.5 {% if attention.unrostered %}border-warn{% endif %}">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Unrostered members</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.unrostered %}text-warn{% else %}text-ink{% endif %}">{{ attention.unrostered }}</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">Active members on no team</div>
|
|
</div>
|
|
|
|
<div class="card p-3.5 {% if attention.pending_approvals %}border-warn{% endif %}">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Pending</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.pending_approvals %}text-warn{% else %}text-ink{% endif %}">{{ attention.pending_approvals }}</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">Memberships awaiting approval</div>
|
|
</div>
|
|
|
|
<div class="card p-3.5">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">New members</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">{{ attention.new_members }}</div>
|
|
{# First season at this club — someone returning after a year away is a renewal. #}
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">First season at this club</div>
|
|
</div>
|
|
|
|
<div class="card p-3.5">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Renewal rate</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">
|
|
{% if attention.renewal_rate is None %}N/A{% else %}{{ attention.renewal_rate }}%{% endif %}
|
|
</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">
|
|
{% if attention.renewal_rate is None %}
|
|
No previous season
|
|
{% else %}
|
|
<progress class="progress mt-1 {% 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 class="card p-3.5">
|
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Attendance rate</div>
|
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">
|
|
{% if attention.attendance.turnout is None %}N/A{% else %}{{ attention.attendance.turnout }}%{% endif %}
|
|
</div>
|
|
<div class="mt-1.5 font-mono text-[11px] text-muted">
|
|
{% if attention.attendance.turnout is None %}
|
|
No events this season
|
|
{% else %}
|
|
<progress class="progress mt-1 {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.attendance.turnout }}" max="100"></progress>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# --- two-column layout: left = charts/stats, right = flags/admins/billing --- #}
|
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
<div class="flex flex-col gap-4">
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide "user-plus" size=16 %} Signups per month</div>
|
|
<p class="mb-2 font-mono text-[11px] text-muted">New members against returning ones.</p>
|
|
<div class="h-48">
|
|
<canvas id="signups-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4">
|
|
<div class="mb-3 flex items-center gap-2 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide "wallet" size=16 %} Club fee status this season</div>
|
|
<div class="h-48">
|
|
<canvas id="fees-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
{% for group in groups %}
|
|
<div class="card flex flex-col">
|
|
<div class="flex items-center gap-2 border-b border-line px-4 py-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide group.icon size=16 %} {{ group.title }}</div>
|
|
<dl class="px-4">
|
|
{% for label, value in group.stats %}
|
|
<div class="flex items-center justify-between py-2">
|
|
<dt class="text-sm text-muted">{{ label }}</dt>
|
|
<dd class="font-mono font-semibold text-ink tabular-nums">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
|
</div>
|
|
{% endfor %}
|
|
</dl>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% include "controlpanel/_club_home_location_card.html" %}
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
{% include "controlpanel/_club_features_card.html" %}
|
|
{% include "controlpanel/_club_admins_card.html" %}
|
|
{% include "controlpanel/_club_billing_card.html" %}
|
|
</div>
|
|
</div>
|
|
{% endblock panel %}
|
|
|
|
{% block extra_body %}
|
|
{{ charts|json_script:"chart-data" }}
|
|
<script src="{% static 'js/chart.js' %}"></script>
|
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
|
<script>
|
|
(() => {
|
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
|
const ink = "#3A4658";
|
|
const grid = "rgba(58,70,88,.12)";
|
|
|
|
// Stacked: the bar height stays "signups this month" while the split shows where
|
|
// they came from. Side-by-side bars would answer a different question.
|
|
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: "#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}},
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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.
|
|
const fees = new Chart(document.getElementById("fees-chart"), {
|
|
type: "pie",
|
|
data: {
|
|
labels: data.fees.map((slice) => slice.label),
|
|
datasets: [
|
|
{
|
|
data: data.fees.map((slice) => slice.value),
|
|
backgroundColor: ["#14A05A", "#F0A22E", "#E4002B", "#8B95A4"],
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {legend: {position: "right", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
|
|
},
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock extra_body %}
|