Files
RosterChief/management/templates/management/signup_list.html
Bernard Siebens adf1120358 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
2026-08-19 23:34:43 +02:00

271 lines
18 KiB
HTML

{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Sign-up" %}{% endblock heading %}
{% block topbar_context %}
{% if season %}
<span class="font-mono text-[13px] text-muted">{% blocktrans with start=season.start_date|date:"Y" end=season.end_date|date:"Y" %}Season {{ start }}-{{ end }}{% endblocktrans %}</span>
{% endif %}
{% endblock topbar_context %}
{% block actions %}
{% if season %}
<form method="post" action="{% url 'management:signup_approve_all_clean' %}">
{% csrf_token %}
<button class="btn btn-outline gap-2" type="submit">{% lucide "check-check" size=16 %} {% trans "Approve all clean" %}</button>
</form>
{% endif %}
{% endblock actions %}
{% block panel %}
{% if not season %}
<div class="alert alert-warning">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there is nothing to process sign-ups for." %}</span>
</div>
{% else %}
<div class="card overflow-hidden">
<table class="table">
<thead>
<tr>
<th>{% trans "Applicant" %}</th>
<th>{% trans "Born" %}</th>
<th>{% trans "Team" %}</th>
<th>{% trans "Fee" %}</th>
<th>{% trans "Checks" %}</th>
<th>{% trans "Waiting" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
{# A plain border-left on a <tr> is unreliable under border-collapse (varies by browser, can get clipped by the card's rounded corner) -- an inset box-shadow paints the same accent bar without depending on table border behaviour at all. #}
<tr class="cursor-pointer {% if membership.is_oldest_pending %}bg-row-focus shadow-[inset_3px_0_0_var(--color-info)]{% endif %}" onclick="openDrawer('{{ membership.pk|dom_id:"signup_drawer" }}')">
<td>
<div class="flex items-center gap-2.5">
<span class="avatar avatar-placeholder shrink-0">
<div class="h-8 w-8 rounded-full text-xs">{{ membership.member.first_name|slice:":1" }}{{ membership.member.last_name|slice:":1" }}</div>
</span>
<div>
<div class="font-semibold text-ink">{{ membership.member.get_full_name }}</div>
<div class="text-xs text-muted">
{% if membership.status == "pending" %}{% trans "Awaiting processing" %}{% else %}{{ membership.get_status_display }}{% endif %}
</div>
</div>
</div>
</td>
<td class="font-mono text-sm text-muted">{{ membership.member.date_of_birth|date:"Y"|default:"—" }}</td>
<td class="text-sm text-ink">
{% if membership.teams_registered %}
{% for team in membership.teams_registered %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% else %}
<span class="text-dim">{% trans "Not placed yet" %}</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm {% if membership.fee_status == "paid" %}badge-success{% elif membership.fee_status == "partially_paid" %}badge-warning{% elif membership.fee_status == "unpaid" %}badge-error{% else %}badge-ghost{% endif %}">{{ membership.get_fee_status_display }}</span>
</td>
<td>
{% if requirements_configured %}
<span class="badge badge-sm {% if membership.onboarding_open %}badge-warning{% else %}badge-success{% endif %}">
{% if membership.onboarding_open %}{% blocktrans count count=membership.onboarding_open %}{{ count }} open{% plural %}{{ count }} open{% endblocktrans %}{% else %}{% trans "Clean" %}{% endif %}
</span>
{% else %}
<span class="text-dim"></span>
{% endif %}
</td>
{# text-xs, not text-sm: IBM Plex Mono reads noticeably larger than Barlow at the same declared size, and "timesince" text (e.g. "2 weeks") is already the most visually busy string in the row. #}
<td class="font-mono text-xs {% if membership.is_oldest_pending %}text-club-dark{% else %}text-muted{% endif %}">
{% if membership.status == "pending" %}{{ membership.created|timesince }}{% else %}—{% endif %}
</td>
<td class="text-right">
<button class="btn btn-outline btn-xs gap-1" type="button" onclick="event.stopPropagation(); openDrawer('{{ membership.pk|dom_id:"signup_drawer" }}')">{% trans "Open" %} {% lucide "chevron-right" size=12 %}</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="py-6 text-center text-muted">{% trans "No one is signed up for this season yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock panel %}
{% block extra_body %}
{% trans "Confirm" as confirm_label %}
{% trans "Bypass" as bypass_label %}
{% trans "Reopen" as reopen_label %}
{% url 'management:signup_list' as signup_url %}
{% for membership in memberships %}
<div id="{{ membership.pk|dom_id:"signup_drawer" }}" class="drawer-backdrop">
<div class="drawer">
<div class="flex items-center gap-3 border-b border-line p-5">
<span class="avatar avatar-placeholder shrink-0">
<div class="h-11 w-11 rounded-full text-base">{{ membership.member.first_name|slice:":1" }}{{ membership.member.last_name|slice:":1" }}</div>
</span>
<div class="min-w-0 flex-1">
<div class="truncate font-display text-xl font-extrabold text-ink uppercase">{{ membership.member.get_full_name }}</div>
<div class="text-[13px] text-muted">{% blocktrans with date=membership.created|date:"j M" %}Applied {{ date }}{% endblocktrans %}{% if membership.teams_registered %} · {% for team in membership.teams_registered %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}</div>
</div>
<button type="button" class="btn btn-ghost btn-square btn-sm" aria-label="{% trans 'Close' %}" onclick="closeDrawer('{{ membership.pk|dom_id:"signup_drawer" }}')">{% lucide "x" size=16 %}</button>
</div>
<div class="flex flex-1 flex-col gap-5 overflow-y-auto p-5">
{% if requirements_configured %}
<div>
<div class="mb-2.5 font-display text-xs font-bold tracking-[.14em] text-muted uppercase">{% trans "Checks" %}</div>
<div class="flex flex-col gap-2">
{% for requirement, status in membership.checklist %}
<div class="flex items-center gap-2.5 text-sm">
{% if status.is_complete or status.is_bypassed %}
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-ok text-white">{% lucide "check" size=13 %}</span>
{% else %}
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-warn font-display text-xs font-extrabold text-white">!</span>
{% endif %}
<span class="min-w-0 flex-1 text-ink">
{{ requirement.name }}
{% if status.is_bypassed %}<span class="text-xs text-muted">({% trans "bypassed" %})</span>{% endif %}
</span>
{% if not status.is_complete and not status.is_bypassed %}
<form method="post" action="{% url 'management:member_requirement_complete' membership.member.pk requirement.pk %}" class="shrink-0">
{% csrf_token %}<input type="hidden" name="next" value="{{ signup_url }}"><input type="hidden" name="note" value="">
<button class="btn btn-outline btn-success btn-xs" type="submit">{{ confirm_label }}</button>
</form>
<button class="btn btn-outline btn-xs shrink-0" type="button" onclick="document.getElementById('bypass_{{ membership.pk }}_{{ requirement.pk }}').showModal()">{{ bypass_label }}</button>
{% else %}
<form method="post" action="{% url 'management:member_requirement_incomplete' membership.member.pk requirement.pk %}" class="shrink-0">
{% csrf_token %}<input type="hidden" name="next" value="{{ signup_url }}">
<button class="btn btn-outline btn-xs" type="submit">{{ reopen_label }}</button>
</form>
{% endif %}
</div>
{% if not status.is_complete and not status.is_bypassed %}
<dialog id="bypass_{{ membership.pk }}_{{ requirement.pk }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold text-ink">{% blocktrans with name=requirement.name %}Bypass “{{ name }}”{% endblocktrans %}</h3>
<form method="post" action="{% url 'management:member_requirement_bypass' membership.member.pk requirement.pk %}" id="bypass_{{ membership.pk }}_{{ requirement.pk }}_form">
{% csrf_token %}
<input type="hidden" name="next" value="{{ signup_url }}">
<div class="form-control mt-2">
<label class="label-text">{% trans "Why isn't this needed?" %}</label>
<textarea class="textarea" name="note" rows="2" required placeholder="{% trans 'e.g. already has a recent photo on file' %}"></textarea>
</div>
</form>
<div class="modal-action">
<form method="dialog"><button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button></form>
<button class="btn btn-primary gap-2" type="submit" form="bypass_{{ membership.pk }}_{{ requirement.pk }}_form">{% lucide "check" size=16 %} {{ bypass_label }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endif %}
{% empty %}
<p class="text-sm text-muted">{% trans "This club has no onboarding requirements set up." %}</p>
{% endfor %}
</div>
</div>
{% endif %}
<div>
<div class="mb-2.5 font-display text-xs font-bold tracking-[.14em] text-muted uppercase">{% trans "Place in" %}</div>
<div class="flex flex-wrap gap-2">
{% for team in teams %}
<form method="post" action="{% url 'management:signup_place_in_team' membership.member.pk %}" data-place-in-team>
{% csrf_token %}
<input type="hidden" name="team" value="{{ team.pk }}">
<button type="submit" class="team-chip {% if team in membership.teams_registered %}placed{% endif %}" {% if team in membership.teams_registered %}disabled{% endif %}>
{{ team.short_name }}
</button>
</form>
{% empty %}
<p class="text-sm text-muted">{% trans "This club has no teams set up yet." %}</p>
{% endfor %}
</div>
<p class="mt-2 text-xs text-muted">{% trans "Rosters them immediately so the team's coach can see them -- position/number is the team's own call to set correctly later. They still won't be invited to (or selectable for) an event kind a blocking checklist item covers." %}</p>
</div>
<div>
<div class="mb-2.5 font-display text-xs font-bold tracking-[.14em] text-muted uppercase">{% trans "Fee" %}</div>
<div class="flex items-center justify-between rounded-lg border border-line p-3">
<span class="badge badge-sm {% if membership.fee_status == "paid" %}badge-success{% elif membership.fee_status == "partially_paid" %}badge-warning{% elif membership.fee_status == "unpaid" %}badge-error{% else %}badge-ghost{% endif %}">{{ membership.get_fee_status_display }}</span>
<a class="link link-hover text-sm" href="{% url 'management:membership_list' %}">{% trans "Manage in Finance" %} →</a>
</div>
</div>
</div>
<div class="flex gap-2.5 border-t border-line p-4">
<form method="post" action="{% url 'management:signup_approve_one' membership.member.pk %}" class="flex-1">
{% csrf_token %}
<button class="btn btn-success w-full gap-2" type="submit" {% if not membership.is_clean or membership.status != "pending" %}disabled{% endif %}>{% lucide "check" size=16 %} {% trans "Approve" %}</button>
</form>
<button type="button" class="btn btn-outline gap-2" onclick="closeDrawer('{{ membership.pk|dom_id:"signup_drawer" }}')">{% trans "Close" %}</button>
</div>
</div>
</div>
{% endfor %}
<script>
// The detail drawer needs JS to open at all now (see assets/management.css's
// own .drawer-backdrop comment for why it's a plain div, not a native
// <dialog> -- Safari rounds a shown <dialog>'s corners regardless of
// author CSS). display can't itself transition, so opening adds .visible
// (display:block) and forces a reflow *before* adding .open (the actual
// background-fade/slide-in), otherwise the browser has no "just-appeared,
// still off-screen" frame to animate away from.
function openDrawer(id) {
const backdrop = document.getElementById(id);
backdrop.classList.add("visible");
void backdrop.offsetHeight;
backdrop.classList.add("open");
}
function closeDrawer(id) {
const backdrop = document.getElementById(id);
backdrop.classList.remove("open");
setTimeout(() => backdrop.classList.remove("visible"), 200);
}
document.querySelectorAll(".drawer-backdrop").forEach((backdrop) => {
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) closeDrawer(backdrop.id);
});
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
document.querySelectorAll(".drawer-backdrop.open").forEach((backdrop) => closeDrawer(backdrop.id));
});
// "Place in [team]" posts in the background -- clicking a team is a single
// fact ("this member is on this team now"), not something that needs a
// full-page reload to confirm. X-Requested-With is what
// SignupPlaceInTeamView checks to answer with JSON instead of a redirect;
// a non-JS client (or a fetch failure) still gets the exact same effect
// through a plain synchronous submit, just with a page reload.
document.querySelectorAll("[data-place-in-team]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const button = form.querySelector("button");
if (button.disabled) return;
fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: {"X-Requested-With": "XMLHttpRequest"},
})
.then((response) => response.json())
.then((data) => {
if (data.ok) {
button.classList.add("placed");
button.disabled = true;
}
})
.catch(() => form.submit());
});
});
</script>
{% endblock extra_body %}