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
This commit is contained in:
2026-08-19 23:34:43 +02:00
parent bff685966d
commit adf1120358
157 changed files with 20342 additions and 4008 deletions

View File

@@ -0,0 +1,112 @@
{% load static lucide ui %}
{% comment %}
Standalone shell for every sitewide allauth screen -- login, password change/reset,
MFA, passkeys, recovery codes -- plus 403.html/maintenance.html, rendered whenever
there is no club tenant (see club/context_processors.py: this is
PLATFORM_BASE_TEMPLATE). Same industrial design language as controlpanel/base.html --
dark ink chrome, Barlow/Barlow Condensed/IBM Plex Mono, assets/controlpanel.css --
but deliberately simpler: one centred card on a dark page, not a full command-bar
app shell, since these are public entrance screens for the whole platform (every
club admin and base-domain account), not the control panel itself.
Block names match what templates/_base.html used to provide (head_title, extra_head,
main, extra_body) rather than inventing new ones: templates/allauth/layouts/base.html
and templates/403.html/maintenance.html target those names directly, and both are
shared with the club-branded skin (_club_base.html, still on assets/app.css and real
daisyUI, untouched) -- give them a different block name here and they would have
nothing to override on this side of the fork.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block head_title %}{% endblock head_title %} &middot; RosterChief
</title>
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="flex min-h-screen flex-col items-center gap-10 bg-ink px-4 py-14 font-sans text-slate">
{# Explicit bg-ink here too, not just on <body>: the white-on-dark brand mark must stay legible on its own. #}
<div class="flex w-full max-w-md items-center justify-between gap-4 bg-ink py-1">
<a class="flex min-w-0 shrink-0 items-center gap-2.5" href="/">
{# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this page. #}
<img class="h-8 w-8 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="32" height="32">
<span class="font-display text-xl font-extrabold tracking-[.1em] text-white uppercase">RosterChief</span>
</a>
<a class="flex shrink-0 items-center gap-1.5 font-mono text-xs text-on-dark-dim hover:text-white" href="/">
{% lucide "arrow-left" size=14 %} Back to site
</a>
</div>
<main class="flex w-full max-w-md flex-1 flex-col justify-center gap-4">
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert {{ alert.css }}" role="alert">
{% lucide alert.icon size=18 %}
<div>
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</main>
<p class="font-mono text-[11px] text-on-dark-faint">&copy; {% now "Y" %} RosterChief</p>
{% comment %}
A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
the same field (templates/allauth/elements/fields.html). The boxed .otp layout
only fits six, so past that this falls back to a plain .input-lg rather than
letting the text spill out of the boxes.
The real <input>'s own text is invisible (assets/controlpanel.css: `.otp input`
is `color: transparent`, only its caret shows) -- this writes each typed
character into its matching <span> directly instead, which is exact by
construction. A pure-CSS letter-spacing overlay (spacing the real glyphs to
match the box pitch) was tried first and drifted more with every character
typed, in a way font-metric tuning couldn't reliably fix.
{% endcomment %}
<script>
document.querySelectorAll("[data-otp]").forEach((otp) => {
const input = otp.querySelector("input");
const boxes = otp.querySelectorAll("span");
if (!input) return;
const fit = () => {
const boxed = input.value.length <= boxes.length;
otp.classList.toggle("otp", boxed);
otp.classList.toggle("otp-lg", boxed);
boxes.forEach((box, index) => {
box.classList.toggle("hidden", !boxed);
box.textContent = boxed ? input.value[index] || "" : "";
});
input.classList.toggle("input", !boxed);
input.classList.toggle("input-lg", !boxed);
};
input.addEventListener("input", fit);
fit();
});
</script>
{% comment %}
allauth puts page-level scripts and out-of-form markup here -- notably the
hidden `mfa_login` form the passkey button submits on the login page. Without
this block that form is never rendered and "Sign in with a passkey" is dead.
{% endcomment %}
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

View File

@@ -4,38 +4,36 @@
Club-scoped admins, and the modals to add one / confirm removing one. Included with
`club`, `admins`, `admin_form` already in context.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "shield-user" size=18 %} Club admins</h2>
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Add admin</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<div class="card flex flex-col">
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Club admins</span>
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_admin_add_modal').showModal()">{% lucide "user-plus" size=14 %} Add admin</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
<td class="font-semibold text-ink">{{ role.member }}</td>
<td class="font-mono text-xs text-muted">{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<button class="btn btn-outline btn-xs gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=12 %} Remove</button>
</td>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ role.pk|dom_id:"admin_remove_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Remove</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@@ -6,27 +6,27 @@
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
<div class="card flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Plan &amp; billing</span>
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
</button>
{% if not subscription %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
{% lucide "hourglass" size=14 %} Start trial
</button>
{% if not subscription %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
{% lucide "hourglass" size=14 %} Start trial
</button>
{% endif %}
{% if subscription %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</button>
{% endif %}
</div>
{% endif %}
{% if subscription %}
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
</button>
{% endif %}
</div>
</div>
<div class="flex flex-col gap-3 px-4 py-3">
{% comment %}
Paying up does not un-archive a club on its own -- restoring is a deliberate act,
because a club can also be archived by hand for reasons that have nothing to do
@@ -34,20 +34,20 @@
instead of something you have to remember to go and check.
{% endcomment %}
{% if club.is_archived and dues_settled %}
<div class="alert alert-success alert-sm mb-2">
<div class="alert alert-success">
{% lucide "circle-check" size=16 %}
<span>This club is archived but owes nothing. Reactivating will restore access and open its next period.</span>
</div>
{% endif %}
{% if not subscription %}
<p class="text-sm opacity-70">This club is not billed for anything. Put it on a plan to start.</p>
<p class="text-sm text-muted">This club is not billed for anything. Put it on a plan to start.</p>
{% else %}
<p class="text-sm opacity-70">
On plan <strong>{{ subscription.plan.name }}</strong>.
<p class="text-sm text-slate">
On plan <strong class="text-ink">{{ subscription.plan.name }}</strong>.
{% if subscription.trial_ends_at %}
<span class="badge badge-info badge-sm gap-1">{% lucide "hourglass" size=12 %} Trial</span>
On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to <strong>{{ subscription.post_trial_plan.name }}</strong>.
On trial until {{ subscription.trial_ends_at|date:"j M Y" }}, then switches to <strong class="text-ink">{{ subscription.post_trial_plan.name }}</strong>.
{% endif %}
{{ subscription.plan.duration_months }}-month periods, archived {{ subscription.plan.grace_days }} days after a period starts if unpaid.
{% if subscription.auto_renew %}
@@ -76,28 +76,32 @@
<tbody>
{% for due in dues %}
<tr>
<td>
<td class="min-w-[220px] whitespace-nowrap">
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">{{ due.plan.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}</div>
<div class="font-mono text-[11px] text-muted">
{{ due.plan.name }} · {{ due.invoice.number }}
<div>grace to {{ due.grace_until|date:"j M Y" }}</div>
</div>
</td>
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
<td class="text-right font-mono tabular-nums">€{{ due.amount|floatformat:2 }}</td>
<td class="text-right font-mono tabular-nums">€{{ due.amount_paid|floatformat:2 }}</td>
<td class="text-right">
{% if due.status == "paid" %}
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
{% elif due.status == "waived" %}
<span class="badge badge-outline gap-1">{% lucide "check" size=12 %} Waived</span>
<span class="badge badge-ghost gap-1">{% lucide "check" size=12 %} Waived</span>
{% elif due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right flex flex-row gap-2 justify-end">
<td class="text-right">
<div class="flex flex-row flex-wrap items-center justify-end gap-2">
{% if due.is_owing %}
<button class="btn btn-primary btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Add payment</button>
{% if not due.payments.all %}
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
{% csrf_token %}
@@ -105,11 +109,12 @@
</form>
{% endif %}
{% endif %}
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
<a class="btn btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</div>
</td>
</tr>
{% for payment in due.payments.all %}
<tr class="text-xs opacity-70">
<tr class="font-mono text-[11px] text-muted">
<td colspan="2" class="pl-8">
{% lucide "corner-down-right" size=12 %}
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
@@ -120,7 +125,7 @@
{% endfor %}
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No periods billed yet.</td>
<td colspan="5" class="text-center text-muted">No periods billed yet.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -4,42 +4,34 @@
Which feature flags apply to this club. Included with `club`, `flags` (from
`flags_for_club`) already in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card flex flex-col">
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Feature flags</span>
<a class="font-mono text-[11px] text-club-dark hover:underline" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=12 class="inline -mt-0.5" %} manage</a>
</div>
<div>
{% for entry in flags %}
<div class="flex items-center gap-3 border-b border-rule px-4 py-2.5 last:border-b-0">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs text-ink">{{ entry.flag.name }}</div>
<div class="text-xs text-muted">{{ entry.flag.note|default:"—" }}</div>
</div>
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %} shrink-0">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-xs shrink-0 gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=14 %} On{% else %}{% lucide "toggle-left" size=14 %} Off{% endif %}
</button>
</form>
{% endif %}
</div>
{% empty %}
<div class="px-4 py-6 text-center text-sm text-muted">No features defined yet.</div>
{% endfor %}
</div>
</div>

View File

@@ -28,64 +28,56 @@
{% for club in clubs %}
<tr>
<td>
<div class="flex flex-row items-center gap-4">
<div>
{% if club.logo %}
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-12 rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
</div>
<div class="flex flex-row items-center gap-3">
{% if club.logo %}
<img class="h-10 w-10 shrink-0 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
<div class="crest flex h-10 w-10 shrink-0 items-center justify-center bg-ink">
<span class="font-display text-xs font-extrabold text-white">{{ club.initials }}</span>
</div>
{% endif %}
<div class="flex flex-col gap-1">
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}</div>
<div class="flex flex-col">
<a class="link link-hover font-semibold text-ink" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
<div class="font-mono text-[11px] text-muted">{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}</div>
</div>
</div>
</td>
<td>
<div class="flex flex-row gap-2">
<div class="flex flex-row gap-1.5">
{% if club.is_archived %}
<span class="badge badge-warning">{% lucide "archive" size=14 %} archived</span>
<span class="badge badge-warning badge-sm">{% lucide "archive" size=12 %} Archived</span>
{% else %}
{% if not club.has_season %}
<span class="badge badge-warning">{% lucide "calendar-x" size=14 %} no seasons</span>
<span class="badge badge-warning badge-sm">{% lucide "calendar-x" size=12 %} No seasons</span>
{% endif %}
{% if not club.upcoming_events %}
<span class="badge badge-ghost badge-outline">{% lucide "moon-star" size=14 %}dormant</span>
<span class="badge badge-ghost badge-sm">{% lucide "moon-star" size=12 %} Dormant</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right tabular-nums">{{ club.active_members }}</td>
<td class="text-right tabular-nums">
<div class="flex flex-row gap-2 items-center justify-end">
<td class="text-right font-mono tabular-nums">{{ club.active_members }}</td>
<td class="text-right">
<div class="flex flex-row items-center justify-end gap-1.5">
{% if not club.admin_count %}
<span class="text-error">{% lucide "triangle-alert" size=16 %}</span>
<span class="text-club-dark">{% lucide "triangle-alert" size=14 %}</span>
{% endif %}
<span class="{% if not club.admin_count %}font-bold text-error{% endif %}">{{ club.admin_count }}</span>
<span class="font-mono tabular-nums {% if not club.admin_count %}font-bold text-club-dark{% endif %}">{{ club.admin_count }}</span>
</div>
</td>
<td class="text-right tabular-nums">{{ club.team_count }}</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right font-mono tabular-nums">{{ club.team_count }}</td>
<td class="text-right font-mono tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right">
{% if club.plan_name %}
<span class="badge badge-accent">{{ club.plan_name|lower }}</span>
{% else %}
-
{% endif %}
<td class="text-right font-mono text-xs text-muted">
{{ club.plan_name|lower|default:"—" }}
</td>
<td class="text-right">
<div class="flex flex-row gap-2 items-center justify-end">
<div class="flex flex-row items-center justify-end gap-1.5">
{% if not club.dues_owed %}
{% if club.plan_name %}
{% comment %}
@@ -94,40 +86,36 @@
for a paid period AND a waived one (both cover the club, they just differ in
how). No covered period at all (only cancelled dues, say) shows a dash.
{% endcomment %}
<div class="flex flex-col items-end gap-1">
{% if club.covered_status == "waived" %}
<span class="badge badge-ghost badge-outline">waived</span>
{% elif club.covered_until %}
<span class="badge badge-success">paid</span>
{% else %}
-
{% endif %}
</div>
{% if club.covered_status == "waived" %}
<span class="badge badge-ghost badge-sm">Waived</span>
{% elif club.covered_until %}
<span class="badge badge-success badge-sm">Paid</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% else %}
-
<span class="text-muted"></span>
{% endif %}
{% else %}
<span class="font-semibold">€{{ club.dues_owed|floatformat:2 }}</span>
<span class="font-mono font-semibold text-ink tabular-nums">€{{ club.dues_owed|floatformat:2 }}</span>
{% if club.dues_grace_until < today %}
<span class="badge badge-error">overdue</span>
<span class="badge badge-error badge-sm">Overdue</span>
{% elif club.dues_period_end < today %}
<span class="badge badge-warning">grace</span>
<span class="badge badge-warning badge-sm">Grace</span>
{% endif %}
{% endif %}
</div>
</td>
<td class="text-right">
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
</td>
<td class="text-right font-mono text-xs whitespace-nowrap text-muted">{{ club.covered_until|date:"j M Y"|default:"—" }}</td>
<td>
<a class="btn btn-outline btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
<a class="btn btn-outline btn-xs gap-1" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=12 %} Edit</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
<td colspan="10" class="text-center text-muted">{{ empty_message|default:"No clubs yet." }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -6,31 +6,31 @@
Locations page shows, no separate sync step involved. Included with `club`,
`home_location`, `home_location_form` already in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "map-pin" size=18 %} Home location</h2>
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()">
{% if home_location %}
{% lucide "pencil" size=16 %} Edit
{% else %}
{% lucide "plus" size=16 %} Set home location
{% endif %}
</button>
</div>
<div class="card flex flex-col">
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Home location</span>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()">
{% if home_location %}
{% lucide "pencil" size=14 %} Edit
{% else %}
{% lucide "plus" size=14 %} Set home location
{% endif %}
</button>
</div>
<div class="px-4 py-3">
{% if home_location %}
<dl class="divide-y divide-base-200">
<dl>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">Name</dt>
<dd class="font-semibold">{{ home_location.name }}</dd>
<dt class="text-sm text-muted">Name</dt>
<dd class="font-semibold text-ink">{{ home_location.name }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">Address</dt>
<dd>{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd>
<dt class="text-sm text-muted">Address</dt>
<dd class="text-right text-sm text-slate">{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd>
</div>
</dl>
{% else %}
<p class="text-sm opacity-60">Not set yet. Once set, events at this location can be recognised as home games.</p>
<p class="text-sm text-muted">Not set yet. Once set, events at this location can be recognised as home games.</p>
{% endif %}
</div>
</div>

View File

@@ -1,37 +1,17 @@
{% load lucide %}
{% comment %}
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
ends up reachable on a desktop and invisible on a phone.
The command bar's tabs. One place, included by base.html, so a new section is a new
<li>-equivalent here and nowhere else -- see design_handoff_rosterchief_platform/README.md
for the "platform / clubs / features / billing / admins / jobs" tab order this mirrors.
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
Active tab: `bg-steel` fill with a 2px `ice` bottom border, per the handoff's command-bar
spec. Inactive tabs are plain `text-on-dark-dim`.
{% endcomment %}
<li>
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
{% lucide "layout-dashboard" size=16 %} Dashboard
</a>
</li>
<li>
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
{% lucide "building-2" size=16 %} Clubs
</a>
</li>
<li>
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
{% lucide "receipt-euro" size=16 %} Billing
</a>
</li>
<li>
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
{% lucide "toggle-right" size=16 %} Features
</a>
</li>
<a class="flex h-full items-center px-3.5 {% if nav == 'dashboard' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:dashboard' %}">platform</a>
<a class="flex h-full items-center px-3.5 {% if nav == 'clubs' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:club_list' %}">clubs</a>
<a class="flex h-full items-center px-3.5 {% if nav == 'features' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:features' %}">features</a>
<a class="flex h-full items-center px-3.5 {% if nav == 'billing' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:billing' %}">billing</a>
{% if user.is_superuser %}
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
<li>
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
{% lucide "user-cog" size=16 %} Platform admins
</a>
</li>
{# Superusers only, exactly as the view is gated: a tab staff cannot follow is a lie. #}
<a class="flex h-full items-center px-3.5 {% if nav == 'admins' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:admins' %}">admins</a>
{% endif %}
<a class="flex h-full items-center px-3.5 {% if nav == 'jobs' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:jobs' %}">jobs</a>

View File

@@ -1,11 +1,13 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Platform admins{% endblock heading %}
{% block panel_title %}Admins{% endblock panel_title %}
{% block subheading %}
<p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p>
{% endblock subheading %}
{% block breadcrumb %}
<span class="text-ink">admins</span>
<span class="text-edge">|</span>
<span>{{ admins|length }} platform admin{{ admins|length|pluralize }}</span>
{% endblock breadcrumb %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('admin_add_modal').showModal()">{% lucide "user-plus" size=16 %} Grant access</button>
@@ -15,66 +17,80 @@
{% url 'controlpanel:admin_add' as admin_add_url %}
{% include "controlpanel/_modal_form.html" with modal_id="admin_add_modal" title="Grant platform access" form=admin_form action_url=admin_add_url submit_label="Grant access" submit_icon="user-plus" blurb="Platform admins can manage every club. They must set up two-factor authentication before they can sign in." %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Staff</th>
<th>Superuser</th>
<th>Last login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
<tr>
<td>
<div class="font-medium">{{ admin.email }}</div>
{% if admin.pk == user.pk %}
<div class="text-xs opacity-60">That's you</div>{% endif %}
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
<button class="btn btn-sm gap-1 {% if admin.is_staff %}btn-success{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "user" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button>
</form>
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
<button class="btn btn-sm gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-outline{% endif %}" type="submit">
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}{% lucide "x" size=14 %} No{% endif %}
</button>
</form>
</td>
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right">
<button class="btn btn-error btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No platform admins.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for admin in admins %}
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
{% endfor %}
<div class="card">
<div class="border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Platform admins</span>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Staff</th>
<th>Superuser</th>
<th>Last login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
<tr class="{% if admin.pk == user.pk %}bg-row-focus{% endif %}">
<td>
<div class="font-medium text-ink">{{ admin.email }}</div>
{% if admin.pk == user.pk %}
<span class="mt-0.5 inline-flex w-fit items-center gap-1 rounded-full border border-edge bg-white px-2 py-0.5 font-mono text-[10px] tracking-[.06em] text-muted uppercase">
{% lucide "badge-check" size=10 %} That's you
</span>
{% endif %}
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
<button
class="inline-flex h-[22px] w-10 items-center rounded-full px-[3px] transition-colors {% if admin.is_staff %}justify-end bg-ok{% else %}justify-start bg-edge{% endif %}"
type="submit"
aria-pressed="{% if admin.is_staff %}true{% else %}false{% endif %}"
aria-label="Toggle staff access for {{ admin.email }}"
>
<span class="h-4 w-4 rounded-full bg-white shadow"></span>
</button>
</form>
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
<button
class="inline-flex h-[22px] w-10 items-center rounded-full px-[3px] transition-colors {% if admin.is_superuser %}justify-end bg-warn{% else %}justify-start bg-edge{% endif %}"
type="submit"
aria-pressed="{% if admin.is_superuser %}true{% else %}false{% endif %}"
aria-label="Toggle superuser access for {{ admin.email }}"
>
<span class="h-4 w-4 rounded-full bg-white shadow"></span>
</button>
</form>
</td>
<td class="font-mono text-[11px] text-muted">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right">
<button class="btn btn-outline btn-error btn-sm gap-1" type="button" onclick="document.getElementById('{{ admin.pk|dom_id:"admin_revoke_modal" }}').showModal()">{% lucide "user-minus" size=14 %} Revoke</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">No platform admins.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for admin in admins %}
{% url 'controlpanel:admin_revoke' admin.pk as admin_revoke_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=admin.pk|dom_id:"admin_revoke_modal" title="Revoke platform access" body="Revoke platform access for "|add:admin.email|add:"? They will no longer be able to reach the control panel." action_url=admin_revoke_url submit_label="Revoke" submit_icon="user-minus" %}
{% endfor %}
</div>
{% endblock panel %}

View File

@@ -1,90 +1,131 @@
{% extends "_platform_base.html" %}
{% load lucide %}
{% load static lucide ui %}
{% block title %}
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %}
{% comment %}
Standalone shell for the control panel -- does NOT extend templates/_base.html or
_platform_base.html, and does not load assets/app.css or daisyUI. This surface is
platform-staff-only, desktop-only (see design_handoff_rosterchief_platform/README.md:
"Control panel ... Desktop only"), and deliberately never club-branded, so it gets its own
document shell, its own stylesheet (assets/controlpanel.css -> static/css/controlpanel.css)
and its own type system (Barlow / Barlow Condensed / IBM Plex Mono) rather than inheriting
the club-facing app's theme. No mobile nav, no theme toggle -- neither exists in the design.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block nav_toggle %}
<button class="btn btn-ghost btn-square lg:hidden" type="button" onclick="document.getElementById('mobile_nav_modal').showModal()" aria-label="Menu">
{% lucide "menu" size=20 %}
</button>
{% endblock nav_toggle %}
<title>
{% block title %}{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief{% endblock title %}
</title>
{% comment %} Kept in the navbar itself only at `lg`+, where there's no hamburger drawer to hold them instead -- see the comment on `nav_icons_class` in _base.html. {% endcomment %}
{% block nav_icons_class %}hidden items-center lg:flex{% endblock nav_icons_class %}
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
{% block menu %}
{% comment %}
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
the day the menu itself grows taller than the screen.
App-shell layout: the command bar and breadcrumb strip are pinned, <main> is the only
scrolling region -- same reasoning templates/_base.html gives for the club-facing shell.
{% endcomment %}
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
<ul class="menu w-full gap-1 p-3 mt-4">
{% include "controlpanel/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate">
<header class="flex h-[52px] shrink-0 items-center gap-5 bg-ink px-5">
<a class="flex shrink-0 items-center gap-2.5" href="{% url 'controlpanel:dashboard' %}">
{# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this bar. #}
<img class="h-7 w-7 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="28" height="28">
<span class="font-display text-base font-extrabold tracking-[.1em] text-white uppercase">RosterChief</span>
<span class="font-mono text-[11px] text-on-dark-faint">control</span>
</a>
{% block main %}
{# Below `lg` the sidebar is hidden and {% block nav_toggle %} above opens this instead. #}
<dialog id="mobile_nav_modal" class="modal modal-start lg:hidden">
<div class="modal-box h-full max-h-none w-72 max-w-[85vw] rounded-none p-0">
<div class="flex items-center justify-between border-b border-base-300 p-3">
<span class="font-roboto text-lg font-bold tracking-wider">Menu</span>
<form method="dialog">
<button class="btn btn-ghost btn-square btn-sm" aria-label="Close">{% lucide "x" size=18 %}</button>
</form>
</div>
<ul class="menu w-full gap-1 p-3">
<li>
<button type="button" data-theme-toggle>
<span data-theme-icon="light" class="hidden items-center gap-3">{% lucide "sun" size=16 %} Light theme</span>
<span data-theme-icon="dark" class="hidden items-center gap-3">{% lucide "moon" size=16 %} Dark theme</span>
<span data-theme-icon="auto" class="hidden items-center gap-3">{% lucide "sun-moon" size=16 %} Auto theme</span>
</button>
</li>
{% if has_management_access %}
<li><a href="{% url "management:home" %}">{% lucide "layout-dashboard" size=16 %} Management</a></li>
{% endif %}
{% if user.is_superuser %}
<li><a href="{% url "admin:index" %}">{% lucide "shield-cog" size=16 %} Django admin</a></li>
{% endif %}
<li class="menu-title">Navigation</li>
<nav class="flex h-full font-mono text-xs">
{% include "controlpanel/_nav_items.html" %}
</ul>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</nav>
{% if maintenance_on %}
<div class="alert alert-error mb-6">
{% lucide "wrench" size=20 %}
<span>
<strong>The platform is currently closed for maintenance.</strong>
Clubs see a maintenance page and the scheduled jobs are standing down.
</span>
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
</div>
{% endif %}
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div class="flex flex-row items-center gap-3">
{% block logo %}{% endblock logo %}
<div class="flex-1"></div>
<div class="flex flex-col gap-2 grow">
<h1 class="text-3xl font-bold">
{% block heading %}Control panel{% endblock heading %}
</h1>
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
<div class="flex items-center gap-2.5">
{% block actions %}{% endblock actions %}
</div>
{% comment %}
Native <details>/<summary> disclosure -- no JS needed for a menu this small.
Only the account/system info on the right, grouped with the status indicator.
{% endcomment %}
<details class="account-menu relative shrink-0">
<summary class="flex cursor-pointer items-center rounded px-1.5 py-1 text-on-dark-dim hover:bg-steel hover:text-white" aria-label="Account">
{% lucide "circle-user" size=18 %}
</summary>
<div class="absolute top-full right-0 z-20 mt-2 w-64 rounded border border-edge bg-white py-1.5 shadow-lg">
<div class="truncate border-b border-rule px-3 pb-2 font-mono text-xs text-muted">{{ user.email }}</div>
<a class="flex items-center gap-2.5 px-3 py-2 text-sm text-slate hover:bg-rule" href="{% url 'account_change_password' %}">{% lucide "key-round" size=15 %} Change password</a>
<a class="flex items-center gap-2.5 px-3 py-2 text-sm text-slate hover:bg-rule" href="{% url 'mfa_index' %}">{% lucide "shield-check" size=15 %} Two-factor authentication</a>
<a class="flex items-center gap-2.5 px-3 py-2 text-sm text-slate hover:bg-rule" href="{% url 'account_logout' %}">{% lucide "log-out" size=15 %} Sign out</a>
</div>
</details>
{% comment %}
Real, not decorative. Three states, most severe wins: maintenance (reflects
features.models.Maintenance, the same switch the Features tab's "Close the
platform" action controls) beats recent job failures (failed_jobs, from
controlpanel.context_processors.job_health -- see features/models.JobRun)
beats "all systems ok". Links to where you'd go to do something about it.
{% endcomment %}
{% if maintenance_on %}
<a class="flex shrink-0 items-center gap-2 font-mono text-[11px] text-club hover:underline" href="{% url 'controlpanel:features' %}">
<span class="h-[7px] w-[7px] rounded-full bg-club"></span>
maintenance mode
</a>
{% elif failed_jobs %}
<a class="flex shrink-0 items-center gap-2 font-mono text-[11px] text-warn hover:underline" href="{% url 'controlpanel:jobs' %}">
<span class="h-[7px] w-[7px] rounded-full bg-warn"></span>
{{ failed_jobs|length }} job failure{{ failed_jobs|length|pluralize }}
</a>
{% else %}
<div class="flex shrink-0 items-center gap-2 font-mono text-[11px] text-ok">
<span class="h-[7px] w-[7px] rounded-full bg-ok"></span>
all systems ok
</div>
{% endif %}
</header>
<div class="flex h-[34px] shrink-0 items-center gap-3 border-b border-edge bg-white px-5 font-mono text-[11px] text-muted">
{% block breadcrumb %}<span class="text-ink">control</span>{% endblock breadcrumb %}
<div class="flex-1"></div>
{% block strip_right %}{% endblock strip_right %}
</div>
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
{% block actions %}{% endblock actions %}
</div>
</div>
<main class="flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-[1440px] flex-col gap-4 px-7 py-6">
{% if maintenance_on %}
<div class="flex items-center gap-3 rounded border border-danger-border bg-danger-bg px-4 py-3 text-sm text-club-dark" role="alert">
{% lucide "wrench" size=18 class="shrink-0" %}
<div>
<span class="font-display font-extrabold tracking-[.04em] uppercase">Platform closed for maintenance.</span>
Every club subdomain serves a maintenance page, and the scheduled jobs stand down.
<a class="link link-hover font-semibold" href="{% url 'controlpanel:features' %}">Reopen it</a>
</div>
</div>
{% endif %}
{% block panel %}{% endblock panel %}
{% endblock main %}
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert {{ alert.css }}" role="alert">
{% lucide alert.icon size=18 %}
<div>
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block panel %}{% endblock panel %}
</div>
</main>
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

View File

@@ -1,147 +1,182 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% load i18n lucide ui %}
{% block heading %}Billing{% endblock heading %}
{% comment %}
Platform billing: RosterChief charging the clubs (not clubs charging their members --
see billing/services). Two tables: every Plan (with its dated price history) and every
Due currently owing across every club. Every action here already existed as a modal or
a link before this restyle; nothing was added or removed, only reskinned to the
industrial control-panel vocabulary (assets/controlpanel.css).
{% endcomment %}
{% block panel_title %}{% trans "Billing" %}{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">billing</span>
<span class="text-edge">|</span>
<span>{{ plans|length }} plan{{ plans|length|pluralize }}</span>
<span class="text-edge">|</span>
<span>{{ owing|length }} club{{ owing|length|pluralize }} owing</span>
{% endblock breadcrumb %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('plan_create_modal').showModal()">{% lucide "plus" size=16 %} New plan</button>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('plan_create_modal').showModal()">{% lucide "plus" size=14 %} {% trans "New plan" %}</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:plan_create' as plan_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="plan_create_modal" title="New plan" form=plan_form action_url=plan_create_url submit_label="Create plan" submit_icon="plus" box_class="max-w-2xl" two_columns=True %}
{% trans "New plan" as new_plan_title %}
{% trans "Create plan" as create_plan_label %}
{% include "controlpanel/_modal_form.html" with modal_id="plan_create_modal" title=new_plan_title form=plan_form action_url=plan_create_url submit_label=create_plan_label submit_icon="plus" box_class="max-w-2xl" two_columns=True %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
{# --- Plans ----------------------------------------------------------- #}
<div class="card flex flex-col">
<div class="flex items-center gap-3 border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% trans "Plans" %}</span>
<span class="flex-1"></span>
{% comment %}
Prices are dated, not edited. A rate change is a new row with a future
active_from; every period already opened keeps the amount it was billed at,
so raising the price cannot rewrite an invoice you have already sent.
so raising the price cannot rewrite an invoice already sent.
{% endcomment %}
<p class="text-sm opacity-70">A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<span class="font-mono text-[11px] text-muted">{% trans "rate changes apply from a future date — open periods keep the amount they were billed at" %}</span>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Plan" %}</th>
<th>{% trans "Timing" %}</th>
<th class="text-right">{% trans "Clubs" %}</th>
<th>{% trans "Prices" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for plan in plans %}
<tr>
<th>Plan</th>
<th>Clocks</th>
<th class="text-right">Clubs</th>
<th>Prices</th>
<th></th>
<td>
<div class="flex items-center gap-1.5">
<span class="font-semibold text-ink">{{ plan.name }}</span>
{% if plan.is_trial %}<span class="badge badge-info badge-xs">{% trans "Trial" %}</span>{% endif %}
{% if not plan.is_active %}<span class="badge badge-ghost badge-xs">{% trans "Retired" %}</span>{% endif %}
</div>
{% if plan.description %}<div class="mt-0.5 text-xs text-muted">{{ plan.description }}</div>{% endif %}
</td>
{% comment %}
Named for what each measures from, because that is the easy
thing to get wrong: grace runs from the period START, not its
end.
{% endcomment %}
<td class="font-mono text-[11px] whitespace-nowrap text-muted">
<div>{% blocktrans count counter=plan.duration_months %}{{ counter }} month{% plural %}{{ counter }} months{% endblocktrans %}</div>
<div>{% blocktrans with days=plan.renewal_lead_days %}billed {{ days }}d before start{% endblocktrans %}</div>
<div>{% blocktrans with days=plan.grace_days %}grace {{ days }}d after start{% endblocktrans %}</div>
</td>
<td class="text-right font-mono tabular-nums">{{ plan.club_count }}</td>
<td>
{% for price in plan.prices.all %}
<div class="flex items-center gap-1.5 font-mono text-xs whitespace-nowrap">
<span class="tabular-nums text-ink">€ {{ price.amount|floatformat:2 }}</span>
<span class="text-muted">{% blocktrans with active_from=price.active_from|date:"Y-m-d" %}from {{ active_from }}{% endblocktrans %}</span>
{% if price.active_from > today %}<span class="badge badge-info badge-xs">{% trans "Scheduled" %}</span>{% endif %}
</div>
{% empty %}
<span class="badge badge-error badge-xs">{% trans "No price — cannot be billed" %}</span>
{% endfor %}
</td>
<td>
<div class="flex justify-end gap-2">
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=13 %} {% trans "New price" %}</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=13 %} {% trans "Edit" %}</button>
<a class="btn btn-outline btn-error btn-sm gap-1" href="{% url 'controlpanel:plan_delete' plan.pk %}">{% lucide "trash-2" size=13 %} {% trans "Delete" %}</a>
</div>
</td>
</tr>
</thead>
<tbody>
{% for plan in plans %}
<tr>
<td>
<div class="font-medium">{{ plan.name }}</div>
{% if plan.is_trial %}<span class="badge badge-info badge-xs">Trial</span>{% endif %}
{% if not plan.is_active %}<span class="badge badge-ghost badge-xs">Retired</span>{% endif %}
{% if plan.description %}
<div class="text-xs opacity-60">{{ plan.description }}</div>{% endif %}
</td>
{% comment %}
Named for what each measures from, because that is the easy thing to
get wrong: grace runs from the period START, not its end.
{% endcomment %}
<td class="text-xs opacity-70 whitespace-nowrap">
<div>{{ plan.duration_months }} month{{ plan.duration_months|pluralize }} long</div>
<div>billed {{ plan.renewal_lead_days }}d before it starts</div>
<div>archived {{ plan.grace_days }}d after it starts</div>
</td>
<td class="text-right tabular-nums">{{ plan.club_count }}</td>
<td>
{% for price in plan.prices.all %}
<div class="text-sm tabular-nums">
€{{ price.amount|floatformat:2 }}
<span class="opacity-60">from {{ price.active_from|date:"j M Y" }}</span>
{% if price.active_from > today %}<span class="badge badge-info badge-xs">Scheduled</span>{% endif %}
</div>
{% empty %}
<span class="badge badge-error badge-sm">No price — cannot be billed</span>
{% endfor %}
</td>
<td class="flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ plan.pk|dom_id:"plan_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
<a class="btn btn-error btn-outline btn-sm gap-1" href="{% url 'controlpanel:plan_delete' plan.pk %}">{% lucide "trash-2" size=14 %} Delete</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No plans yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">{% trans "No plans yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for plan in plans %}
{% url 'controlpanel:plan_price_create' plan.pk as plan_price_url %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_price_modal" title="New price — "|add:plan.name form=plan.price_form action_url=plan_price_url submit_label="Add price" submit_icon="euro" %}
{% blocktrans asvar plan_price_title with plan=plan.name %}New price — {{ plan }}{% endblocktrans %}
{% trans "Add price" as add_price_label %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_price_modal" title=plan_price_title form=plan.price_form action_url=plan_price_url submit_label=add_price_label submit_icon="euro" %}
{% url 'controlpanel:plan_update' plan.pk as plan_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_edit_modal" title="Edit "|add:plan.name form=plan.edit_form action_url=plan_update_url submit_label="Save" submit_icon="check" box_class="max-w-2xl" two_columns=True %}
{% blocktrans asvar plan_edit_title with plan=plan.name %}Edit {{ plan }}{% endblocktrans %}
{% trans "Save" as save_label %}
{% include "controlpanel/_modal_form.html" with modal_id=plan.pk|dom_id:"plan_edit_modal" title=plan_edit_title form=plan.edit_form action_url=plan_update_url submit_label=save_label submit_icon="check" box_class="max-w-2xl" two_columns=True %}
{% endfor %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
{# --- Owed -------------------------------------------------------------- #}
<div class="card flex flex-col">
<div class="flex items-center gap-3 border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% trans "Owed" %}</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">{% blocktrans count counter=owing|length %}{{ counter }} period outstanding{% plural %}{{ counter }} periods outstanding{% endblocktrans %}</span>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Club" %}</th>
<th>{% trans "Period" %}</th>
<th class="text-right">{% trans "Balance" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for due in owing %}
<tr>
<th>Club</th>
<th>Period</th>
<th class="text-right">Owed</th>
<th class="text-right">Status</th>
<th></th>
<td>
<a class="link link-hover font-semibold text-ink" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
<div class="text-xs text-muted">{{ due.plan.name }}</div>
</td>
<td class="font-mono text-[11px] whitespace-nowrap text-muted">
{{ due.period_start|date:"Y-m-d" }} → {{ due.period_end|date:"Y-m-d" }}
<div>{% blocktrans with grace_until=due.grace_until|date:"Y-m-d" %}grace to {{ grace_until }}{% endblocktrans %}</div>
</td>
<td class="text-right font-mono font-semibold tabular-nums text-ink">€ {{ due.balance|floatformat:2 }}</td>
<td>
{% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=11 %} {% trans "Overdue" %}</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=11 %} {% trans "In grace" %}</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td>
<div class="flex justify-end gap-2">
<button class="btn btn-outline btn-success btn-sm gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=13 %} {% trans "Record payment" %}</button>
<a class="btn btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=13 %} {% trans "Invoice" %}</a>
</div>
</td>
</tr>
</thead>
<tbody>
{% for due in owing %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
<div class="text-xs opacity-60">{{ due.plan.name }}</div>
</td>
<td class="text-sm">
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
<div class="text-xs opacity-60">Grace to {{ due.grace_until|date:"j M Y" }}</div>
</td>
<td class="text-right font-semibold tabular-nums">€{{ due.balance|floatformat:2 }}</td>
<td class="text-right">
{% if due.grace_until < today %}
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
{% elif due.period_end < today %}
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
{% else %}
<span class="badge badge-outline">{{ due.get_status_display }}</span>
{% endif %}
</td>
<td class="text-right flex flex-row gap-2 justify-end">
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ due.pk|dom_id:"due_pay_modal" }}').showModal()">{% lucide "banknote" size=14 %} Record payment</button>
<a class="btn btn-accent btn-outline btn-sm gap-1" href="{% url 'controlpanel:due_invoice' due.pk %}">{% lucide "file-down" size=14 %} Download invoice</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">Nothing outstanding.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">{% trans "Nothing outstanding." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for due in owing %}
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %}
{% blocktrans asvar due_pay_title with club=due.club.name %}Record payment — {{ club }}{% endblocktrans %}
{% trans "Record payment" as record_payment_label %}
{% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title=due_pay_title form=due.payment_form action_url=due_pay_url submit_label=record_payment_label submit_icon="banknote" %}
{% endfor %}
{% endblock panel %}

View File

@@ -1,67 +1,77 @@
{% extends "controlpanel/base.html" %}
{% load static lucide %}
{% block logo %}
{% if club.logo %}
{% 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 and its children, not the rest of
the panel's buttons and badges.
{% endcomment %}
<div class="avatar">
<div class="w-16 rounded-full bg-base-100 ring-2 ring-primary ring-offset-2 ring-offset-base-100" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}>
<img class="club-logo object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
</div>
</div>
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
{% endblock logo %}
{% block panel_title %}{{ club.name }}{% endblock panel_title %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}{% if club.legal_name %} &middot; {{ club.legal_name }}{% endif %}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
{% endblock subheading %}
{% 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=16 %} Edit</a>
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
<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=16 %} Restore</button>
<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=16 %} Archive</button>
<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 &middot; {{ club.get_sport_type_display }}{% if club.legal_name %} &middot; {{ 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 mb-6">
{% lucide "alert-triangle" size=20 %}
<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 mb-6">
{% lucide "calendar-x" size=20 %}
<span>
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
</span>
<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 %}
@@ -70,132 +80,110 @@
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="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.outstanding|floatformat:2 }}</div>
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div>
<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 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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
<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 bg-base-100 shadow border-l-4 {% if attention.unrostered %}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 "user-minus" size=16 %} Unrostered members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">Active members on no team</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>
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}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 "clock" size=16 %} Pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">Memberships awaiting approval</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 "sparkles" size=16 %} New members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #}
<div class="text-xs opacity-60">First season at this club</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 %} Renewal rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.renewal_rate is None %}
N/A
{% else %}
{{ attention.renewal_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.renewal_rate is None %}
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-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
N/A
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
No events this season
{% else %}
<progress class="progress w-full {% 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 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="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.</p>
<div class="h-56">
<canvas id="signups-chart"></canvas>
</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" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
{% endfor %}
</div>
{% include "controlpanel/_club_home_location_card.html" %}
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</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>
<div class="mb-6 grid gap-4 md:grid-cols-4">
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endfor %}
</div>
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
{% include "controlpanel/_club_home_location_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% endblock panel %}
{% block extra_body %}
@@ -205,64 +193,50 @@
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const ink = "#3A4658";
const grid = "rgba(58,70,88,.12)";
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
// 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: css("--color-primary", "#4f46e5")},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
// 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}},
},
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}},
},
});
// 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"],
},
},
});
// 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: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink}}},
},
});
return [signups, fees];
};
let charts = render();
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
},
});
})();
</script>
{% endblock extra_body %}

View File

@@ -1,21 +1,29 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
{% block panel_title %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock panel_title %}
{% block breadcrumb %}
<a class="hover:text-ink" href="{% url 'controlpanel:club_list' %}">clubs</a>
<span>/</span>
<span class="text-ink">{% if object %}{{ object.slug }}{% else %}new{% endif %}</span>
{% endblock breadcrumb %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="font-display text-2xl font-extrabold text-ink uppercase">{% if object %}Edit {{ object }}{% else %}New club{% endif %}</div>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<form method="post" enctype="multipart/form-data" class="flex flex-col gap-4">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<div class="alert alert-error">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
@@ -23,22 +31,22 @@
{% form_field form.sport_type %}
</div>
<div class="divider"></div>
<div class="border-t border-rule"></div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{% form_field form.logo %}
{% form_field form.primary_color %}
{% form_field form.secondary_color %}
</div>
<div class="divider"></div>
<div class="border-t border-rule"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.season_start %}
{% form_field form.season_duration_months %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="flex justify-start gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
</div>

View File

@@ -1,41 +1,52 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %}
{% block panel_title %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">clubs</span>
{% if show_archived %}
<span class="text-edge">|</span>
<span class="font-semibold text-club-dark">Archived</span>
{% endif %}
<span class="text-edge">|</span>
<span>{{ clubs|length }} club{{ clubs|length|pluralize }}</span>
{% endblock breadcrumb %}
{% block actions %}
{% if show_archived %}
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=14 %} Hide archived</a>
{% else %}
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=14 %} Show archived</a>
{% endif %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=14 %} New club</a>
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search"
name="q"
value="{{ search }}"
placeholder="Search clubs…"
class="input input-bordered w-full max-w-xs">
</label>
{% if show_archived %}
<div class="flex items-center gap-2">
<span class="font-display text-xl font-extrabold tracking-[.04em] text-ink uppercase">Archived clubs</span>
<span class="badge badge-warning">{% lucide "archive" size=12 %} {{ clubs|length }} archived</span>
</div>
{% endif %}
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
<form method="get" class="flex items-center gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<div class="relative">
<span class="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-dim">{% lucide "search" size=14 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="Search clubs…" class="input w-64 pl-8 font-mono text-xs">
</div>
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=14 %} Search</button>
{% if search %}
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
<a class="btn btn-ghost gap-2" href="{% url "controlpanel:club_list" %}{% if show_archived %}?archived=1{% endif %}">{% lucide "x" size=14 %} Clear</a>
{% endif %}
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
{% if show_archived %}
{% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
{% else %}
{% include "controlpanel/_club_health_table.html" %}
{% endif %}
</div>
<div class="card">
{% if show_archived %}
{% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
{% else %}
{% include "controlpanel/_club_health_table.html" %}
{% endif %}
</div>
{% endblock panel %}

View File

@@ -1,111 +1,213 @@
{% extends "controlpanel/base.html" %}
{% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }} &middot; {% now "d b Y" %}{% endblock subheading %}
{% 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 gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=14 %} Create 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>
{# --- 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 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 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 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 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 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 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 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 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 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">&euro;{{ 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 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="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">
<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>
<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">
<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 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 font-mono">{{ 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 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>
</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" %}
{# --- 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 %}
@@ -116,84 +218,28 @@
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const ink = "#3A4658";
const grid = "rgba(58,70,88,.12)";
// 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")},
],
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}},
},
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 %}

View File

@@ -1,7 +1,15 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Features{% endblock heading %}
{% block panel_title %}Features{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">features</span>
<span class="text-edge">|</span>
<span>{{ flags|length }} flag{{ flags|length|pluralize }}</span>
<span class="text-edge">|</span>
<span>{{ switches|length }} switch{{ switches|length|pluralize }}</span>
{% endblock breadcrumb %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
@@ -14,132 +22,153 @@
{% comment %}
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
control panel and the auth screens stay open — otherwise you could not sign in to
turn it back off.
turn it back off. This is the single most safety-critical control in the panel, so
the active state gets the full club/club-dark treatment, not just a badge.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
<div class="card-body">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="w-full">
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
{% if maintenance.is_active %}
<div class="flex flex-row gap-2 text-sm">
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
<div>&middot;</div>
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
</div>
{% if maintenance.message %}
<div class="my-4">
<div class="font-semibold mb-1">Message</div>
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-70">
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
</p>
{% endif %}
</div>
<div class="card overflow-hidden {% if maintenance.is_active %}border-2 border-club{% endif %}">
{% if maintenance.is_active %}
<div class="flex flex-wrap items-center gap-2.5 border-b border-club bg-club px-4 py-3">
{% lucide "lock" size=18 class="shrink-0 text-white" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-white uppercase">Maintenance mode — platform closed</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-white/80">since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} · {{ maintenance.started_by.email }}{% endif %}</span>
</div>
{% else %}
<div class="flex items-center gap-2.5 border-b border-line bg-subhead px-4 py-3">
{% lucide "wrench" size=18 class="shrink-0 text-muted" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Maintenance mode</span>
<span class="flex-1"></span>
<span class="flex items-center gap-1.5 font-mono text-[11px] text-ok-text">
<span class="h-[7px] w-[7px] rounded-full bg-ok"></span>
platform open
</span>
</div>
{% endif %}
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
{% if not maintenance.is_active %}
<div class="form-control my-2 w-full pb-2">
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
<span class="label-text">{{ maintenance_form.message.label }}</span>
</label>
{{ maintenance_form.message|daisy }}
<span class="label-text-alt mt-1 block text-xs text-base-content/70">{{ maintenance_form.message.help_text }}</span>
<div class="card-body">
{% if maintenance.is_active %}
{% if maintenance.message %}
<div>
<div class="mb-1.5 font-mono text-[10px] tracking-[.08em] text-muted uppercase">Message shown to clubs</div>
<div class="border border-line bg-subhead px-3 py-2.5 font-mono text-sm text-ink">{{ maintenance.message }}</div>
</div>
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
{% else %}
<button class="btn btn-success gap-2" type="submit">{% lucide "lock-open" size=16 %} Reopen the platform</button>
{% endif %}
</form>
<p class="text-sm text-muted">Every club subdomain is serving a maintenance page and the scheduled jobs have stood down. The control panel and the sign-in screens stay open.</p>
<form class="mt-1" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
<button class="btn btn-success gap-2" type="submit">{% lucide "lock-open" size=16 %} Reopen the platform</button>
</form>
{% else %}
<p class="text-sm text-muted">Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.</p>
<form class="flex flex-col gap-3" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
<div class="form-control w-full">
<label class="label-text mb-1.5 block" for="{{ maintenance_form.message.id_for_label }}">{{ maintenance_form.message.label }}</label>
{{ maintenance_form.message|daisy }}
<span class="label-text-alt mt-1 block">{{ maintenance_form.message.help_text }}</span>
</div>
<button class="btn btn-error gap-2 self-start" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
</form>
{% endif %}
</div>
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
<p class="text-sm opacity-70">
Flags are turned on per club. Setting <em>Everyone</em> to Yes or No overrides club targeting entirely.
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<div class="card">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
{% lucide "flag" size=16 class="shrink-0 text-muted" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Flags</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">{{ flags|length }} flag{{ flags|length|pluralize }}</span>
</div>
<p class="px-4 pt-3 pb-3 text-sm text-muted">
Flags are turned on per club. Setting <span class="font-semibold text-ink">Everyone</span> to Yes or No overrides club targeting entirely.
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Everyone</th>
<th>Clubs</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{% for flag in flags %}
<tr>
<th>Name</th>
<th>Everyone</th>
<th>Clubs</th>
<th>Note</th>
<th></th>
<td class="font-mono">{{ flag.name }}</td>
<td>
{% if flag.everyone is True %}
<span class="badge badge-success">On for all</span>
{% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span>
{% else %}
<span class="badge badge-info">Per club</span>
{% endif %}
</td>
<td class="font-mono tabular-nums text-muted">{{ flag.clubs.count }}</td>
<td class="max-w-xs truncate text-muted">{{ flag.note|default:"—" }}</td>
<td class="text-right">
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
</thead>
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td>
{% if flag.everyone is True %}
<span class="badge badge-success">On for all</span>
{% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span>
{% else %}
<span class="badge badge-info">Per club</span>
{% endif %}
</td>
<td>{{ flag.clubs.count }}</td>
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
<td class="text-right">
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for flag in flags %}
{% url 'controlpanel:flag_update' flag.pk as flag_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for flag in flags %}
{% url 'controlpanel:flag_update' flag.pk as flag_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=flag.pk|dom_id:"flag_edit_modal" title="Edit "|add:flag.name form=flag.edit_form action_url=flag_update_url submit_label="Save" submit_icon="check" %}
{% endfor %}
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "power" size=18 %} Switches</h2>
<p class="text-sm opacity-70">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for switch in switches %}
<tr>
<td class="font-mono font-medium">{{ switch.name }}</td>
<td class="opacity-70">{{ switch.note|default:"-" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if switch.active %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if switch.active %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No switches yet — add one in the Django admin.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
{% lucide "power" size=16 class="shrink-0 text-muted" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Switches</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">{{ switches|length }} switch{{ switches|length|pluralize }}</span>
</div>
<p class="px-4 pt-3 pb-3 text-sm text-muted">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{% for switch in switches %}
<tr>
<td class="font-mono">{{ switch.name }}</td>
<td class="text-muted">{{ switch.note|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %}
<button class="relative inline-block h-[22px] w-10 shrink-0 rounded-full transition-colors {% if switch.active %}bg-ok{% else %}bg-edge{% endif %}" type="submit" aria-pressed="{% if switch.active %}true{% else %}false{% endif %}" aria-label="Toggle {{ switch.name }}">
<span class="absolute top-[3px] h-4 w-4 rounded-full bg-white transition-all {% if switch.active %}right-[3px]{% else %}left-[3px]{% endif %}"></span>
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">No switches yet — add one in the Django admin.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,77 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block panel_title %}Jobs{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">jobs</span>
<span class="text-edge">|</span>
<span>{{ jobs|length }} scheduled</span>
{% endblock breadcrumb %}
{% block panel %}
<div class="rounded border border-info-border bg-info-bg px-4 py-3 text-sm text-info-text">
{% lucide "info" size=16 class="inline -mt-0.5 mr-1" %}
These run on Celery Beat's own schedule (see <span class="font-mono">rosterchief/settings.py</span>) — this page is monitoring only, there is no "run now" here. A run that never appears at its scheduled time is the signal something's wrong with <span class="font-mono">worker</span>/<span class="font-mono">beat</span>, not with this page.
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{% for job in jobs %}
<div class="card flex flex-col">
<div class="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
<div>
<div class="font-display text-sm font-extrabold tracking-[.08em] text-ink uppercase">{{ job.label }}</div>
<div class="mt-0.5 font-mono text-[11px] text-muted">{{ job.name }}</div>
</div>
{% if job.latest %}
{% if job.latest.status == "success" %}
<span class="badge badge-success shrink-0">ok</span>
{% elif job.latest.status == "failure" %}
<span class="badge badge-error shrink-0">error</span>
{% else %}
<span class="badge badge-info shrink-0">running</span>
{% endif %}
{% else %}
<span class="badge shrink-0">never run</span>
{% endif %}
</div>
<div class="flex flex-1 flex-col gap-2 px-4 py-3 text-sm">
{# flex-1 here so the paragraph absorbs whatever space a shorter description leaves, keeping the schedule pill pinned to the bottom of the content block across every card in the row instead of drifting with description length. #}
<p class="flex-1 text-slate">{{ job.description }}</p>
<div class="badge flex items-center gap-1.5 font-mono text-[11px] text-muted">
{% lucide "clock" size=12 %} {{ job.schedule }}
</div>
{% if job.latest %}
<div class="mt-1 font-mono text-[11px] text-muted">
last run {{ job.latest.started_at|date:"j M Y, H:i" }}
{% if job.latest.duration %}· {{ job.latest.duration.total_seconds|floatformat:1 }}s{% endif %}
</div>
{% if job.latest.status == "success" and job.latest.detail %}
<div class="rounded bg-subhead px-2.5 py-2 font-mono text-xs text-slate">{{ job.latest.detail }}</div>
{% elif job.latest.status == "failure" and job.latest.error %}
<div class="rounded border border-danger-border bg-danger-bg px-2.5 py-2 font-mono text-xs text-club-dark">{{ job.latest.error }}</div>
{% endif %}
{% endif %}
</div>
{% if job.runs|length > 1 %}
<div class="mt-auto flex flex-col gap-1.5 border-t border-rule px-4 py-3">
<div class="font-mono text-[10px] tracking-[.06em] text-dim uppercase">Recent runs</div>
{% for run in job.runs|slice:":5" %}
<div class="flex items-center gap-2 font-mono text-[11px]">
<span class="text-dim">{{ run.started_at|date:"d/m H:i" }}</span>
{% if run.status == "success" %}
<span class="text-ok-text">ok</span>
{% elif run.status == "failure" %}
<span class="text-club-dark">error</span>
{% else %}
<span class="text-info-text">running</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock panel %}

View File

@@ -7,76 +7,96 @@
that list can be long. See billing.services.plans for what "delete" actually does.
{% endcomment %}
{% block heading %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock heading %}
{% block panel_title %}{% blocktrans with plan=plan.name %}Delete “{{ plan }}”{% endblocktrans %}{% endblock panel_title %}
{% block breadcrumb %}
<a class="text-ink hover:underline" href="{% url 'controlpanel:billing' %}">billing</a>
<span class="text-edge">|</span>
<span>delete plan</span>
<span class="text-edge">|</span>
<span class="text-club-dark">{{ plan.name }}</span>
{% endblock breadcrumb %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "arrow-left" size=16 %} {% trans "Back to billing" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "triangle-alert" size=18 %} {% trans "This can't be undone" %}</h2>
{% if impact.will_hard_delete %}
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so it will be removed completely.{% endblocktrans %}</p>
{% else %}
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}</p>
{% endif %}
</div>
</div>
{% if impact.unsubscribed_clubs %}
<div class="card mb-6 bg-base-100 shadow border-l-4 border-error">
<div class="card-body">
<h2 class="card-title text-base">
{% lucide "building-2" size=18 %}
{% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %}
</h2>
<p class="text-sm opacity-70">{% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}</p>
<ul class="mt-2 divide-y divide-base-200">
{% for club in impact.unsubscribed_clubs %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<span class="badge badge-error badge-outline gap-1">{% lucide "circle-x" size=12 %} {% trans "Loses this plan" %}</span>
</li>
{% endfor %}
</ul>
<div class="max-w-3xl">
{# --- the plan being removed, and which of the two outcomes applies -- #}
<div class="card flex items-start gap-3 border-l-4 border-l-club-dark p-4">
{% lucide "triangle-alert" size=20 class="mt-0.5 shrink-0 text-club-dark" %}
<div>
<div class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% trans "This can't be undone" %}</div>
{% if impact.will_hard_delete %}
<p class="mt-1.5 text-sm text-slate">{% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so the plan will be removed completely.{% endblocktrans %}</p>
{% else %}
<p class="mt-1.5 text-sm text-slate">{% blocktrans with plan=plan.name %}“{{ plan }}” has billing history, so it will be hidden rather than removed — past invoices will still show what they were billed under.{% endblocktrans %}</p>
{% endif %}
</div>
</div>
{% endif %}
{% if impact.broken_trial_clubs %}
<div class="card mb-6 bg-base-100 shadow border-l-4 border-warning">
<div class="card-body">
<h2 class="card-title text-base">
{% lucide "hourglass" size=18 %}
{% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %}
</h2>
<p class="text-sm opacity-70">{% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}</p>
<ul class="mt-2 divide-y divide-base-200">
{% for club in impact.broken_trial_clubs %}
<li class="py-2 flex items-center justify-between">
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<span class="badge badge-warning badge-outline gap-1">{% lucide "octagon-alert" size=12 %} {% trans "Trial needs a new plan" %}</span>
</li>
{% endfor %}
</ul>
{# --- clubs currently subscribed to this plan -------------------------- #}
{% if impact.unsubscribed_clubs %}
<div class="card mt-4 flex flex-col border-l-4 border-l-club-dark">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
{% lucide "building-2" size=16 class="text-club-dark" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">
{% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %}
</span>
</div>
<p class="px-4 pt-3 text-sm text-slate">{% trans "Deleting this plan removes their subscription outright — each shows as not billed for anything afterwards, the same as a club that was never put on a plan." %}</p>
<table class="table mt-1">
<tbody>
{% for club in impact.unsubscribed_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="text-right">
<span class="badge badge-error gap-1">{% lucide "circle-x" size=11 %} {% trans "Loses this plan" %}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}
{% if not impact.has_impact %}
<div class="alert alert-info mb-6">
{% lucide "info" size=20 %}
<span>{% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %}</span>
</div>
{% endif %}
{# --- clubs mid-trial, scheduled to land on this plan ------------------- #}
{% if impact.broken_trial_clubs %}
<div class="card mt-4 flex flex-col border-l-4 border-l-warn">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
{% lucide "hourglass" size=16 class="text-warn-text" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">
{% blocktrans count counter=impact.broken_trial_clubs|length %}{{ counter }} club's trial is scheduled to switch to this plan{% plural %}{{ counter }} clubs' trials are scheduled to switch to this plan{% endblocktrans %}
</span>
</div>
<p class="px-4 pt-3 text-sm text-slate">{% trans "They stay on their current trial plan, but the scheduled switch is cancelled — pick a new plan for them before the trial ends." %}</p>
<table class="table mt-1">
<tbody>
{% for club in impact.broken_trial_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="text-right">
<span class="badge badge-warning gap-1">{% lucide "octagon-alert" size=11 %} {% trans "Trial needs a new plan" %}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<form method="post" action="{% url 'controlpanel:plan_delete' plan.pk %}">
{% csrf_token %}
<div class="flex flex-row justify-end gap-2">
{% if not impact.has_impact %}
<div class="alert alert-info mt-4">
{% lucide "info" size=18 %}
<span>{% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %}</span>
</div>
{% endif %}
<form method="post" action="{% url 'controlpanel:plan_delete' plan.pk %}" class="mt-6 flex justify-end gap-2">
{% csrf_token %}
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "x" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-error gap-2" type="submit">{% lucide "trash-2" size=16 %} {% trans "Delete plan" %}</button>
</div>
</form>
</form>
</div>
{% endblock panel %}

View File

@@ -37,13 +37,13 @@
/>
{% elif field_type == "checkbox" %}
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-start gap-2">
<input
type="checkbox"
{% if show_as_toggle %}
class="toggle {% if size_modifier %}toggle-{{ size_modifier }}{% endif %}"
class="toggle mt-0.5 {% if size_modifier %}toggle-{{ size_modifier }}{% endif %}"
{% else %}
class="checkbox {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}"
class="checkbox mt-0.5 {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}"
{% endif %}
name="{{ field.html_name }}"
{% if field.value %}checked="checked"{% endif %}