Files
RosterChief/management/templates/management/home.html
Bernard Siebens 0ecdeac354 Add a news review workflow and a staff notification area
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
2026-08-21 09:52:59 +02:00

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" %} &rarr;</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" %}&euro;{% 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 %}