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:
112
controlpanel/templates/controlpanel/_auth_base.html
Normal file
112
controlpanel/templates/controlpanel/_auth_base.html
Normal 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 %} · 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">© {% 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · {{ 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 · {{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
{% 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 · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if club.is_archived %}
|
||||
<div class="alert alert-warning 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" %}€{% 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" %}€{% 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }} · {% 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">€{{ 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 %}
|
||||
|
||||
@@ -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>·</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 %}
|
||||
|
||||
77
controlpanel/templates/controlpanel/jobs.html
Normal file
77
controlpanel/templates/controlpanel/jobs.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user