News gains a PENDING_REVIEW status between draft and published. A non-editor author (can_add_news but not can_publish_news -- a coach_manager, not an ADMIN/EDITOR) gets a "Send for review" button instead of Publish; an editor/admin always sees Publish directly, no review step. Submitting notifies every ADMIN/EDITOR in-app only (see notify_members' new send_email=False) -- a review queue that emailed on every submission would get noisy fast. The notification area itself: a topbar bell (badge + dropdown, same <details>/<summary> convention as the sidebar's user-menu, generalised to a shared .dismissable-details close handler) visible on every page, plus a fuller "Notifications" card on the dashboard, both fed by a new notification_bell context processor. "Mark all read" clears the signed-in staff member's own unread notifications for this club. This is the reusable notification system's first consumer beyond news publishing itself -- validates that notify_members()/Notification generalise the way they were meant to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
338 lines
22 KiB
HTML
338 lines
22 KiB
HTML
{% extends "management/base.html" %}
|
|
{% load i18n static lucide %}
|
|
|
|
{% block panel_title %}Overview{% endblock panel_title %}
|
|
{% block heading %}{{ club.name }}{% endblock heading %}
|
|
|
|
{% block topbar_context %}
|
|
{% if not attention.no_season %}
|
|
<span class="badge badge-success">{% blocktrans with start=attention.season.start_date|date:"Y" end=attention.season.end_date|date:"Y" %}Season {{ start }}-{{ end }}{% endblocktrans %}</span>
|
|
{% endif %}
|
|
{% endblock topbar_context %}
|
|
|
|
{% block actions %}
|
|
<a class="btn btn-primary gap-2" href="{% url 'management:member_create' %}">{% lucide "plus" size=16 %} {% trans "New member" %}</a>
|
|
{% endblock actions %}
|
|
|
|
{% block panel %}
|
|
{# Every level here; base.html repeats it on other pages only once it turns urgent. #}
|
|
{% include "management/_billing_notice.html" %}
|
|
|
|
{% if attention.no_season %}
|
|
<div class="alert alert-warning">
|
|
{% lucide "calendar-x" size=20 %}
|
|
<span>{% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if is_club_admin and billing_ends_at %}
|
|
<div class="alert alert-warning">
|
|
{% lucide "calendar-clock" size=20 %}
|
|
<span>
|
|
{% if billing_auto_renews %}
|
|
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and will renew automatically.{% endblocktrans %}
|
|
{% else %}
|
|
{% blocktrans with date=billing_ends_at|date:"j M Y" %}Your current billing period ends {{ date }} and is not set to renew automatically — billing is about to stop. Contact us to keep access.{% endblocktrans %}
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="flex gap-3.5">
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Total members" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{{ member_count }}</div>
|
|
<div class="mt-1 text-[13px] {% if member_count_change > 0 %}text-ok{% elif member_count_change < 0 %}text-club{% else %}text-muted{% endif %}">
|
|
{% if member_count_change is None %}
|
|
{% trans "No previous season to compare" %}
|
|
{% else %}
|
|
{% if member_count_change > 0 %}+{% endif %}{{ member_count_change }} {% trans "vs last season" %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Awaiting approval" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if attention.pending_approvals %}text-club{% else %}text-ink{% endif %}">{{ attention.pending_approvals }}</div>
|
|
<div class="mt-1 text-[13px] text-muted">{% trans "Memberships to review" %}</div>
|
|
</div>
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Teams without coach" %}</div>
|
|
<div class="mt-1 font-display text-[34px] 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 text-[13px] text-muted">{% trans "Nobody can pick a squad" %}</div>
|
|
</div>
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Unrostered members" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if attention.unrostered %}text-warn{% else %}text-ink{% endif %}">{{ attention.unrostered }}</div>
|
|
<div class="mt-1 text-[13px] text-muted">{% trans "Active, on no team" %}</div>
|
|
</div>
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "New members" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{{ attention.new_members }}</div>
|
|
<div class="mt-1 text-[13px] text-muted">{% trans "First season at this club" %}</div>
|
|
</div>
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Attendance" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{% if attention.attendance.turnout is None %}—{% else %}{{ attention.attendance.turnout }}%{% endif %}</div>
|
|
<div class="mt-1 text-[13px] text-muted">{% if attention.attendance.turnout is None %}{% trans "No events this season" %}{% else %}{% trans "Turnout this season" %}{% endif %}</div>
|
|
</div>
|
|
{% if requirements_configured %}
|
|
<div class="card min-w-0 flex-1 p-4">
|
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Missing documentation" %}</div>
|
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if missing_documentation_count %}text-warn{% else %}text-ink{% endif %}">{{ missing_documentation_count }}</div>
|
|
<div class="mt-1 text-[13px] text-muted">{% trans "Members with an open checklist item" %}</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.35fr_1fr]">
|
|
{# --- left: needs attention + signups ----------------------------- #}
|
|
<div class="flex flex-col gap-5">
|
|
<div class="card flex flex-col">
|
|
<div class="flex items-center border-b border-line px-4.5 py-3.5">
|
|
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Needs attention" %}</span>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
{% if attention.pending_approvals %}
|
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm bg-club"></span>
|
|
<div class="flex-1">
|
|
<div class="text-[15px] font-semibold text-ink">{% blocktrans count count=attention.pending_approvals %}{{ count }} membership awaiting approval{% plural %}{{ count }} memberships awaiting approval{% endblocktrans %}</div>
|
|
</div>
|
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_list' %}?status=pending">{% trans "Review" %}</a>
|
|
</div>
|
|
{% endif %}
|
|
{% if attention.teams_without_manager %}
|
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm bg-warn"></span>
|
|
<div class="flex-1">
|
|
<div class="text-[15px] font-semibold text-ink">{% blocktrans count count=attention.teams_without_manager %}{{ count }} team has no coach assigned{% plural %}{{ count }} teams have no coach assigned{% endblocktrans %}</div>
|
|
<div class="text-[13px] text-muted">{% trans "Nobody can pick a squad for them" %}</div>
|
|
</div>
|
|
<a class="btn btn-outline btn-sm" href="{% url 'management:team_list' %}">{% trans "Assign" %}</a>
|
|
</div>
|
|
{% endif %}
|
|
{% if attention.unrostered %}
|
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm bg-warn"></span>
|
|
<div class="flex-1">
|
|
<div class="text-[15px] font-semibold text-ink">{% blocktrans count count=attention.unrostered %}{{ count }} active member is on no team{% plural %}{{ count }} active members are on no team{% endblocktrans %}</div>
|
|
</div>
|
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_list' %}?unrostered=1">{% trans "Review" %}</a>
|
|
</div>
|
|
{% endif %}
|
|
{% if missing_documentation_count %}
|
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm bg-warn"></span>
|
|
<div class="flex-1">
|
|
<div class="text-[15px] font-semibold text-ink">{% blocktrans count count=missing_documentation_count %}{{ count }} member has an open checklist item{% plural %}{{ count }} members have an open checklist item{% endblocktrans %}</div>
|
|
<div class="text-[13px] text-muted">{% trans "Documents/medical certificates still outstanding" %}</div>
|
|
</div>
|
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_list' %}?docs=open">{% trans "Review" %}</a>
|
|
</div>
|
|
{% endif %}
|
|
{% if not attention.pending_approvals and not attention.teams_without_manager and not attention.unrostered and not missing_documentation_count %}
|
|
<div class="px-4.5 py-6 text-center text-sm text-muted">{% trans "Nothing needs attention." %}</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if is_club_admin %}
|
|
{# Financial/admin-only charts -- desktop-only canvases, same reasoning the old flat template gave: squeezed on a phone, and this surface is desktop-only anyway now. #}
|
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
|
<div class="card p-4.5">
|
|
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Signups per month" %}</div>
|
|
<p class="mb-2 text-sm text-muted">{% trans "New members against returning ones." %}</p>
|
|
<div class="h-44">
|
|
<canvas id="signups-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4.5">
|
|
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Club fee status" %}</div>
|
|
<div class="h-44">
|
|
<canvas id="fees-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4.5">
|
|
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Renewal rate" %}</div>
|
|
{% if attention.renewal_rate is None %}
|
|
<p class="text-sm text-muted">{% trans "No previous season to compare." %}</p>
|
|
{% else %}
|
|
<div class="h-44">
|
|
<canvas id="renewal-chart"></canvas>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# --- right: this weekend + news ----------------------------------- #}
|
|
<div class="flex flex-col gap-5">
|
|
{% if recent_notifications is not None %}
|
|
{# Same anatomy as "Needs attention" on the left -- see management.context_processors.notification_bell, also shown as a dropdown from the topbar bell on every page; this is the fuller list. #}
|
|
<div class="card flex flex-col">
|
|
<div class="flex items-center justify-between border-b border-line px-4.5 py-3.5">
|
|
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Notifications" %}</span>
|
|
{% if unread_notification_count %}
|
|
<form method="post" action="{% url 'management:notification_mark_all_read' %}">
|
|
{% csrf_token %}
|
|
<button class="text-xs font-semibold text-club hover:underline" type="submit">{% trans "Mark all read" %}</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
<div class="flex flex-col">
|
|
{% for notification in recent_notifications %}
|
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm {% if not notification.read_at %}bg-club{% else %}bg-line{% endif %}"></span>
|
|
<div class="flex-1">
|
|
<div class="text-[15px] font-semibold text-ink">{{ notification.title }}</div>
|
|
<div class="text-[13px] text-muted">{{ notification.body|truncatechars:120 }}</div>
|
|
</div>
|
|
<span class="shrink-0 font-mono text-xs text-dim">{{ notification.created|timesince }} {% trans "ago" %}</span>
|
|
</div>
|
|
{% empty %}
|
|
<div class="px-4.5 py-6 text-center text-sm text-muted">{% trans "Nothing yet." %}</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="rounded-xl bg-ink p-4.5 text-white">
|
|
<div class="mb-3.5 font-display text-base font-extrabold tracking-[.08em] uppercase">{% trans "Upcoming" %}</div>
|
|
<div class="flex flex-col gap-3">
|
|
{% for event in upcoming_events %}
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-11 shrink-0 text-center">
|
|
<div class="font-display text-[22px] leading-none font-extrabold">{{ event.start|date:"d" }}</div>
|
|
<div class="font-display text-[10px] tracking-[.1em] text-on-dark-dim uppercase">{{ event.start|date:"D" }}</div>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<a class="block truncate text-sm font-semibold hover:underline" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
|
|
<div class="font-mono text-xs text-on-dark-dim">{{ event.start|date:"H:i" }}</div>
|
|
</div>
|
|
<div class="flex max-w-[35%] shrink-0 flex-wrap items-center justify-end gap-1.5">
|
|
{% for team in event.teams.all %}
|
|
<span class="inline-flex items-center rounded-full border border-on-dark-faint px-1.5 py-0.5 font-display text-[10px] font-bold tracking-[.06em] text-white uppercase">{{ team.short_name }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
<a class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-on-dark-faint text-on-dark-dim hover:border-white hover:text-white" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans "View event" %}">
|
|
{% lucide "chevron-right" size=15 %}
|
|
</a>
|
|
</div>
|
|
{% if not forloop.last %}<div class="h-px bg-hairline"></div>{% endif %}
|
|
{% empty %}
|
|
<div class="text-sm text-on-dark-dim">{% trans "Nothing scheduled." %}</div>
|
|
{% endfor %}
|
|
</div>
|
|
<a class="mt-3.5 inline-flex items-center gap-1 font-mono text-[11px] text-on-dark-dim hover:text-white" href="{% url 'management:event_list' %}">{% trans "View calendar" %} →</a>
|
|
</div>
|
|
|
|
<div class="card flex-1 p-4.5">
|
|
<div class="mb-3.5 flex items-center justify-between">
|
|
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "News" %}</span>
|
|
<a class="btn btn-outline btn-sm" href="{% url 'management:news_list' %}">{% trans "View all" %}</a>
|
|
</div>
|
|
<div class="divide-y">
|
|
{% for news_item in published_news %}
|
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
|
<a class="truncate text-sm font-semibold text-ink hover:underline" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a>
|
|
<span class="shrink-0 font-mono text-xs text-muted">{{ news_item.published_at|date:"j M" }}</span>
|
|
</div>
|
|
{% empty %}
|
|
<div class="py-4 text-center text-sm text-muted">{% trans "Nothing published yet." %}</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3.5 {% if is_club_admin %}md:grid-cols-4{% else %}md:grid-cols-3{% endif %}">
|
|
{% for group in groups %}
|
|
{% if group.title != "Shop" or is_club_admin %}
|
|
<div class="card p-4">
|
|
<div class="mb-2 flex items-center gap-1.5 font-display text-sm font-extrabold tracking-[.06em] text-ink uppercase">{% lucide group.icon size=16 %} {{ group.title }}</div>
|
|
<dl>
|
|
{% for label, value in group.stats %}
|
|
<div class="flex items-center justify-between py-1.5">
|
|
<dt class="text-[13px] text-muted">{{ label }}</dt>
|
|
<dd class="font-mono text-sm font-semibold text-ink">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
|
</div>
|
|
{% endfor %}
|
|
</dl>
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endblock panel %}
|
|
|
|
{% block extra_body %}
|
|
{% trans "New" as new_label %}
|
|
{% trans "Returning" as returning_label %}
|
|
{% trans "Renewed" as renewed_label %}
|
|
{% trans "Not renewed" as not_renewed_label %}
|
|
{{ charts|json_script:"chart-data" }}
|
|
{{ attention.renewal_rate|json_script:"renewal-rate-data" }}
|
|
<script src="{% static 'js/chart.js' %}"></script>
|
|
<script>
|
|
(() => {
|
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
|
const renewalRate = JSON.parse(document.getElementById("renewal-rate-data").textContent);
|
|
// The club's own accent (--color-club resolves through --tenant-club, set in
|
|
// base.html from Club.secondary_color) -- Chart.js paints to a canvas, so it
|
|
// needs the resolved colour, not the CSS variable reference itself.
|
|
const clubColor = getComputedStyle(document.documentElement).getPropertyValue("--color-club").trim() || "#E4002B";
|
|
const ink = "#3A4658";
|
|
const grid = "#EEF0F3";
|
|
|
|
const signupsCanvas = document.getElementById("signups-chart");
|
|
if (signupsCanvas) {
|
|
new Chart(signupsCanvas, {
|
|
type: "bar",
|
|
data: {
|
|
labels: data.signups.map((point) => point.month),
|
|
datasets: [
|
|
{label: "{{ new_label|escapejs }}", data: data.signups.map((point) => point.new), backgroundColor: "#0B1220"},
|
|
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: clubColor},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {legend: {position: "bottom", labels: {color: ink, font: {family: "Barlow"}}}},
|
|
scales: {
|
|
x: {stacked: true, ticks: {color: ink}, grid: {display: false}},
|
|
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const feesCanvas = document.getElementById("fees-chart");
|
|
if (feesCanvas) {
|
|
// Colour carries the meaning here -- unpaid must read as a problem, waived
|
|
// must not -- so the slices are pinned to the semantic tokens, in order.
|
|
new Chart(feesCanvas, {
|
|
type: "pie",
|
|
data: {
|
|
labels: data.fees.map((slice) => slice.label),
|
|
datasets: [{data: data.fees.map((slice) => slice.value), backgroundColor: ["#14A05A", "#F0A22E", "#E4002B", "#8B95A4"]}],
|
|
},
|
|
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {position: "bottom", labels: {color: ink}}}},
|
|
});
|
|
}
|
|
|
|
const renewalCanvas = document.getElementById("renewal-chart");
|
|
if (renewalCanvas && renewalRate !== null) {
|
|
new Chart(renewalCanvas, {
|
|
type: "pie",
|
|
data: {
|
|
labels: ["{{ renewed_label|escapejs }}", "{{ not_renewed_label|escapejs }}"],
|
|
datasets: [{data: [renewalRate, 100 - renewalRate], backgroundColor: ["#14A05A", "#E4002B"]}],
|
|
},
|
|
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {position: "bottom", labels: {color: ink}}}},
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
{% endblock extra_body %}
|