Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend

Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-19 23:34:43 +02:00
parent bff685966d
commit adf1120358
157 changed files with 20342 additions and 4008 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
media
private_media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.

View File

@@ -409,7 +409,7 @@ ClubMembership(ClubScopedModel) # -> carries `club`
status CharField (TextChoices: pending | active | lapsed | cancelled)
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
signed_up_at DateTimeField (null) # when the member registered for the season
activated_at DateTimeField (null) # when membership became active (usually on payment)
activated_at DateTimeField (null) # when membership became active (admin approval only, never on payment alone)
Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
```

View File

@@ -117,34 +117,30 @@ docker compose up -d --no-deps web
## Scheduled jobs
Four commands need to run on a schedule. Put them on the **host**, not in a container, and on
**exactly one node** when you have several — three nodes archiving the same club is three
emails to the same club.
Five jobs run on a schedule via **Celery Beat**, not host cron — see `rosterchief/settings.py`
(`CELERY_BEAT_SCHEDULE`) for the exact times and `features/jobs.py` for what each one does.
`worker` and `beat` are just the `web` image running a different command (see `compose.yaml`);
`worker` can scale to several containers, but run **exactly one `beat`** across the whole
deployment — it decides *when* a task fires, so two of them means every job runs twice (two
`archive_overdue_clubs` runs is two emails to the same club, the same "exactly one node"
reasoning the old crontab needed).
```cron
# Bill: remind club admins about outstanding platform fees. Dry-run by default, same as the
# archive job below — this one mails paying customers, so --commit is opt-in. Reminders go
# once per escalation level, not once per run, so a daily cron is not a daily email.
0 5 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py send_billing_reminders --commit
| Job | Cadence | What it does |
|---|---|---|
| `extend_event_series` | daily 03:00 | materialises recurring event occurrences so the calendar never runs dry |
| `renew_subscriptions` | daily 04:00 | opens the next billing period for clubs whose current one is running out |
| `send_billing_reminders` | daily 05:00 | emails club admins about outstanding platform fees, once per escalation level |
| `archive_overdue_clubs` | daily 06:00 | archives clubs unpaid past their grace period |
| `generate_seasons` | monthly, 1st 05:00 | generates the next 2 years of seasons for every active club |
# Bill: archive clubs unpaid past their grace period.
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
# not a morning of angry clubs. Since grace now runs from the period START rather than its
# end (see BILLING.md §3), this job is load-bearing in a way it never used to be — a club
# is archivable ~60 days after being invoiced, not ~410. Re-do the dry-run week.
0 6 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit
Each task always acts (no `--dry-run`/`--commit` gate) — the same as the old crontab always
passing `--commit`. Run status (started, finished, success/failure, what it returned or
raised) is recorded in `features.models.JobRun` and shown on the control panel's **Jobs**
tab, which a crontab line mailing stderr on failure never gave us.
# Events: extend recurring series so the calendar never runs dry.
0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series
# Seasons: generate the next 2 years ahead for every active club. Safe to run repeatedly and
# needs no --commit — unlike archiving or resyncing, creating a future season row is additive
# and idempotent, so a monthly cadence just keeps every club's season list from ever running
# out. --resync exists on the same command for removing seasons that no longer match a club's
# settings, but that can delete rows, so it isn't run unattended here.
0 5 1 * * cd /srv/rosterchief && docker compose run --rm web python manage.py generate_seasons
```
The `manage.py <command>` versions of these still exist unchanged, for manual/dry-run use
from a shell — see each command's own `--help` (`generate_seasons --resync`, for one, is
still CLI-only: it can delete rows, so it isn't something a beat schedule runs unattended).
## Maintenance mode
@@ -155,16 +151,19 @@ Control panel → **Features → Maintenance mode**. While it is on:
you with no way to turn it back off;
- `/healthz` keeps answering on every host, or the load balancer would take the node out of
rotation and the control panel with it;
- the **scheduled jobs stand down**`archive_overdue_clubs`, `extend_event_series` and
`import_members_csv` refuse to run.
- the **scheduled jobs stand down**the five Celery tasks in the table above, plus
`import_members_csv` when run by hand.
`migrate` and `collectstatic` are deliberately **not** blocked. Maintenance is usually
declared *in order* to run them, and a guard that stopped them would mean turning the mode
off to do the work you turned it on for.
The scheduled jobs exit **non-zero** while the platform is closed, so cron will mail you.
That is intended: a job that silently skips itself is how a month of billing goes missing. If
you genuinely mean to run one during a window, pass `--ignore-maintenance`.
A Celery task raises loudly rather than skipping quietly while the platform is closed — that
is intended, a job that silently no-ops is how a month of billing goes missing — which
`worker` logs and, via `features/signals.py`, records as a `Failed` JobRun on the control
panel's **Jobs** tab. The `manage.py` version of each command still exits non-zero the same
way and accepts `--ignore-maintenance` for the rare case you genuinely mean to run one by
hand during a window.
So a migration-heavy deploy looks like:
@@ -456,6 +455,12 @@ via copy-on-write instead of each worker importing Django independently), plus t
Postgres rows to come in lower than above — not yet re-measured, so treat the table as the
shape of where memory goes rather than exact numbers on the current config.
The table also predates `worker` and `beat` (see "Scheduled jobs"): each is one more full
Django process, not re-measured yet either, but expect each to land in the same range as one
gunicorn worker above (~5060 MB) since it's the same app import cost with none of gunicorn's
own overhead. `beat` additionally has essentially nothing to do between firing its five daily
tasks, so it's the cheapest process in the stack to run.
2 GB would run it. 4 GB is the recommendation for three reasons, all of which are the kind of
thing that bites at the worst moment:
@@ -599,7 +604,7 @@ Nothing in the code changes. What changes is where the services live:
| Cache / flags | `redis` container | managed Redis (or your existing one) |
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
| Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there |
| Cron | host crontab | one node only |
| Scheduled jobs | `worker` + `beat` containers | `worker` on any/every node; **`beat` on exactly one** |
| TLS | Caddy on the box | load balancer, or Caddy on each node |
Drop `db` and `redis` from `compose.yaml`, point the URLs at the central services, and run

View File

@@ -77,6 +77,8 @@ COPY --from=venv /app/.venv ./.venv
COPY . .
COPY --from=css /build/static/css/app.css ./static/css/app.css
COPY --from=css /build/static/css/controlpanel.css ./static/css/controlpanel.css
COPY --from=css /build/static/css/management.css ./static/css/management.css
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
@@ -88,7 +90,7 @@ RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
# app runs as rosterchief, not root. Existing image content (even an empty, correctly-owned
# dir) is what a named volume copies its initial ownership from on first use.
RUN useradd --system --uid 1000 rosterchief \
&& mkdir -p /app/media \
&& mkdir -p /app/media /app/private_media \
&& chown -R rosterchief /app
USER rosterchief

1063
assets/controlpanel.css Normal file

File diff suppressed because it is too large Load Diff

1063
assets/management.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
"""Force MFA enrolment for privileged users.
Anyone who can change other people's data must have a second factor: Django
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
in *any* club. Regular members may enrol, but aren't forced to.
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN, EDITOR, or
MEMBER_ADMIN) in *any* club. Regular members may enrol, but aren't forced to.
Enrolled users are challenged for their second factor by allauth at login; this
middleware only handles the other half — a privileged user who has never
@@ -21,7 +21,7 @@ from club.models import ClubRole
#: under DEBUG — without it, live reload dies on the enrolment page itself.
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)
ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)
def mfa_required_for(user) -> bool:

84
billing/tasks.py Normal file
View File

@@ -0,0 +1,84 @@
"""Celery tasks behind the billing beat schedule entries (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Each mirrors its management command's *acting* behaviour exactly -- manage.py's own
--dry-run/--commit flags exist for a human at a terminal to preview first (see
billing/management/commands/), which a beat schedule has no terminal to do. These always
act, the same as the crontab entries they replace always passed --commit.
"""
from celery import shared_task
from django.utils import timezone
from billing.services import BillingError
from billing.services.dues import archivable_clubs, renew, subscriptions_due_for_renewal
from billing.services.reminders import reminders_to_send, send_reminder
from club.models import Club
from features.models import Maintenance
def _stand_down():
# Loud, not silent -- see events/tasks.py for why these raise instead of skipping quietly.
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
@shared_task(name="billing.tasks.renew_subscriptions")
def renew_subscriptions():
if Maintenance.is_on():
_stand_down()
due_for_renewal = subscriptions_due_for_renewal()
if not due_for_renewal:
return "Nothing to renew."
renewed, failures = 0, []
for subscription in due_for_renewal:
try:
renew(subscription)
renewed += 1
except BillingError as error:
# One unpriced plan must not stop every other club from being billed.
failures.append(f"{subscription.club}: {error}")
if failures:
raise RuntimeError(f"Renewed {renewed} club(s), {len(failures)} failed:\n " + "\n ".join(failures))
return f"Renewed {renewed} club(s)."
@shared_task(name="billing.tasks.send_billing_reminders")
def send_billing_reminders():
if Maintenance.is_on():
_stand_down()
clubs = Club.objects.active().select_related("subscription", "subscription__plan").order_by("name")
sendable = [result for result in reminders_to_send(clubs) if result.sent]
if not sendable:
return "Nothing owing. No reminders to send."
sent, failures = 0, []
for result in sendable:
try:
send_reminder(result.club, result.notice, recipients=result.recipients)
sent += 1
except OSError as error:
# One bad address or a momentary SMTP failure must not stop the rest of the run.
failures.append(f"{result.club}: {error}")
if failures:
raise RuntimeError(f"Sent {sent} reminder(s), {len(failures)} failed:\n " + "\n ".join(failures))
return f"Sent {sent} reminder(s)."
@shared_task(name="billing.tasks.archive_overdue_clubs")
def archive_overdue_clubs():
if Maintenance.is_on():
_stand_down()
overdue = list(archivable_clubs(timezone.localdate()))
for due in overdue:
due.club.archive()
return f"Archived {len(overdue)} club(s)."

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
@admin.register(Club)
@@ -63,3 +63,19 @@ class ClubRoleAdmin(admin.ModelAdmin):
search_fields = ["club__name", "member__last_name", "member__first_name"]
list_filter = ["club", "role"]
raw_id_fields = ["member"]
@admin.register(OnboardingRequirement)
class OnboardingRequirementAdmin(admin.ModelAdmin):
list_display = ["name", "club", "requires_document", "is_active", "order"]
list_filter = ["club", "is_active", "requires_document"]
search_fields = ["name", "club__name"]
ordering = ["club", "order", "name"]
@admin.register(MemberRequirementStatus)
class MemberRequirementStatusAdmin(admin.ModelAdmin):
list_display = ["membership", "requirement", "is_complete", "completed_at", "completed_by"]
list_filter = ["requirement__club", "is_complete", "requirement"]
search_fields = ["membership__member__last_name", "membership__member__first_name", "requirement__name"]
raw_id_fields = ["membership"]

View File

@@ -1,23 +1,46 @@
"""Tenant-aware page branding.
Every page inherits its chrome from ``base_template``. On a club subdomain that
resolves to the club-branded skin, on the base domain to the RosterChief one, so
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
now or later) follow the tenant without a single template of their own knowing
that clubs exist.
resolves to the club-branded skin, on the base domain to the platform one — the
control panel's own industrial design system (assets/controlpanel.css) — so the
auth screens (login, password reset, MFA, passkeys — anything allauth ships, now
or later) follow the tenant without a single template of their own knowing that
clubs exist. templates/403.html and templates/maintenance.html extend
``base_template`` directly too, so they follow the same split.
The control panel deliberately does *not* use this: it hardcodes the platform
base, so no branding bug can ever dress the platform panel up as a club.
A club subdomain serves two very different chromes, though: the public club site
(daisyUI, assets/app.css) and the management app (assets/management.css) live on
the same tenant, distinguished only by path. Without the checks below, a staff
member clicking "Change password" from inside the management app would land back
on the club's *public* skin -- jarring, and visually nothing like where they just
were. MANAGEMENT_BASE_TEMPLATE picks up management/base.html's own chrome instead,
for two cases: a request path directly under /manage/ (matching management/urls.py's
own hardcoded "manage/" prefix in rosterchief/urls.py -- e.g. a 403 on a management
page), and the session flag ClubStaffRequiredMixin.dispatch sets on every management
view (club/mixins.py) -- needed because allauth's password-change/MFA/logout screens
live under /accounts/, outside /manage/, so the path check alone can't see they were
reached from the management app's own user menu.
The control panel's own pages deliberately do *not* use this: controlpanel/base.html
hardcodes itself, so no branding bug can ever dress the platform panel up as a club.
"""
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
PLATFORM_BASE_TEMPLATE = "controlpanel/_auth_base.html"
CLUB_BASE_TEMPLATE = "_club_base.html"
MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html"
def branding(request):
club = getattr(request, "club", None) # set by ClubTenantMiddleware
if club and (request.path.startswith("/manage/") or request.session.get("management_context")):
base_template = MANAGEMENT_BASE_TEMPLATE
elif club:
base_template = CLUB_BASE_TEMPLATE
else:
base_template = PLATFORM_BASE_TEMPLATE
return {
"club": club,
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
"base_template": base_template,
}

View File

@@ -0,0 +1,65 @@
# Generated by Django 6.0.6 on 2026-08-16 20:42
import club.models
import django.core.files.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0024_club_contact_email'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OnboardingRequirement',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, verbose_name='name')),
('description', models.TextField(blank=True, help_text="Shown to staff on the member's checklist.", verbose_name='description')),
('requires_document', models.BooleanField(default=False, help_text='Staff can attach a file (e.g. the certificate itself) when marking this complete.', verbose_name='requires a document')),
('is_active', models.BooleanField(default=True, help_text='Inactive requirements no longer apply to new memberships, but existing statuses are kept.', verbose_name='active')),
('order', models.PositiveIntegerField(default=0, help_text='Lower numbers show first on the checklist.', verbose_name='order')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'onboarding requirement',
'verbose_name_plural': 'onboarding requirements',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='MemberRequirementStatus',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_complete', models.BooleanField(default=False, verbose_name='complete')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
('document', models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=django.core.files.storage.FileSystemStorage(base_url=None, location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document')),
('note', models.TextField(blank=True, help_text='Staff-only, e.g. how or when this was received.', verbose_name='note')),
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='completed by')),
('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_statuses', to='club.clubmembership', verbose_name='membership')),
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='club.onboardingrequirement', verbose_name='requirement')),
],
options={
'verbose_name': 'member requirement status',
'verbose_name_plural': 'member requirement statuses',
},
),
migrations.AddConstraint(
model_name='onboardingrequirement',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_onboarding_requirement_name_per_club'),
),
migrations.AddConstraint(
model_name='memberrequirementstatus',
constraint=models.UniqueConstraint(fields=('membership', 'requirement'), name='unique_requirement_status_per_membership'),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.6 on 2026-08-17 11:39
import club.models
import rosterchief.storage
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0025_onboardingrequirement_memberrequirementstatus_and_more'),
]
operations = [
migrations.AddField(
model_name='memberrequirementstatus',
name='is_bypassed',
field=models.BooleanField(default=False, verbose_name='bypassed'),
),
migrations.AddField(
model_name='onboardingrequirement',
name='blocked_event_kinds',
field=models.JSONField(blank=True, default=list, help_text="Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational.", verbose_name='blocks selection for'),
),
migrations.AlterField(
model_name='clubrole',
name='role',
field=models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor'), ('member_admin', 'member admin')], default='member', max_length=250, verbose_name='role'),
),
migrations.AlterField(
model_name='memberrequirementstatus',
name='document',
field=models.FileField(blank=True, help_text="Stored privately -- readable only through this member's own page, never a direct link.", storage=rosterchief.storage.PrivateStorage(location='/Users/bernard/Code/PycharmProjects/RosterChief/private_media'), upload_to=club.models.onboarding_document_path, verbose_name='document'),
),
]

View File

@@ -4,7 +4,7 @@ from waffle import flag_is_active
from members.models import Group
from .services.access import can_add_news, can_edit_news, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
from .services.access import can_add_news, can_edit_news, can_manage_members, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -25,6 +25,12 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is None:
raise Http404("The management app is not available on the base domain.")
# Read by club/context_processors.py's branding() -- allauth's password-change/MFA/
# logout screens live under /accounts/, not /manage/, so a path check alone can't
# tell they were reached from the management app's own user menu. This sticks for
# the rest of the session (nothing clears it back to False on a public-site visit),
# which is the right default for the common case of one person, one role.
request.session["management_context"] = True
return super().dispatch(request, *args, **kwargs)
def test_func(self):
@@ -32,13 +38,28 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
seasons, positions, roles, shop configuration."""
"""ADMIN role only (a platform superuser always passes too, see
is_club_admin) — genuinely admin-only ground: Finance/Shop, Club identity,
Sponsors, seasons, and granting/revoking ClubRole itself. Everything a
MEMBER_ADMIN may also touch uses MemberAdminRequiredMixin below instead."""
def test_func(self):
return is_club_admin(self.request.user, self.request.club)
class MemberAdminRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, a platform superuser, or MEMBER_ADMIN specifically -- full read/write
on people: members, families, groups, parent claims, member import, teams
(roster/staff/CRUD), referee levels, referee management, and onboarding
requirements. Deliberately does NOT cover Finance/Shop, Club identity,
Sponsors, or role-granting (role_list/role_create/role_revoke stay
ClubAdminRequiredMixin) -- a MEMBER_ADMIN must never be able to grant
themselves, or anyone else, real ADMIN."""
def test_func(self):
return can_manage_members(self.request.user, self.request.club)
class FeatureRequiredMixin(ClubAdminRequiredMixin):
"""Gate for a whole management section (shop, forms, ...) this club doesn't
have at all unless its waffle Flag (see the ``features`` app, set per-club

View File

@@ -5,11 +5,13 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
from rosterchief.storage import private_storage
class ClubManager(models.Manager):
@@ -256,6 +258,14 @@ class Season(ClubScopedModel):
context needed) -- the season that follows the one covering ``date``."""
return cls.objects.filter(club=club, start_date__gt=date).order_by("start_date").first()
@classmethod
def before(cls, club, season):
"""Return ``club``'s most recent season starting before ``season`` --
e.g. the management dashboard's member-count trend compares against
this. Mirrors next_after's own "adjacent by date" reasoning, just
looking the other way."""
return cls.objects.filter(club=club, start_date__lt=season.start_date).order_by("-start_date").first()
class ClubMembership(ClubScopedModel):
class Kind(models.TextChoices):
@@ -319,6 +329,21 @@ class ClubMembership(ClubScopedModel):
"""
return self.kind == self.Kind.GUARDIAN
@property
def open_requirement_count(self) -> int:
"""How many active onboarding requirements this membership hasn't resolved
yet (completed or bypassed) -- see OnboardingRequirement's docstring for why
this is separate from status/fee_status. One query per call; for a list of
memberships, annotate with club.services.onboarding.annotate_onboarding_status
instead."""
met = set(self.requirement_statuses.filter(Q(is_complete=True) | Q(is_bypassed=True)).values_list("requirement_id", flat=True))
required = set(OnboardingRequirement.objects.filter(club_id=self.club_id, is_active=True).values_list("pk", flat=True))
return len(required - met)
@property
def onboarding_complete(self) -> bool:
return self.open_requirement_count == 0
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
# A guardian owes nothing -- they're not a member. Caught here rather than
@@ -356,11 +381,104 @@ class FeePayment(UUIDModel):
return f"{self.membership}{self.amount}"
def onboarding_document_path(instance: MemberRequirementStatus, filename: str) -> str:
return f"clubs/{instance.membership.club.slug}/onboarding/{instance.membership_id}/{filename}"
class OnboardingRequirement(ClubScopedModel):
"""A club-defined item every member must satisfy after signing up or renewing --
e.g. "provide a medical certificate", "upload a photo".
``ClubMembership.fee_status`` is still driven by payment alone (see
``club.services.fees._sync_fee_status``) and this never touches it -- a member
reads as paid *and* still has an open checklist, both true at once. ``status``
is different: paying in full only ever settles ``fee_status`` now -- it never
flips ``status`` to ACTIVE by itself. The only path there is the deliberately
manual one, ``club.services.onboarding.approve_one``/``approve_all_clean``, run
by an admin from the Sign-up page, which additionally requires every blocking
requirement to be resolved first. Nothing flips status automatically just
because the fee cleared or the last checklist item was ticked (checklist actions
aren't even admin-gated); activation is always that one deliberate admin step,
so a membership can be fully paid *and* fully checked off and still sit PENDING
until someone actually clicks Approve.
``blocked_event_kinds`` is what makes a specific requirement matter before that
point: a club can decide e.g. a medical certificate blocks GAME invitations/
selection but not TRAINING ones, so a provisionally-rostered member (see
``events.services.attendance.effective_members``) can still be invited to practice
while their paperwork is outstanding. Empty means "informational only" -- open or
not, it never blocks anything. Stored as a plain list of ``events.models.Event.
EventKind`` values (not a FK/enum at the DB layer) specifically to avoid a
club -> events import cycle (events already imports club for Event.club); the
form layer (management/forms.py) is what actually validates against EventKind.
``MemberRequirementStatus`` tracks completion per ``ClubMembership`` (so a fresh
checklist starts each season, matching how membership itself is season-scoped).
"""
name = models.CharField(_("name"), max_length=100)
description = models.TextField(_("description"), blank=True, help_text=_("Shown to staff on the member's checklist."))
requires_document = models.BooleanField(_("requires a document"), default=False, help_text=_("Staff can attach a file (e.g. the certificate itself) when marking this complete."))
blocked_event_kinds = models.JSONField(_("blocks selection for"), default=list, blank=True, help_text=_("Event kinds a member can't be invited to or selected for while this is open. Empty means purely informational."))
is_active = models.BooleanField(_("active"), default=True, help_text=_("Inactive requirements no longer apply to new memberships, but existing statuses are kept."))
order = models.PositiveIntegerField(_("order"), default=0, help_text=_("Lower numbers show first on the checklist."))
class Meta:
verbose_name = _("onboarding requirement")
verbose_name_plural = _("onboarding requirements")
ordering = ["order", "name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_onboarding_requirement_name_per_club"),
]
def __str__(self):
return self.name
class MemberRequirementStatus(UUIDModel):
"""Whether one ``ClubMembership`` has satisfied one ``OnboardingRequirement``,
this season. Not itself club-scoped -- its club is reached through ``membership``,
same reasoning as ``FeePayment`` above."""
membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="requirement_statuses", verbose_name=_("membership"))
requirement = models.ForeignKey(OnboardingRequirement, on_delete=models.CASCADE, related_name="statuses", verbose_name=_("requirement"))
is_complete = models.BooleanField(_("complete"), default=False)
#: Distinct from is_complete -- "confirmed, not needed for this person" (e.g. they
#: already have a recent photo on file) reads differently from "actually received"
#: on a checklist/audit, even though both equally stop this item from blocking
#: anything (see club.services.onboarding.is_open). Mutually exclusive with
#: is_complete in practice (mark_bypassed/mark_complete each clear the other).
is_bypassed = models.BooleanField(_("bypassed"), default=False)
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", verbose_name=_("completed by"))
document = models.FileField(_("document"), storage=private_storage, upload_to=onboarding_document_path, blank=True, help_text=_("Stored privately -- readable only through this member's own page, never a direct link."))
note = models.TextField(_("note"), blank=True, help_text=_("Staff-only, e.g. how or when this was received."))
class Meta:
verbose_name = _("member requirement status")
verbose_name_plural = _("member requirement statuses")
constraints = [
models.UniqueConstraint(fields=["membership", "requirement"], name="unique_requirement_status_per_membership"),
]
def __str__(self):
return f"{self.membership}{self.requirement}"
def clean(self):
validate_club_scope(self, self.membership.club_id, same_club_fields=("requirement",))
class ClubRole(ClubScopedModel):
class Roles(models.TextChoices):
ADMIN = "admin", _("admin")
MEMBER = "member", _("member")
EDITOR = "editor", _("editor")
#: Full read/write on people (members, families, groups, parent claims,
#: teams, referee setup, onboarding requirements) without Finance/Shop,
#: Club identity, Sponsors, or the ability to grant/revoke ClubRole itself
#: -- see club.services.access.can_manage_members and
#: club.mixins.MemberAdminRequiredMixin for exactly what that covers.
MEMBER_ADMIN = "member_admin", _("member admin")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)

View File

@@ -45,19 +45,44 @@ def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
def is_platform_superuser(user: User) -> bool:
"""A Django superuser sees and manages every club as if they held ADMIN there,
with no ClubRole row needed -- the platform-operator override. Already forced
through MFA regardless (authentication.middleware.mfa_required_for checks
is_superuser directly), so this bypass never skips that."""
return bool(user and user.is_authenticated and user.is_superuser)
def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
return is_platform_superuser(user) or has_club_role(user, club, ClubRole.Roles.ADMIN)
def is_member_admin(user: User, club: Club) -> bool:
"""MEMBER_ADMIN: full read/write on people (members, families, groups, parent
claims, teams, referee setup, onboarding requirements) without Finance/Shop,
Club identity, Sponsors, or the ability to grant/revoke ClubRole itself --
see can_manage_members for the actual gate, this is just the role check."""
return has_club_role(user, club, ClubRole.Roles.MEMBER_ADMIN)
def can_manage_members(user: User, club: Club) -> bool:
"""The gate for club.mixins.MemberAdminRequiredMixin -- real ADMIN (which already
includes the superuser bypass), or MEMBER_ADMIN specifically."""
return is_club_admin(user, club) or is_member_admin(user, club)
def has_management_access(user: User, club: Club) -> bool:
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
staff assignment (coach, team manager, physio, ...).
"""Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform
superuser, or *any* current-season staff assignment (coach, team manager,
physio, ...).
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
member generally) holds that automatically the moment their ClubMembership goes
active (club/signals.py), so it says nothing about whether someone is staff.
"""
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
if is_platform_superuser(user):
return True
elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR, ClubRole.Roles.MEMBER_ADMIN)).exists()
return elevated or teams_staffed_by(user, club).exists()

View File

@@ -8,7 +8,6 @@ step here, never recomputed by re-aggregating FeePayment on every read.
from decimal import Decimal
from django.db.models import F
from django.utils import timezone
from club.models import ClubMembership, FeePayment
@@ -20,7 +19,8 @@ def remaining_balance(membership):
def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
"""Record money received against one membership's fee. Several payments may
land on one membership -- a family paying in two installments must not read as
unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
unpaid. Updates amount_paid and re-syncs fee_status to match; membership.status
is untouched -- see _sync_fee_status."""
payment = FeePayment.objects.create(membership=membership, amount=amount, method=method, reference=reference, note=note, recorded_by=recorded_by)
membership.amount_paid = F("amount_paid") + amount
@@ -54,16 +54,10 @@ def _sync_fee_status(membership, *, force_paid=False):
else:
new_status = ClubMembership.FeeStatus.UNPAID
# fee_status only -- membership.status is never touched here. Paying in full
# used to also flip status straight to ACTIVE on its own; now that's exclusively
# club.services.onboarding.approve_one/approve_all_clean's call, so a paid-up
# membership still waits on that deliberate admin step. See OnboardingRequirement's
# docstring (club/models.py) for why.
membership.fee_status = new_status
update_fields = ["fee_status"]
# Same "become a full member" behavior the bulk action already had: settling
# the fee in full also activates the membership, once, first time only.
if new_status == ClubMembership.FeeStatus.PAID:
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields.append("status")
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)
membership.save(update_fields=["fee_status"])

231
club/services/onboarding.py Normal file
View File

@@ -0,0 +1,231 @@
"""Per-member onboarding checklist -- see OnboardingRequirement's docstring
(club/models.py) for why fee_status stays untouched by any of this, and for
why approve_one/approve_all_clean below are the only way to reach
ClubMembership.status ACTIVE (fee_status alone, even fully PAID, never does).
No signal pre-creates a MemberRequirementStatus row when a membership is created
or a requirement is added: "required, no row yet" and "required, row with
is_complete=is_bypassed=False" both mean the same thing (not done), so there is
nothing to backfill either way -- a club adding a new requirement mid-season
immediately shows it as open on every existing membership, and deactivating one
immediately stops asking for it, with no migration-shaped cleanup step in either
direction.
"""
from collections import defaultdict
from django.db.models import Q
from django.utils import timezone
from club.models import ClubMembership, MemberRequirementStatus, OnboardingRequirement
from members.models import Member
#: Shared by every "is this item resolved" check below -- resolved means it no
#: longer blocks anything, whether that's because it was actually completed or
#: because staff decided it doesn't apply to this person.
_RESOLVED = Q(is_complete=True) | Q(is_bypassed=True)
def checklist_for(membership):
"""Every active requirement for this membership's club, each paired with its
status row if one exists (or None -- not started). One query for the
requirements, one for the statuses that exist; the membership detail page
renders exactly this list under its Documents tab."""
requirements = OnboardingRequirement.objects.filter(club_id=membership.club_id, is_active=True)
statuses = {status.requirement_id: status for status in membership.requirement_statuses.select_related("completed_by")}
return [(requirement, statuses.get(requirement.pk)) for requirement in requirements]
def mark_complete(membership, requirement, *, user, document=None, note=""):
"""Actually received/verified -- as opposed to mark_bypassed, "not needed for
this person". Clears any prior bypass: the two are mutually exclusive."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = True
status.is_bypassed = False
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
if document:
status.document = document
status.save()
return status
def mark_bypassed(membership, requirement, *, user, note=""):
"""Confirmed not needed for this member (e.g. they already have a recent
photo on file) -- stops the item blocking anything, same as mark_complete,
but reads correctly on the checklist/audit trail as a deliberate staff
decision rather than a document actually received. A note is expected here
(not enforced at this layer -- see RequirementBypassForm) since "why" is the
whole point of a bypass in a way it isn't for an ordinary completion."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = True
status.completed_at = timezone.now()
status.completed_by = user
status.note = note
status.document = None
status.save()
return status
def mark_incomplete(membership, requirement):
"""Undo a mark_complete/mark_bypassed -- kept as a row (not deleted) so the
document/note a club already collected isn't thrown away by an accidental
toggle."""
status, _created = MemberRequirementStatus.objects.get_or_create(membership=membership, requirement=requirement)
status.is_complete = False
status.is_bypassed = False
status.completed_at = None
status.completed_by = None
status.save()
return status
def annotate_onboarding_status(queryset):
"""`queryset` of ClubMembership, returned as a list with each row given an
`.onboarding_open` attribute (count of unresolved active requirements) -- the
list-page equivalent of the `open_requirement_count` property, in a fixed
number of queries regardless of list size rather than the N+1 a per-row
property call would cost across a whole table."""
memberships = list(queryset)
if not memberships:
return memberships
required_by_club = {}
for club_id in {membership.club_id for membership in memberships}:
required_by_club[club_id] = set(OnboardingRequirement.objects.filter(club_id=club_id, is_active=True).values_list("pk", flat=True))
met_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership_id__in=[membership.pk for membership in memberships]).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
met_by_membership[membership_id].add(requirement_id)
for membership in memberships:
required = required_by_club.get(membership.club_id, set())
membership.onboarding_open = len(required - met_by_membership[membership.pk])
return memberships
def members_with_open_requirements(club, season):
"""Members whose current-season membership has at least one unresolved active
requirement -- the same condition the dashboard's "Missing documentation" KPI
counts (management.views.HomeView), reused here for the member list's own
?docs=open filter. None when there's no season to check against."""
if season is None:
return Member.objects.none()
memberships = list(ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER))
annotate_onboarding_status(memberships)
member_ids = [membership.member_id for membership in memberships if membership.onboarding_open]
return Member.objects.filter(pk__in=member_ids)
def blocking_event_kinds(membership) -> set:
"""Every event kind currently blocked for this membership by at least one open
(not complete, not bypassed) active requirement -- e.g. {"game"} while a medical
certificate is outstanding but nothing blocks training. Powers the Sign-up page's
detail pane and member_detail's Documents tab ("blocks: Games" next to an open
item), so staff can see exactly what's at stake without reading every requirement."""
blocked = set()
for requirement, status in checklist_for(membership):
if status is not None and (status.is_complete or status.is_bypassed):
continue
blocked.update(requirement.blocked_event_kinds)
return blocked
def blocked_member_ids_for_event(club, season, event_kind) -> set:
"""Member ids that must NOT be invited to (or selectable for) an event of
`event_kind` this season, because at least one active requirement that blocks
that kind is still open on their current-season membership. Bulk, not per-member
-- events.services.attendance.effective_members() calls this once per event save,
not once per candidate member.
A member with no current-season ClubMembership.MEMBER row at all isn't covered
here -- effective_members() already wouldn't include them (they're not on any
roster to begin with), so there's nothing to subtract.
Filtered in Python, not via a `blocked_event_kinds__contains=[event_kind]`
queryset lookup -- JSONField `contains` isn't supported on SQLite (only
Postgres/MySQL/Oracle), and a club's own requirement count is always small
enough that fetching them all costs nothing worth optimising away."""
blocking_requirement_ids = {requirement.pk for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds}
if not blocking_requirement_ids:
return set()
memberships = ClubMembership.objects.filter(club=club, season=season, kind=ClubMembership.Kind.MEMBER)
resolved_by_membership = defaultdict(set)
statuses = MemberRequirementStatus.objects.filter(membership__in=memberships, requirement_id__in=blocking_requirement_ids).filter(_RESOLVED)
for membership_id, requirement_id in statuses.values_list("membership_id", "requirement_id"):
resolved_by_membership[membership_id].add(requirement_id)
blocked_member_ids = set()
for membership_id, member_id in memberships.values_list("pk", "member_id"):
if blocking_requirement_ids - resolved_by_membership.get(membership_id, set()):
blocked_member_ids.add(member_id)
return blocked_member_ids
#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are.
_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED)
def is_signup_clean(membership) -> bool:
"""Paid up (or waived) and every active requirement resolved -- what both
approve_all_clean and approve_one gate on, and what the Sign-up page's
per-member Approve button enables/disables against. Not itself a shortcut
for "already active": a membership can be exactly this clean and still be
PENDING, waiting on this deliberately manual step."""
return membership.fee_status in _CLEAN_FEE_STATUSES and membership.onboarding_complete
def approve_one(membership) -> bool:
"""Admin-triggered single activation from the Sign-up page's detail panel --
same rule and same reasoning as approve_all_clean, just one membership instead
of a whole season's queue. Returns whether it actually activated (False if it
wasn't PENDING or wasn't clean)."""
if membership.status != ClubMembership.StatusChoices.PENDING or not is_signup_clean(membership):
return False
membership.status = ClubMembership.StatusChoices.ACTIVE
update_fields = ["status"]
if membership.activated_at is None:
membership.activated_at = timezone.localdate()
update_fields.append("activated_at")
membership.save(update_fields=update_fields)
return True
def approve_all_clean(club, season) -> int:
"""Admin-triggered bulk activation from the Sign-up page -- the *only* path to
ClubMembership.status ACTIVE (see OnboardingRequirement's docstring: paying in
full only settles fee_status now, club.services.fees._sync_fee_status never
touches status). Only ever moves PENDING -> ACTIVE, and only for a membership
that is both paid up (fee_status PAID or WAIVED) and has resolved every active
requirement -- "manual documentation check to be done by the admin" means
clicking this once everything has actually been checked, not something that runs
on its own. Returns how many memberships were activated."""
memberships = list(
ClubMembership.objects.filter(
club=club,
season=season,
kind=ClubMembership.Kind.MEMBER,
status=ClubMembership.StatusChoices.PENDING,
fee_status__in=_CLEAN_FEE_STATUSES,
)
)
annotate_onboarding_status(memberships)
ready = [membership for membership in memberships if membership.onboarding_open == 0]
today = timezone.localdate()
for membership in ready:
membership.status = ClubMembership.StatusChoices.ACTIVE
if membership.activated_at is None:
membership.activated_at = today
if ready:
ClubMembership.objects.bulk_update(ready, ["status", "activated_at"])
return len(ready)

34
club/tasks.py Normal file
View File

@@ -0,0 +1,34 @@
"""Celery task behind the `generate-seasons` beat schedule entry (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Mirrors `manage.py generate_seasons`'s default behaviour (generate, not --resync) exactly --
that command still exists, unchanged, for manual use from a shell, including --resync, which
this task deliberately does not run unattended (see club/management/commands/generate_seasons.py:
--resync can delete rows, so it isn't something a beat schedule should do on its own).
"""
from celery import shared_task
from dateutil.relativedelta import relativedelta
from django.utils import timezone
from club.models import Club
from club.services.seasons import generate_seasons as generate_seasons_for_club
from features.models import Maintenance
#: How far ahead to generate, matching the management command's own default.
YEARS_AHEAD = 2
@shared_task(name="club.tasks.generate_seasons")
def generate_seasons():
if Maintenance.is_on():
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
until = timezone.localdate() + relativedelta(years=YEARS_AHEAD)
clubs = Club.objects.active()
total = 0
for club in clubs:
total += len(generate_seasons_for_club(club, until))
return f"Generated {total} season(s) across {clubs.count()} club(s)."

View File

@@ -8,6 +8,7 @@ from allauth.mfa.models import Authenticator
from dateutil.relativedelta import relativedelta
from django.contrib import admin as django_admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError
@@ -21,18 +22,34 @@ from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members
from .models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor, club_logo_path
from .models import Club, ClubMembership, ClubRole, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
can_manage_members,
can_manage_shop,
has_club_role,
has_management_access,
is_club_admin,
is_member_admin,
is_platform_superuser,
members_visible_to,
roles_in_club,
teams_managed_by,
teams_staffed_by,
)
from .services.fees import mark_as_paid, record_payment, remaining_balance
from .services.onboarding import (
annotate_onboarding_status,
approve_all_clean,
approve_one,
blocked_member_ids_for_event,
blocking_event_kinds,
checklist_for,
mark_bypassed,
mark_complete,
mark_incomplete,
)
from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
from .tenancy import (
ClubTenantMiddleware,
@@ -496,6 +513,26 @@ class SeasonNextAfterTests(TestCase):
self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other)
class SeasonBeforeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.previous = Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31))
cls.current = Season.objects.create(club=cls.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
def test_returns_the_most_recent_season_starting_before_this_one(self):
self.assertEqual(Season.before(self.club, self.current), self.previous)
def test_returns_none_when_there_is_no_earlier_season(self):
self.assertIsNone(Season.before(self.club, self.previous))
def test_is_scoped_to_the_given_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
other_current = Season.objects.create(club=other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
self.assertIsNone(Season.before(other, other_current))
class SponsorModelTests(TestCase):
@classmethod
def setUpTestData(cls):
@@ -1035,6 +1072,61 @@ class AccessServiceTests(TestCase):
self.assertTrue(can_manage_shop(admin_user, self.club))
self.assertFalse(can_manage_shop(editor_user, self.club))
# --- platform superuser bypass ---
def test_superuser_is_club_admin_everywhere_with_no_clubrole_at_all(self):
user, _ = self.make_user_member("root@example.com")
user.is_superuser = True
user.save()
self.assertTrue(is_club_admin(user, self.club))
self.assertTrue(is_club_admin(user, self.other_club))
self.assertTrue(has_management_access(user, self.club))
self.assertTrue(is_platform_superuser(user))
def test_a_plain_staff_flag_alone_is_not_the_superuser_bypass(self):
user, _ = self.make_user_member("staffonly@example.com")
user.is_staff = True
user.save()
self.assertFalse(is_club_admin(user, self.club))
self.assertFalse(is_platform_superuser(user))
def test_an_anonymous_user_is_never_the_superuser_bypass(self):
self.assertFalse(is_platform_superuser(AnonymousUser()))
# --- MEMBER_ADMIN / can_manage_members ---
def test_member_admin_role_grants_can_manage_members_but_not_is_club_admin(self):
user, member = self.make_user_member("memberadmin@example.com")
self.grant(member, ClubRole.Roles.MEMBER_ADMIN)
self.assertTrue(is_member_admin(user, self.club))
self.assertTrue(can_manage_members(user, self.club))
self.assertFalse(is_club_admin(user, self.club))
def test_real_admin_also_satisfies_can_manage_members(self):
user, member = self.make_user_member("admin@example.com")
self.grant(member, ClubRole.Roles.ADMIN)
self.assertTrue(can_manage_members(user, self.club))
def test_editor_alone_does_not_satisfy_can_manage_members(self):
user, member = self.make_user_member("editor@example.com")
self.grant(member, ClubRole.Roles.EDITOR)
self.assertFalse(can_manage_members(user, self.club))
def test_member_admin_counts_as_management_access(self):
user, member = self.make_user_member("memberadmin@example.com")
self.grant(member, ClubRole.Roles.MEMBER_ADMIN)
self.assertTrue(has_management_access(user, self.club))
def test_member_admin_in_one_club_has_no_bearing_on_another(self):
user, member = self.make_user_member("memberadmin@example.com")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER_ADMIN)
self.assertFalse(can_manage_members(user, self.other_club))
class ClubRoleStatusSyncTests(TestCase):
@classmethod
@@ -1154,16 +1246,16 @@ class BrandingTests(TestCase):
def test_the_base_domain_gets_the_platform_skin(self):
response = self.login_page("rosterchief.app")
self.assertTemplateUsed(response, "_platform_base.html")
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
self.assertTemplateNotUsed(response, "_club_base.html")
self.assertContains(response, "Club &amp; Team Management")
self.assertContains(response, "RosterChief")
self.assertIsNone(response.context["club"])
def test_a_club_subdomain_gets_the_club_skin(self):
response = self.login_page("ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "_club_base.html")
self.assertTemplateNotUsed(response, "_platform_base.html")
self.assertTemplateNotUsed(response, "controlpanel/_auth_base.html")
self.assertContains(response, "Ajax United")
self.assertEqual(response.context["club"], self.club)
@@ -1171,7 +1263,7 @@ class BrandingTests(TestCase):
# The subdomain stops resolving, so there is no club to brand with.
self.club.archive()
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html")
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "controlpanel/_auth_base.html")
def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self):
response = self.login_page("ajax-united.rosterchief.app")
@@ -1210,6 +1302,55 @@ class BrandingTests(TestCase):
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-secondary")
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class ManagementBrandingTests(TestCase):
"""allauth's password-change/MFA/logout screens live under /accounts/, outside
/manage/, so branding() (this module) can't tell they were reached from the
management app's own user menu by path alone -- it also checks the session flag
ClubStaffRequiredMixin.dispatch sets (club/mixins.py). These are the tests for
that flag, as distinct from BrandingTests above (which only covers the plain
per-tenant split, never touching /manage/ at all)."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = Season.objects.create(club=cls.club, start_date=timezone.localdate() - datetime.timedelta(days=30), end_date=timezone.localdate() + datetime.timedelta(days=300))
cls.staff_user = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
member = Member.objects.create(user=cls.staff_user, first_name="Ada", last_name="Admin")
ClubMembership.objects.create(club=cls.club, member=member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=cls.club, member=member).update(role=ClubRole.Roles.ADMIN)
Authenticator.objects.create(user=cls.staff_user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
def test_the_change_password_screen_stays_club_branded_without_a_visit_to_manage(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "_club_base.html")
self.assertTemplateNotUsed(response, "management/_auth_base.html")
def test_the_change_password_screen_gets_the_management_skin_after_visiting_manage(self):
self.client.force_login(self.staff_user)
self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app")
response = self.client.get(reverse("account_change_password"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "management/_auth_base.html")
self.assertContains(response, "Ajax United")
def test_the_mfa_index_screen_gets_the_management_skin_after_visiting_manage(self):
self.client.force_login(self.staff_user)
self.client.get(reverse("management:home"), HTTP_HOST="ajax-united.rosterchief.app")
response = self.client.get(reverse("mfa_index"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "management/_auth_base.html")
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
@@ -1217,14 +1358,17 @@ class BrandingTests(TestCase):
class Custom403PageTests(TestCase):
"""Django's default 403 handler picks up templates/403.html automatically --
branded per tenant (base_template, same as maintenance.html) so a permission
error still looks like the app, not a bare Django error page, and the navbar
(sign out, theme toggle, home link) stays reachable."""
error still looks like the app, not a bare Django error page. A club subdomain
itself splits further: a /manage/ URL gets the management app's own skin
(management/_auth_base.html) rather than the club's public one, matching every
other allauth-adjacent screen reached from inside the management app -- see
club/context_processors.py's MANAGEMENT_BASE_TEMPLATE."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_a_club_subdomain_403_gets_the_club_skin(self):
def test_a_manage_url_403_gets_the_management_skin(self):
member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123")
self.client.force_login(member)
@@ -1233,7 +1377,7 @@ class Custom403PageTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertContains(response, "Access denied", status_code=403)
self.assertContains(response, "Ajax United", status_code=403)
self.assertContains(response, "Sign out", status_code=403)
self.assertTemplateUsed(response, "management/_auth_base.html")
def test_the_base_domain_403_gets_the_platform_skin(self):
self.client.force_login(get_user_model().objects.create_user(email="platform-403@example.com", password="pw-secret-123"))
@@ -1242,7 +1386,8 @@ class Custom403PageTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertContains(response, "Access denied", status_code=403)
self.assertContains(response, "Club &amp; Team Management", status_code=403)
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
self.assertContains(response, "RosterChief", status_code=403)
class ClubBrandingModelTests(TestCase):
@@ -1352,17 +1497,21 @@ class FeeServiceTests(TestCase):
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
def test_reaching_the_full_amount_settles_and_activates(self):
def test_reaching_the_full_amount_settles_the_fee_but_leaves_status_pending(self):
# Paying in full only ever settles fee_status now -- activation is
# exclusively club.services.onboarding.approve_one/approve_all_clean's call
# (see OnboardingRequirement's docstring), so a membership can be fully paid
# and still sit PENDING until an admin actually approves it.
record_payment(self.membership, amount=Decimal("100.00"))
record_payment(self.membership, amount=Decimal("50.00"))
self.membership.refresh_from_db()
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(self.membership.activated_at, timezone.localdate())
self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
self.assertIsNone(self.membership.activated_at)
self.assertFalse(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self):
def test_settling_in_full_never_touches_activated_at(self):
earlier = datetime.date(2026, 1, 1)
self.membership.activated_at = earlier
self.membership.save()
@@ -1400,7 +1549,7 @@ class FeeServiceTests(TestCase):
unpriced.refresh_from_db()
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(unpriced.status, ClubMembership.StatusChoices.PENDING)
self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
def test_recorded_by_is_stored_on_the_payment(self):
@@ -1652,3 +1801,294 @@ class GenerateSeasonsCommandTests(TestCase):
self.assertFalse(Season.objects.filter(pk=wrong.pk).exists())
class OnboardingRequirementTests(TestCase):
"""club.services.onboarding -- deliberately orthogonal to status/fee_status (see
OnboardingRequirement's docstring): a fully paid, active membership can still
have open requirements, and neither field moves when one is marked complete."""
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = make_season(cls.club)
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
cls.membership = ClubMembership.objects.create(
club=cls.club, member=cls.member, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE, fee_status=ClubMembership.FeeStatus.PAID
)
cls.staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
cls.photo = OnboardingRequirement.objects.create(club=cls.club, name="Photo", order=1)
cls.medical = OnboardingRequirement.objects.create(club=cls.club, name="Medical certificate", requires_document=True, order=2)
def test_a_membership_with_no_status_rows_has_every_requirement_open(self):
self.assertEqual(self.membership.open_requirement_count, 2)
self.assertFalse(self.membership.onboarding_complete)
def test_marking_one_complete_leaves_the_other_open(self):
mark_complete(self.membership, self.photo, user=self.staff)
self.assertEqual(self.membership.open_requirement_count, 1)
self.assertFalse(self.membership.onboarding_complete)
def test_completing_every_requirement_clears_the_membership(self):
mark_complete(self.membership, self.photo, user=self.staff)
mark_complete(self.membership, self.medical, user=self.staff)
self.assertTrue(self.membership.onboarding_complete)
def test_marking_complete_never_touches_status_or_fee_status(self):
# The whole point: a document upload must never re-derive membership state --
# club.services.fees owns status/fee_status exclusively.
unpaid = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
mark_complete(unpaid, self.photo, user=self.staff)
mark_complete(unpaid, self.medical, user=self.staff)
unpaid.refresh_from_db()
self.assertTrue(unpaid.onboarding_complete)
self.assertEqual(unpaid.status, ClubMembership.StatusChoices.PENDING)
self.assertEqual(unpaid.fee_status, ClubMembership.FeeStatus.UNPAID)
def test_mark_complete_records_who_and_when(self):
status = mark_complete(self.membership, self.medical, user=self.staff, note="emailed 12 Aug")
self.assertTrue(status.is_complete)
self.assertEqual(status.completed_by, self.staff)
self.assertIsNotNone(status.completed_at)
self.assertEqual(status.note, "emailed 12 Aug")
def test_mark_complete_is_idempotent_per_requirement(self):
mark_complete(self.membership, self.photo, user=self.staff)
mark_complete(self.membership, self.photo, user=self.staff)
self.assertEqual(MemberRequirementStatus.objects.filter(membership=self.membership, requirement=self.photo).count(), 1)
def test_mark_incomplete_undoes_it_without_deleting_the_row(self):
mark_complete(self.membership, self.photo, user=self.staff, note="handed in at practice")
status = mark_incomplete(self.membership, self.photo)
self.assertFalse(status.is_complete)
self.assertIsNone(status.completed_at)
self.assertIsNone(status.completed_by)
# The note (and any document) survive the toggle -- it's evidence something
# was received once, even if it needs redoing.
self.assertEqual(status.note, "handed in at practice")
def test_an_inactive_requirement_does_not_block_onboarding(self):
self.medical.is_active = False
self.medical.save()
mark_complete(self.membership, self.photo, user=self.staff)
self.assertTrue(self.membership.onboarding_complete)
def test_checklist_for_pairs_every_active_requirement_with_its_status_or_none(self):
mark_complete(self.membership, self.photo, user=self.staff)
checklist = checklist_for(self.membership)
by_requirement = dict(checklist)
self.assertEqual(len(checklist), 2)
self.assertTrue(by_requirement[self.photo].is_complete)
self.assertIsNone(by_requirement[self.medical])
def test_a_second_clubs_requirement_never_applies_here(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
OnboardingRequirement.objects.create(club=other_club, name="Waiver")
self.assertEqual(self.membership.open_requirement_count, 2) # not 3
def test_annotate_onboarding_status_matches_the_per_row_property_across_a_list(self):
second = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Sam", last_name="Lee"), season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
mark_complete(self.membership, self.photo, user=self.staff)
annotated = annotate_onboarding_status(ClubMembership.objects.filter(club=self.club))
by_pk = {membership.pk: membership.onboarding_open for membership in annotated}
self.assertEqual(by_pk[self.membership.pk], 1)
self.assertEqual(by_pk[second.pk], 2)
def test_annotate_onboarding_status_costs_a_fixed_number_of_queries_regardless_of_list_size(self):
# One for the queryset itself, one for the club's required requirements, one for
# every membership's completed statuses -- flat regardless of how many rows.
for i in range(5):
ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name=f"M{i}", last_name="Roe"), season=self.season)
with self.assertNumQueries(3):
annotate_onboarding_status(ClubMembership.objects.filter(club=self.club))
# --- mark_bypassed ---
def test_mark_bypassed_resolves_the_item_without_marking_it_complete(self):
status = mark_bypassed(self.membership, self.photo, user=self.staff, note="already has a recent one on file")
self.assertFalse(status.is_complete)
self.assertTrue(status.is_bypassed)
self.assertEqual(status.note, "already has a recent one on file")
self.assertEqual(self.membership.open_requirement_count, 1)
def test_mark_complete_clears_a_prior_bypass(self):
mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed")
status = mark_complete(self.membership, self.photo, user=self.staff)
self.assertTrue(status.is_complete)
self.assertFalse(status.is_bypassed)
def test_mark_bypassed_clears_a_prior_completion(self):
mark_complete(self.membership, self.photo, user=self.staff)
status = mark_bypassed(self.membership, self.photo, user=self.staff, note="turns out not needed")
self.assertFalse(status.is_complete)
self.assertTrue(status.is_bypassed)
def test_mark_incomplete_also_clears_a_bypass(self):
mark_bypassed(self.membership, self.photo, user=self.staff, note="not needed")
status = mark_incomplete(self.membership, self.photo)
self.assertFalse(status.is_complete)
self.assertFalse(status.is_bypassed)
self.assertEqual(self.membership.open_requirement_count, 2)
# --- blocking_event_kinds ---
def test_blocking_event_kinds_is_empty_when_nothing_blocks_anything(self):
self.assertEqual(blocking_event_kinds(self.membership), set())
def test_blocking_event_kinds_collects_kinds_from_every_open_requirement(self):
self.medical.blocked_event_kinds = ["game", "tournament"]
self.medical.save()
self.photo.blocked_event_kinds = ["game"]
self.photo.save()
self.assertEqual(blocking_event_kinds(self.membership), {"game", "tournament"})
def test_blocking_event_kinds_ignores_a_resolved_requirement(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
mark_complete(self.membership, self.medical, user=self.staff)
self.assertEqual(blocking_event_kinds(self.membership), set())
def test_blocking_event_kinds_ignores_a_bypassed_requirement(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
mark_bypassed(self.membership, self.medical, user=self.staff, note="waived")
self.assertEqual(blocking_event_kinds(self.membership), set())
# --- blocked_member_ids_for_event ---
def test_blocked_member_ids_for_event_is_empty_when_nothing_is_configured_to_block(self):
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
def test_blocked_member_ids_for_event_flags_a_member_with_an_open_blocking_requirement(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), {self.member.pk})
def test_blocked_member_ids_for_event_is_kind_specific(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "training"), set())
def test_blocked_member_ids_for_event_excludes_a_member_who_resolved_it(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
mark_complete(self.membership, self.medical, user=self.staff)
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
def test_blocked_member_ids_for_event_excludes_a_bypassed_requirement_too(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
mark_bypassed(self.membership, self.medical, user=self.staff, note="waived")
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
# --- approve_all_clean ---
def test_approve_all_clean_activates_a_pending_paid_up_fully_checked_member(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
mark_complete(pending, self.photo, user=self.staff)
mark_bypassed(pending, self.medical, user=self.staff, note="waived")
activated = approve_all_clean(self.club, self.season)
pending.refresh_from_db()
self.assertEqual(activated, 1)
self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE)
def test_approve_all_clean_skips_a_pending_member_with_an_open_requirement(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
mark_complete(pending, self.photo, user=self.staff)
# self.medical left open.
activated = approve_all_clean(self.club, self.season)
pending.refresh_from_db()
self.assertEqual(activated, 0)
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
def test_approve_all_clean_skips_a_pending_member_who_has_not_paid(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.UNPAID)
mark_complete(pending, self.photo, user=self.staff)
mark_complete(pending, self.medical, user=self.staff)
activated = approve_all_clean(self.club, self.season)
pending.refresh_from_db()
self.assertEqual(activated, 0)
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
def test_approve_all_clean_never_touches_an_already_active_membership(self):
# self.membership is already ACTIVE/PAID with two open requirements --
# approve_all_clean only ever moves PENDING -> ACTIVE, it doesn't re-check
# or deactivate anyone already active.
activated = approve_all_clean(self.club, self.season)
self.membership.refresh_from_db()
self.assertEqual(activated, 0)
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
def test_approve_all_clean_ignores_a_guardian_kind_membership(self):
guardian_member = Member.objects.create(first_name="Pat", last_name="Guardian")
ClubMembership.objects.create(club=self.club, member=guardian_member, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
activated = approve_all_clean(self.club, self.season)
self.assertEqual(activated, 0)
def test_approve_all_clean_stamps_activated_at(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
mark_complete(pending, self.photo, user=self.staff)
mark_complete(pending, self.medical, user=self.staff)
approve_all_clean(self.club, self.season)
pending.refresh_from_db()
self.assertEqual(pending.activated_at, timezone.localdate())
# --- approve_one ---
def test_approve_one_activates_a_clean_pending_membership_and_stamps_activated_at(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
mark_complete(pending, self.photo, user=self.staff)
mark_complete(pending, self.medical, user=self.staff)
activated = approve_one(pending)
pending.refresh_from_db()
self.assertTrue(activated)
self.assertEqual(pending.status, ClubMembership.StatusChoices.ACTIVE)
self.assertEqual(pending.activated_at, timezone.localdate())
def test_approve_one_refuses_a_paid_but_unchecked_membership(self):
# Fully paid is not enough on its own -- the whole point of this change is
# that fee_status alone never activates; the checklist must be resolved too.
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)
mark_complete(pending, self.photo, user=self.staff)
# self.medical left open.
activated = approve_one(pending)
pending.refresh_from_db()
self.assertFalse(activated)
self.assertEqual(pending.status, ClubMembership.StatusChoices.PENDING)
self.assertIsNone(pending.activated_at)

View File

@@ -24,6 +24,9 @@ services:
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
# running fine in between.
- media_data:/app/media
# Private uploads (e.g. a member's medical certificate) -- see compose.yaml's own
# comment on this volume for why it's absent from every other service here.
- private_media_data:/app/private_media
depends_on:
db:
condition: service_healthy
@@ -36,6 +39,32 @@ services:
retries: 3
start_period: 20s
worker:
build: .
restart: unless-stopped
env_file: .env.production
# See compose.yaml for what runs here and why.
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
restart: unless-stopped
env_file: .env.production
# Exactly ONE of these across the whole deployment -- see compose.yaml.
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:17-alpine
restart: unless-stopped
@@ -56,8 +85,10 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
# Cache AND Celery broker for worker/beat above -- see compose.yaml's redis comment.
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
pgdata:
media_data:
private_media_data:

View File

@@ -40,6 +40,10 @@ services:
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
# running fine in between.
- media_data:/app/media
# Private uploads (e.g. a member's medical certificate -- see rosterchief/storage.py).
# Deliberately NOT mounted into `caddy` below, unlike media_data: nothing should be able
# to serve this except the authenticated Django view that reads it.
- private_media_data:/app/private_media
depends_on:
db:
condition: service_healthy
@@ -52,6 +56,38 @@ services:
retries: 3
start_period: 20s
worker:
build: .
restart: unless-stopped
env_file: .env.production
# The scheduled platform jobs (see billing/tasks.py, club/tasks.py, events/tasks.py) run
# here, dispatched by `beat` below over the same Redis `web` uses as a cache — see
# rosterchief/settings.py's "Task queue (Celery)" section. Several of these are safe to
# scale; `beat` is not (see its own comment).
command: ["celery", "-A", "rosterchief", "worker", "--loglevel=info", "--concurrency=2"]
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
restart: unless-stopped
env_file: .env.production
# The scheduler -- decides *when* each task in CELERY_BEAT_SCHEDULE fires and hands it to
# a worker. Run exactly ONE of these: two beats would each independently decide it's time
# and every job runs twice (two archive_overdue_clubs runs is two emails to the same club,
# same reasoning as the old crontab's "exactly one node" -- see DEPLOYMENT.md).
command: ["celery", "-A", "rosterchief", "beat", "--loglevel=info"]
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:17-alpine
restart: unless-stopped
@@ -75,10 +111,14 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
# is what keeps every gunicorn worker agreeing about which feature flags are on. maxmemory
# is a ceiling, not a saving — this is already the smallest process in the stack — but on a
# memory-limited box it should evict cache entries under pressure, not grow unbounded.
# Doubles as the Celery broker/result backend for `worker`/`beat` (see rosterchief/settings.py)
# as well as the cache. maxmemory-policy allkeys-lru is right for a cache — evict rather than
# grow unbounded — but it means a queued task message COULD be evicted under memory pressure
# before a worker consumes it, same as a Redis restart drops anything queued (--save "",
# --appendonly no: nothing here persists by design). Acceptable at this job volume (five
# scheduled tasks a day; a missed one runs at its next scheduled time regardless, per
# CELERY_BEAT_SCHEDULE); if that stops being true, give Celery its own Redis instance rather
# than changing this cache's eviction policy to suit it.
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
@@ -86,3 +126,4 @@ volumes:
caddy_data:
caddy_config:
media_data:
private_media_data:

View File

@@ -0,0 +1,16 @@
"""So the command bar's status indicator (base.html) can reflect real job health on every
control panel page, not just the dashboard, without every view remembering to pass it.
Guarded to controlpanel pages only -- unlike features.context_processors.maintenance (a
cached read, cheap anywhere), this runs a real query, and every other page on the platform
(club subdomains, the public site) has no command bar to show it on.
"""
from .services.jobs import recent_job_failures
def job_health(request):
if not (request.resolver_match and request.resolver_match.app_name == "controlpanel"):
return {}
return {"failed_jobs": recent_job_failures()}

View File

@@ -0,0 +1,51 @@
"""Read side of the scheduled-job history for the control panel's Jobs tab and the
Platform dashboard's job log / failed-jobs tile.
``features.jobs.JOB_REGISTRY`` is what a job *is* (label, description, schedule);
``features.models.JobRun`` is what actually happened, written by the Celery signal handlers
in features/signals.py. This module just joins the two for a template.
"""
from datetime import timedelta
from django.utils import timezone
from features.jobs import JOB_REGISTRY
from features.models import JobRun
#: Runs shown per job on the Jobs tab -- enough to see a pattern (a job that fails every
#: third day, say) without the page turning into a full audit log.
RECENT_RUNS = 10
#: What counts as "recent" for the dashboard's failed-jobs KPI tile.
FAILURE_WINDOW_HOURS = 24
#: Rows in the Platform dashboard's job log card.
JOB_LOG_ROWS = 8
def job_overview():
"""One entry per registered job, its most recent runs, and a shortcut to the latest."""
return [
{
"name": name,
"label": meta["label"],
"description": meta["description"],
"schedule": meta["schedule"],
"runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])),
"latest": runs[0] if runs else None,
}
for name, meta in JOB_REGISTRY.items()
]
def recent_job_failures(hours=FAILURE_WINDOW_HOURS):
"""Failures in the last `hours` -- the platform-health "failed jobs" signal. A number
that sits here is exactly what a dead beat schedule or a broken task looks like."""
since = timezone.now() - timedelta(hours=hours)
return JobRun.objects.filter(status=JobRun.Status.FAILURE, started_at__gte=since)
def recent_job_runs(limit=JOB_LOG_ROWS):
"""Every job's runs, most recent first, for the dashboard's Job log card."""
return JobRun.objects.all()[:limit]

View File

@@ -104,6 +104,42 @@ def clubs_with_health(queryset=None, today=None, now=None):
)
#: Risk tiers for the dashboard's "Club health" table, high risk first. Derived from signals
#: `clubs_with_health` already annotates -- no separate query, and nothing here is invented:
#: a club with no season covering today cannot take a signup, and dues past their grace date
#: are exactly what the archive job is about to act on.
RISK_HIGH, RISK_WATCH, RISK_OK = "high", "watch", "ok"
def club_risk(club, today):
"""The risk tier, plus a human reason naming exactly which signal tripped it -- so the
dashboard can show *why*, not just a colour. Checked in the same order as the tier
logic below: the first matching condition is the one reported."""
if not club.has_season:
return RISK_HIGH, _("No season covers today")
if club.dues_grace_until is not None and club.dues_grace_until < today:
return RISK_HIGH, _("Dues overdue past grace")
if not club.upcoming_events:
return RISK_WATCH, _("No events in the next 30 days")
if club.dues_owed:
return RISK_WATCH, _("Dues outstanding")
return RISK_OK, _("Nothing needs attention")
def clubs_by_risk(queryset=None, today=None):
"""`clubs_with_health`, ordered highest risk first -- the dashboard's Club health table
is "sorted by risk" per the design, and risk is exactly the thing that table is for."""
today = today or timezone.localdate()
order = {RISK_HIGH: 0, RISK_WATCH: 1, RISK_OK: 2}
clubs = list(clubs_with_health(queryset, today=today))
for club in clubs:
club.risk, club.risk_reason = club_risk(club, today)
clubs.sort(key=lambda club: order[club.risk])
return clubs
def platform_totals():
return {
"clubs": Club.objects.active().count(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,67 +1,77 @@
{% extends "controlpanel/base.html" %}
{% load static lucide %}
{% block logo %}
{% if club.logo %}
{% comment %}
The ring is the club's own primary colour, same as on the club's own subdomain --
but the control panel never injects a page-wide --color-primary override (it must
not dress itself up as the club), so it's set here as a locally-scoped custom
property instead: it only reaches this element and its children, not the rest of
the panel's buttons and badges.
{% endcomment %}
<div class="avatar">
<div class="w-16 rounded-full bg-base-100 ring-2 ring-primary ring-offset-2 ring-offset-base-100" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}>
<img class="club-logo object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
</div>
</div>
{% else %}
<div class="avatar avatar-placeholder">
<div class="w-16 text-xl rounded-full bg-neutral text-neutral-content">
<span>{{ club.initials }}</span>
</div>
</div>
{% endif %}
{% endblock logo %}
{% block panel_title %}{{ club.name }}{% endblock panel_title %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}{% if club.legal_name %} &middot; {{ club.legal_name }}{% endif %}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
{% endblock subheading %}
{% block breadcrumb %}
<a class="hover:text-ink" href="{% url 'controlpanel:club_list' %}">clubs</a>
<span>/</span>
<span class="text-ink">{{ club.slug }}</span>
<span>/</span>
<span>settings</span>
<div class="flex-1"></div>
<span>id {{ club.pk|stringformat:"s"|slice:":8" }}</span>
<span class="text-edge">|</span>
<span>created {{ club.created|date:"Y-m-d" }}</span>
{% endblock breadcrumb %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app" target="_blank" rel="noopener">{% lucide "external-link" size=14 %} Open</a>
{% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %}
<button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=16 %} Restore</button>
<button class="btn btn-success gap-2" type="submit">{% lucide "archive-restore" size=14 %} Restore</button>
</form>
{% else %}
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
{% csrf_token %}
<button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=16 %} Archive</button>
<button class="btn btn-warning gap-2" type="submit">{% lucide "archive" size=14 %} Archive</button>
</form>
{% endif %}
{% endblock actions %}
{% block panel %}
{# --- club header --------------------------------------------------- #}
<div class="flex items-start gap-4">
{% comment %}
The ring is the club's own primary colour, same as on the club's own subdomain --
but the control panel never injects a page-wide --color-primary override (it must
not dress itself up as the club), so it's set here as a locally-scoped custom
property instead: it only reaches this element, not the rest of the panel's
buttons and badges.
{% endcomment %}
{% if club.logo %}
<div class="crest ring-primary flex h-[68px] w-[68px] shrink-0 items-center justify-center overflow-hidden bg-ink" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}>
<img class="h-full w-full object-cover" src="{{ club.logo.url }}" alt="{{ club.name }}">
</div>
{% else %}
<div class="crest flex h-[68px] w-[68px] shrink-0 items-center justify-center bg-ink">
<span class="font-display text-xl font-extrabold text-white">{{ club.initials }}</span>
</div>
{% endif %}
<div class="flex-1">
<div class="font-display text-2xl leading-none font-extrabold text-ink uppercase">{{ club.name }}</div>
<div class="mt-1.5 font-mono text-xs text-muted">
{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}{% if club.legal_name %} &middot; {{ club.legal_name }}{% endif %}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
</div>
</div>
</div>
{% if club.is_archived %}
<div class="alert alert-warning mb-6">
{% lucide "alert-triangle" size=20 %}
<div class="alert alert-warning">
{% lucide "alert-triangle" size=18 %}
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div>
{% endif %}
{% if attention.no_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
</span>
<div class="alert alert-warning">
{% lucide "calendar-x" size=18 %}
<span>No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.</span>
</div>
{% endif %}
@@ -70,132 +80,110 @@
the club's setup, not a statistic: with nobody in a management position the access
service grants no authority over that team, so nobody can pick the squad.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.outstanding|floatformat:2 }}</div>
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{% comment %}<div class="card p-3.5 border-club">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Outstanding</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-club tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div>{% endcomment %}
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} Teams without coach</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
<div class="card p-3.5 {% if attention.teams_without_manager %}border-club{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Teams without coach</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.teams_without_manager %}text-club{% else %}text-ink{% endif %}">{{ attention.teams_without_manager }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">Teams nobody can pick a squad for</div>
</div>
<div class="card p-3.5 {% if attention.unrostered %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Unrostered members</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.unrostered %}text-warn{% else %}text-ink{% endif %}">{{ attention.unrostered }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">Active members on no team</div>
</div>
<div class="card p-3.5 {% if attention.pending_approvals %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Pending</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold tabular-nums {% if attention.pending_approvals %}text-warn{% else %}text-ink{% endif %}">{{ attention.pending_approvals }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">Memberships awaiting approval</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">New members</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #}
<div class="mt-1.5 font-mono text-[11px] text-muted">First season at this club</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Renewal rate</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">
{% if attention.renewal_rate is None %}N/A{% else %}{{ attention.renewal_rate }}%{% endif %}
</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">
{% if attention.renewal_rate is None %}
No previous season
{% else %}
<progress class="progress mt-1 {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}" max="100"></progress>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">Active members on no team</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Attendance rate</div>
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">
{% if attention.attendance.turnout is None %}N/A{% else %}{{ attention.attendance.turnout }}%{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">Memberships awaiting approval</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} New members</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
{# First season at this club — someone returning after a year away is a renewal. #}
<div class="text-xs opacity-60">First season at this club</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.renewal_rate is None %}border-info{% elif attention.renewal_rate < 30 %}border-error{% elif attention.renewal_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "repeat" size=16 %} Renewal rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.renewal_rate is None %}
N/A
{% else %}
{{ attention.renewal_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.renewal_rate is None %}
No previous season
{% else %}
<progress class="progress w-full {% if attention.renewal_rate < 30 %}progress-error{% elif attention.renewal_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.renewal_rate }}"
max="100"></progress>
{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if attention.attendance.turnout is None %}border-info{% elif attention.attendance.turnout < 30 %}border-error{% elif attention.attendance.turnout < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} Attendance rate</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
N/A
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
No events this season
{% else %}
<progress class="progress w-full {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}"
value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">
{% if attention.attendance.turnout is None %}
No events this season
{% else %}
<progress class="progress mt-1 {% if attention.attendance.turnout < 30 %}progress-error{% elif attention.attendance.turnout < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attention.attendance.turnout }}" max="100"></progress>
{% endif %}
</div>
</div>
</div>
{# --- two-column layout: left = charts/stats, right = flags/admins/billing --- #}
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="card p-4">
<div class="mb-3 flex items-center gap-2 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide "user-plus" size=16 %} Signups per month</div>
<p class="mb-2 font-mono text-[11px] text-muted">New members against returning ones.</p>
<div class="h-48">
<canvas id="signups-chart"></canvas>
</div>
</div>
<div class="card p-4">
<div class="mb-3 flex items-center gap-2 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide "wallet" size=16 %} Club fee status this season</div>
<div class="h-48">
<canvas id="fees-chart"></canvas>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
<p class="text-sm opacity-70">New members against returning ones.</p>
<div class="h-56">
<canvas id="signups-chart"></canvas>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{% for group in groups %}
<div class="card flex flex-col">
<div class="flex items-center gap-2 border-b border-line px-4 py-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% lucide group.icon size=16 %} {{ group.title }}</div>
<dl class="px-4">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm text-muted">{{ label }}</dt>
<dd class="font-mono font-semibold text-ink tabular-nums">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
{% endfor %}
</div>
{% include "controlpanel/_club_home_location_card.html" %}
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</div>
<div class="flex flex-col gap-4">
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
</div>
</div>
<div class="mb-6 grid gap-4 md:grid-cols-4">
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endfor %}
</div>
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
{% include "controlpanel/_club_home_location_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% endblock panel %}
{% block extra_body %}
@@ -205,64 +193,50 @@
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const ink = "#3A4658";
const grid = "rgba(58,70,88,.12)";
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
// Stacked: the bar height stays "signups this month" while the split shows where
// they came from. Side-by-side bars would answer a different question.
const signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
// Stacked: the bar height stays "signups this month" while the split shows where
// they came from. Side-by-side bars would answer a different question.
const signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: "#0B1220"},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: "#14B8E8"},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
scales: {
x: {stacked: true, ticks: {color: ink, font: {family: "IBM Plex Mono", size: 10}}, grid: {display: false}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0, font: {family: "IBM Plex Mono", size: 10}}, grid: {color: grid}},
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
});
// Colour carries the meaning here — unpaid must read as a problem, waived must
// not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), {
type: "pie",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
{
data: data.fees.map((slice) => slice.value),
backgroundColor: ["#14A05A", "#F0A22E", "#E4002B", "#8B95A4"],
},
},
});
// Colour carries the meaning here — unpaid must read as a problem, waived must
// not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), {
type: "pie",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
{
data: data.fees.map((slice) => slice.value),
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink}}},
},
});
return [signups, fees];
};
let charts = render();
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
},
});
})();
</script>
{% endblock extra_body %}

View File

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

View File

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

View File

@@ -1,111 +1,213 @@
{% extends "controlpanel/base.html" %}
{% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }} &middot; {% now "d b Y" %}{% endblock subheading %}
{% block panel_title %}Platform health{% endblock panel_title %}
{% block breadcrumb %}
<span class="text-ink">platform</span>
<span class="text-edge">|</span>
<span>{{ totals.clubs }} club{{ totals.clubs|pluralize }} live</span>
<span class="text-edge">|</span>
<span>{{ totals.members }} member{{ totals.members|pluralize }}</span>
{% endblock breadcrumb %}
{% block strip_right %}
{% if failed_jobs %}
<span class="text-club-dark">{{ failed_jobs|length }} job failure{{ failed_jobs|length|pluralize }} · 24h</span>
{% endif %}
{% endblock strip_right %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} Create new club</a>
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=14 %} Create club</a>
{% endblock actions %}
{% block panel %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
</div>
{# --- KPI row ------------------------------------------------------- #}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">clubs live</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.clubs }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.archived_clubs }} archived</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-info">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.archived_clubs }}</div>
<div class="text-xs opacity-60">Not accessible but data maintained</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">members</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.members }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.admins }} club admin{{ totals.admins|pluralize }}</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.clubs_without_season }}</div>
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
</div>
<div class="card p-3.5">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dues owed</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">€{{ attention.dues_owed|floatformat:0 }}</div>
<div class="mt-1.5 font-mono text-[11px] {% if attention.dues_overdue %}text-club-dark{% else %}text-muted{% endif %}">{{ attention.dues_in_grace }} in grace · {{ attention.dues_overdue }} overdue</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">No events scheduled next 30 days</div>
</div>
<div class="card p-3.5 {% if attention.clubs_without_season %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">no season</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if attention.clubs_without_season %}text-warn{% else %}text-ink{% endif %}">{{ attention.clubs_without_season }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">can't take signups</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins without MFA configured</div>
</div>
<div class="card p-3.5 {% if attention.dormant_clubs %}border-warn{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dormant clubs</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if attention.dormant_clubs %}text-warn{% else %}text-ink{% endif %}">{{ attention.dormant_clubs }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">no events 30d</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</div>
<div class="text-4xl font-bold tabular-nums font-mono">&euro;{{ attention.dues_owed|floatformat:2 }}</div>
<div class="text-xs opacity-60">
{{ attention.dues_in_grace }} in grace ·
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
{% comment %}
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
then they fall past the horizon. A number that lingers here means the job has
stopped and a club is about to use the platform for free — which no other
figure on this page reveals, because nothing has been billed yet.
{% endcomment %}
{% if attention.renewals_pending %}
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
{% endif %}
</div>
</div>
<div class="card p-3.5 {% if failed_jobs %}border-club{% endif %}">
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">failed jobs</div>
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold tabular-nums {% if failed_jobs %}text-club{% else %}text-ink{% endif %}">{{ failed_jobs|length }}</div>
<div class="mt-1.5 font-mono text-[11px] text-muted">last 24h · <a class="link link-hover" href="{% url 'controlpanel:jobs' %}">jobs</a></div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
<p class="text-sm opacity-70">New members against returning ones, across every club.</p>
<div class="h-56">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[1.6fr_1fr]">
{# --- left column ------------------------------------------------ #}
<div class="flex flex-col gap-4">
<div class="card p-4">
<div class="mb-3 flex items-center">
{# No hand-written colour key here: Chart.js renders its own real, correctly-swatched legend below the chart -- a second, uncoloured text hint duplicating it just reads as broken. #}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Signups · {{ charts.signups|length }} months</span>
</div>
<div class="h-44">
<canvas id="signups-chart"></canvas>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
<p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
<div class="mt-2 space-y-3">
<div class="card flex flex-col">
<div class="flex items-center border-b border-line px-4 py-3">
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Club health</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">sorted by risk</span>
</div>
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Active members</th>
<th>Events 30d</th>
<th>Plan</th>
<th>Risk</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td><a class="link link-hover font-semibold text-ink" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a></td>
<td class="font-mono tabular-nums">{{ club.active_members }}</td>
<td class="font-mono tabular-nums {% if not club.upcoming_events %}text-club-dark{% endif %}">{{ club.upcoming_events }}</td>
<td class="font-mono">{{ club.plan_name|default:"—" }}</td>
<td>
{% if club.risk == "high" %}
<span class="badge badge-error" title="{{ club.risk_reason }}">high</span>
{% elif club.risk == "watch" %}
<span class="badge badge-warning" title="{{ club.risk_reason }}">watch</span>
{% else %}
<span class="badge badge-success" title="{{ club.risk_reason }}">ok</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">No clubs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Onboarding</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{% for step in funnel %}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums font-mono">{{ step.count }}</span>
</div>
<progress class="progress {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %} w-full" value="{{ step.count }}"
max="{{ funnel.0.count }}"></progress>
<div class="mb-1 flex items-center gap-1.5 font-mono text-[11px] text-muted">{% lucide step.icon size=12 %} {{ step.label }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink tabular-nums">{{ step.count }}</div>
<progress class="progress mt-1.5 {% if step.count == funnel.0.count %}progress-success{% elif step.count == 0 %}progress-error{% else %}progress-warning{% endif %}" value="{{ step.count }}" max="{{ funnel.0.count|default:1 }}"></progress>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
{% include "controlpanel/_club_health_table.html" %}
{# --- right column ------------------------------------------------ #}
<div class="flex flex-col gap-4">
<div class="rounded bg-ink p-4 text-white">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] uppercase">Alerts</div>
<div class="flex flex-col gap-2.5 font-mono text-xs">
{% if attention.renewals_pending %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ attention.renewals_pending }} subscription{{ attention.renewals_pending|pluralize }} awaiting renewal</div>
<div class="text-on-dark-dim">renew_subscriptions may be stalled</div>
</div>
{% endif %}
{% if attention.dues_overdue %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ attention.dues_overdue }} club{{ attention.dues_overdue|pluralize }} overdue on platform fees</div>
<div class="text-on-dark-dim">past grace — see billing</div>
</div>
{% endif %}
{% for run in job_log %}
{% if run.status == "failure" %}
<div class="border-l-2 border-club pl-2.5">
<div>{{ run.name }} failed</div>
<div class="text-on-dark-dim">{{ run.started_at|date:"H:i" }} · {{ run.error|truncatechars:60|default:"see the Jobs tab" }}</div>
</div>
{% endif %}
{% endfor %}
{% if attention.admins_pending_mfa %}
<div class="border-l-2 border-warn pl-2.5">
<div>{{ attention.admins_pending_mfa }} admin{{ attention.admins_pending_mfa|pluralize }} without MFA</div>
<div class="text-on-dark-dim">locked out until they enrol</div>
</div>
{% endif %}
{% if attention.clubs_unbilled %}
<div class="border-l-2 border-warn pl-2.5">
<div>{{ attention.clubs_unbilled }} active club{{ attention.clubs_unbilled|pluralize }} on no plan</div>
<div class="text-on-dark-dim">using the platform for free</div>
</div>
{% endif %}
{% if not attention.renewals_pending and not attention.dues_overdue and not attention.admins_pending_mfa and not attention.clubs_unbilled and not failed_jobs %}
<div class="text-on-dark-dim">Nothing needs attention.</div>
{% endif %}
</div>
</div>
<div class="card p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Feature adoption</div>
<div class="flex flex-col gap-2.5 font-mono text-[11px] text-slate">
{% for flag in flags %}
<div>
<div class="mb-1 flex justify-between">
<span>{{ flag.name }}</span>
<span>{% if flag.overridden %}{{ flag.everyone|yesno:"everyone,off" }}{% else %}{{ flag.clubs }}/{{ totals.clubs }}{% endif %}</span>
</div>
<div class="h-1.5 bg-rule">
<div class="h-full bg-ink" style="width:{% if flag.overridden %}100{% else %}{% widthratio flag.clubs totals.clubs 100 %}{% endif %}%"></div>
</div>
</div>
{% empty %}
<div class="text-muted">No feature flags yet.</div>
{% endfor %}
</div>
</div>
<div class="card flex-1 p-4">
<div class="mb-3 font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Job log</div>
<div class="flex flex-col gap-1.5 font-mono text-[11px]">
{% for run in job_log %}
<div class="flex gap-2">
<span class="text-dim">{{ run.started_at|date:"H:i" }}</span>
{% if run.status == "success" %}
<span class="text-ok-text">ok</span>
{% elif run.status == "failure" %}
<span class="text-club-dark">err</span>
{% else %}
<span class="text-info-text">···</span>
{% endif %}
<span class="text-slate">{{ run.name }}{% if run.detail %} · {{ run.detail|truncatechars:40 }}{% endif %}</span>
</div>
{% empty %}
<div class="text-muted">No job runs recorded yet.</div>
{% endfor %}
</div>
<a class="mt-3 self-start font-mono text-[11px] text-club-dark hover:underline" href="{% url 'controlpanel:jobs' %}">View all jobs →</a>
</div>
</div>
</div>
{% endblock panel %}
@@ -116,84 +218,28 @@
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const ink = "#3A4658";
const grid = "rgba(58,70,88,.12)";
// Chart.js paints to a canvas, so it cannot inherit daisyUI's colours the way the
// rest of the page does — they are CSS variables. Read the computed values, and
// rebuild when the theme attribute changes, or the charts keep the light palette
// after a switch to dark.
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
// Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of
// them: the axis is rounded to keep the labels short, but the tooltip keeps the
// cents — rounding a euro amount someone is reading off a chart is a lie.
const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0});
const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2});
const build = (id, label, series, colour, type, money) =>
new Chart(document.getElementById(id), {
type,
data: {
labels: series.map((point) => point.month),
datasets: [{label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {display: false},
// The tooltip carries the unit too: an axis in euros and a bare
// number on hover reads as two different quantities.
tooltip: money ? {callbacks: {label: (item) => exactEuros.format(item.parsed.y)}} : {},
},
scales: {
x: {ticks: {color: ink}, grid: {color: grid}},
y: {
beginAtZero: true,
grid: {color: grid},
ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined},
},
},
},
});
// Stacked, so the bar height stays "signups this month" while the split shows
// where they came from. New is per club: joining a second club is a new
// membership there, even for someone who has been on the platform for years.
const signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: "#0B1220"},
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: "#14B8E8"},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
scales: {
x: {stacked: true, ticks: {color: ink, font: {family: "IBM Plex Mono", size: 10}}, grid: {display: false}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0, font: {family: "IBM Plex Mono", size: 10}}, grid: {color: grid}},
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "bottom", labels: {color: ink}}},
scales: {
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
},
},
});
return [signups]; // build("revenue-chart", "Dues", data.dues, css("--color-accent", "#0ea5e9"), "bar", true)];
};
let charts = render();
// The theme toggle sets data-theme on <html>; "auto" removes it entirely, so watch
// the attribute rather than listening for a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
},
});
})();
</script>
{% endblock extra_body %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,10 +126,13 @@ class ClubManagementTests(ControlPanelTestBase):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self):
# The dashboard's own Club health table is deliberately leaner (club / active
# members / events / plan / risk — see dashboard.html) to stay scannable; the full
# subdomain-and-sport subtitle lives on the clubs list instead.
self.club.sport_type = Club.SportType.ICE_HOCKEY
self.club.save()
response = self.client.get(reverse("controlpanel:dashboard"))
response = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(response, f"{self.club.slug}.rosterchief.app &middot; Ice hockey", html=False)
@@ -665,8 +668,8 @@ class MessageRenderingTests(ControlPanelTestBase):
# so the generic per-level one ("Careful") must not show.
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
self.assertContains(response, "alert alert-soft border-2 alert-warning border-warning")
self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
self.assertContains(response, "alert alert-warning border-warning")
self.assertContains(response, "Club archived")
self.assertContains(response, "<svg") # the lucide icon
@@ -968,9 +971,9 @@ class DashboardMetricsTests(ControlPanelTestBase):
def test_the_dashboard_renders_its_metrics_and_charts(self):
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending")
self.assertContains(response, "Payment pending")
# The six KPI tiles -- see controlpanel/templates/controlpanel/dashboard.html.
for label in ("clubs live", "members", "dues owed", "no season", "dormant clubs", "failed jobs"):
self.assertContains(response, label)
self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"])
@@ -1249,8 +1252,10 @@ class ClubHealthTableTests(TestCase):
response = self.client.get(reverse("controlpanel:dashboard"))
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
# Health, not vanity: a leaner set than the full clubs list -- one row per club is
# meant to be scannable, so "risk" is the column that matters, not everything club_list
# already shows in full via _club_health_table.html.
for column in ("Club", "Active members", "Events 30d", "Plan", "Risk"):
self.assertContains(response, f">{column}</th>")

View File

@@ -40,4 +40,6 @@ urlpatterns = [
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
path("admins/<uuid:pk>/update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"),
path("admins/<uuid:pk>/revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"),
# Jobs (Celery Beat's scheduled platform jobs -- monitoring only, see controlpanel/services/jobs.py)
path("jobs/", views.JobsView.as_view(), name="jobs"),
]

View File

@@ -25,6 +25,7 @@ from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocati
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
from .services.jobs import job_overview, recent_job_runs
from .services.platform_admins import (
PlatformAdminError,
grant_platform_access,
@@ -32,7 +33,7 @@ from .services.platform_admins import (
revoke_platform_access,
set_platform_access,
)
from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals
from .services.statistics import club_attention, club_charts, club_statistics, clubs_by_risk, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
@@ -63,12 +64,28 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
funnel=onboarding_funnel(),
flags=flag_adoption(),
charts=platform_charts(),
clubs=clubs_with_health(),
clubs=clubs_by_risk(),
# failed_jobs itself comes from controlpanel.context_processors.job_health, on
# every controlpanel page (the command bar's status indicator needs it too) --
# not re-fetched here, so the query only runs once per request.
job_log=recent_job_runs(),
today=timezone.localdate(),
**kwargs,
)
class JobsView(PlatformStaffRequiredMixin, TemplateView):
"""Status and recent history of the scheduled platform jobs -- see features/jobs.py for
the registry and features/models.JobRun for what a Celery task run writes. Monitoring
only, deliberately: these run on Celery Beat's own schedule (rosterchief/settings.py),
not on demand from here."""
template_name = "controlpanel/jobs.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="jobs", jobs=job_overview(), **kwargs)
class ClubListView(PlatformStaffRequiredMixin, ListView):
template_name = "controlpanel/club_list.html"
context_object_name = "clubs"

View File

@@ -0,0 +1,278 @@
# Handoff: RosterChief Platform — four surfaces, one system
## Overview
RosterChief is a sports club management platform (built for ice hockey clubs; Sharks Mechelen is the reference tenant). This handoff covers a complete visual and structural redesign of all four surfaces:
| # | Surface | Audience | Device |
|---|---------|----------|--------|
| 01 | **Control panel** | RosterChief staff (34 people) | Desktop only |
| 02 | **Club management** | Board, secretary, treasurer | Desktop only |
| 03 | **Coach mode** | Anyone with a staff role on a team | Mobile first |
| 04 | **Member mode** | Every member and every parent | Mobile first |
**The single most important structural decision:** surfaces 03 and 04 are *two modes of one installed app*, not two apps. A persistent Coach / Member switcher sits in the app header; it only appears for people who hold a staff role, and the chosen mode is remembered per device. Each mode has its own tab bar and its own navigation stack.
**The second:** there is no "parent app". A person is one account with a set of memberships and roles. Katrien Somers is simultaneously a Div 4 player, the mother of two U16 players, and head coach of U16 — three facts about one row, not three logins. Every per-member screen (attendance, profile, dues) carries a **person switcher** at the top listing the people that account manages, including "me".
## About the design files
The files in this bundle are **design references created in HTML** — prototypes showing intended look and behaviour, not production code to copy. `RosterChief Platform.dc.html` is a single-file design document containing all 25 screens laid out side by side on a canvas. It uses inline styles and a small custom runtime; **do not port that runtime.**
The task is to **recreate these designs in the RosterChief codebase** (`bsiebens/RosterChief` — Django, server-rendered templates) using its established patterns. Per the user's preference, **use Tailwind CSS** for styling: the token table below is written as a `tailwind.config` extension, and every measurement in this document maps onto a Tailwind utility.
The Django app boundaries the screens map onto are listed in `github.md` at the project root (`## Screen map`).
## Fidelity
**High-fidelity.** Final colours, typography, spacing and interaction affordances. Recreate pixel-accurately. Two deliberate exceptions:
1. **Photography is placeholder.** Every `<image-slot>` in the reference marks a spot where real club photography belongs (hero action shots, team photos, news covers, article portraits). Sizes and gradient scrims are final; the images are not.
2. **The iOS bezel is presentation only.** `ios-frame.jsx` draws a device frame so the mobile screens read as phone screens in the design document. The app content starts *below* a 54px status-bar inset and ends above a 2630px home-indicator inset — preserve those safe areas via `env(safe-area-inset-*)`, not fixed padding.
---
## Design tokens
### Tailwind config
```js
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
ink: '#0B1220', // darkest — app chrome, sidebars, dark cards
navy: '#101E36', // member-mode app header, management sidebar accents
steel: '#1B2B47', // inset controls on dark (switcher track, chips)
hairline: '#1E2B42', // rules on dark surfaces
paper: '#F4F5F7', // app/page background
line: '#E3E6EB', // 1px borders on light
rule: '#EEF0F3', // table row dividers
edge: '#D6DAE1', // stronger light border (control panel, inputs)
stroke: '#C9CFD8', // secondary-button border
muted: '#6C7787', // secondary text
dim: '#8B95A4', // tertiary text, inactive tab icons
slate: '#3A4658', // body copy on light
onDark: '#93A0B4', // secondary text on dark
onDarkDim: '#7C8AA0',
onDarkFaint: '#5C6B85',
club: '#E4002B', // CLUB ACCENT — themeable, see "Club theming"
clubDark: '#B00021', // club accent, text-on-light / hover
ice: '#14B8E8', // coach-mode accent
iceInk: '#04212C', // text on ice
ok: '#14A05A',
okBg: '#E6F6EE', okBorder: '#BFE7D3', okText: '#0C7A43',
warn: '#F0A22E',
warnBg: '#FFF5E4', warnBorder: '#F6E0B8', warnText: '#9A6410', warnDeep: '#7A4E08',
dangerBg: '#FDECEC', dangerBorder: '#F5C9CE',
infoBg: '#EAF7FC', infoBorder: '#C3E7F4', infoText: '#0A6F91',
rowSel: '#FFF7F8', // selected table row (club tint)
rowFocus: '#F4F9FF', // focused/active row (info tint)
rowWarn: '#FFFDF6', // row needing attention
subhead: '#F8F9FA', // table header / section header fill
violet: '#7C5CFC', // calendar resource: training rink
},
fontFamily: {
display: ['"Barlow Condensed"', 'sans-serif'],
sans: ['Barlow', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'monospace'],
},
},
},
}
```
Google Fonts: `Barlow:400,500,600,700` · `Barlow+Condensed:600,700,800` · `IBM+Plex+Mono:400,500,600`.
### Type roles
| Role | Spec | Tailwind |
|---|---|---|
| Screen title (mobile) | Barlow Condensed 800, 2226px, uppercase | `font-display font-extrabold text-2xl uppercase` |
| Screen title (desktop) | Barlow Condensed 800, 24px, uppercase | `font-display font-extrabold text-2xl uppercase` |
| Section headline (doc) | Barlow Condensed 800, 56px, `leading-[.95]`, uppercase | `font-display font-extrabold text-[56px] leading-[.95] uppercase` |
| Hero headline (mobile) | Barlow Condensed 800, 2840px, `leading-[.96]`, uppercase | |
| Card title | Barlow Condensed 800, 2024px, uppercase | |
| Eyebrow / label | Barlow Condensed 700800, 1112px, `tracking-[.14em]`, uppercase, `text-muted` | `font-display font-extrabold text-xs tracking-[.14em] uppercase text-muted` |
| Nav / button label | Barlow Condensed 800, 1317px, `tracking-[.1em]`, uppercase | |
| Body | Barlow 400, 16px, `leading-[1.6]`, `text-slate` | |
| Lede | Barlow 600, 18px, `leading-[1.5]`, `text-ink` | |
| Row title | Barlow 600, 15px, `text-ink` | |
| Row meta | Barlow 400, 1213px, `text-muted` | |
| **Scoreboard numeral** | Barlow Condensed 800, 2676px, `leading-none`, `tabular-nums` | `font-display font-extrabold tabular-nums leading-none` |
| **Jersey number** | Barlow Condensed 800, 1826px, `tabular-nums` | |
| Data / mono | IBM Plex Mono 400, 1013px — IDs, money, timestamps, licence numbers, technical values | `font-mono` |
Rules: display type is **always uppercase**; body copy never is. Money always mono, always European format (`€ 780,00`). Dates in UI copy are `Sat 22 Aug`; dates in mono fields are ISO-ish (`2026-08-01`, `11.03.14-234.56`).
### Spacing, radius, borders
- Spacing: 4px base. Common: `gap-1.5 gap-2 gap-2.5 gap-3 gap-3.5 gap-4 gap-5`; mobile screen padding `px-4`; desktop content padding `px-7 py-6`; card padding `p-4` (mobile) / `p-[18px]` (desktop).
- Radius: mobile cards `rounded-[14px]`; nested/inner cards `rounded-xl`; desktop cards `rounded-xl`; buttons and inputs `rounded-lg`; chips/pills `rounded-full`; **control panel `rounded` (4px) — square by intent**; desktop frame `rounded-xl`.
- Borders: `border border-line` on light cards; `border-edge` in the control panel; `border-[1.5px] border-stroke` on secondary buttons; dividers `border-rule`.
- Shadows: only on the doc-level frames (`shadow-[0_24px_60px_rgba(11,18,32,.18)]`) and the member-detail drawer (`shadow-[-24px_0_60px_rgba(11,18,32,.2)]`). **Cards inside the product carry no shadow** — separation comes from hairlines.
- Minimum hit target: **44px** everywhere on mobile. Attendance in/out buttons are 4446px tall; primary mobile CTAs 4652px.
### Components
**Buttons** — height 46px mobile / 3436px desktop, `rounded-lg`, label in Barlow Condensed 800 uppercase `tracking-[.1em]`:
- Primary: `bg-club text-white`
- Dark: `bg-ink text-white`
- Coach primary: `bg-ice text-iceInk`
- Positive (attendance In): `bg-ok text-white`
- Secondary: `bg-white border-[1.5px] border-stroke text-ink`
- Ghost on dark: `bg-white/15 text-white`
**Status pills**`rounded-full px-2.5 py-[5px]`, Barlow Condensed 700, 1113px, `tracking-[.1em]`, uppercase:
| State | Classes |
|---|---|
| In / Paid / Ready / Clean / Live | `bg-okBg text-okText border border-okBorder` |
| Out / Overdue / Transfer | `bg-dangerBg text-clubDark border border-dangerBorder` |
| No reply / Due / Watch / Medical | `bg-warnBg text-warnText border border-warnBorder` |
| Selected / Beta / Scheduled | `bg-infoBg text-infoText border border-infoBorder` |
| Draft / Optional | `bg-rule text-[#4A5566] border border-[#DDE1E7]` |
**Role switcher** — full-width segmented pill inside the app header. Track `bg-steel rounded-full p-1 gap-1` (member mode) or `bg-ink/`+`bg-steel` (coach mode); each segment `flex-1 h-9 rounded-full`; active segment is `bg-white text-ink` in Member mode and `bg-ice text-iceInk` in Coach mode; inactive `text-onDark`.
**Toggle**`w-12 h-7 rounded-full` (mobile) / `w-10 h-[22px]` (desktop); on `bg-ok`, off `bg-edge`, beta `bg-ice`; knob is a white circle inset 3px.
**Bottom tab bar**`bg-white border-t border-line pt-2 pb-[26px]` (member) or `bg-ink pt-2 pb-[26px]` (coach); four equal items, 48px tall, 21px stroke-2 icon over a Barlow Condensed 700 12px `tracking-[.08em]` uppercase label. Active colour = `club` (member) / `ice` (coach); inactive `dim` / `#6E7C93`.
- Member tabs: Home · Calendar · News · Me
- Coach tabs: Today · Squad · Schedule · Create
**Table row (desktop)** — CSS grid, 40px header row (`bg-subhead border-b border-line`, mono or Barlow Condensed 12px `tracking-[.12em]` uppercase `text-muted` headings), 4652px body rows divided by `border-b border-rule`. Selected rows tint `rowSel`, focused row `rowFocus`, attention row `rowWarn`. Bulk-action bar appears as a **48px `bg-ink` strip directly above the table** with the count in Barlow Condensed uppercase and actions in `text-ice`.
**Club crest mark** — the shield is a clip-path, not an image: `clip-path: polygon(50% 0, 100% 18%, 100% 62%, 50% 100%, 0 62%, 0 18%)` on a solid block. Sizes: 20×22 (preview), 28×30 (sidebar), 30×32 (app header), 64×68 (control panel club header). Replace with the club's real SVG logo where one is uploaded; the clip-path is the fallback.
---
## Club theming
Club customisation is **club-level only**: primary colour, secondary colour, logo, wordmark. Model fields already exist (`club/models.py`: `Club.primary_color`, `Club.secondary_color`, `Club.logo`).
- `secondary_color` drives the **club accent** (`--club`): app header active tab, primary buttons, section eyebrows, selected-row tint, public-site nav and join CTA, invoice/email headers.
- `primary_color` drives dark chrome where the club overrides the platform navy.
- **Never themeable:** status colours, type, spacing, neutrals, table chrome, form fields. This is what keeps every club legible and every screen familiar. Implement as CSS custom properties set on `:root` per tenant, consumed by Tailwind arbitrary values (`bg-[var(--club)]`) or a `club` colour mapped to `var(--club)`.
- Coach mode uses `ice` (#14B8E8) regardless of club — the mode signal must survive theming.
- The control panel is **never** club-branded.
- Escape hatch: a per-club custom stylesheet for the public site only (`custom_stylesheet` feature flag).
---
## Screens
### Member mode (mobile, 402×874 reference)
**M1 · Home.** Navy header: crest + club name + season, bell with unread dot, role switcher below. Body scrolls: person switcher chips (avatar + first name; active chip `border-[1.5px] border-ink`, plus a "Me" chip) → dark hero card with 120px photo, gradient scrim, `NEXT UP · SAT 22 AUG` eyebrow in `ice`, match title, meta row (face-off / meet / venue), then "Lars — are you in?" with In (`bg-ok`) / Out (`bg-steel`) 46px buttons → "Needs your answer" card with a count in club red and dated rows carrying `REPLY` pills → dues card (€ badge, amount, due date, Pay button) → news teaser card with 104px cover, club-red category eyebrow and condensed uppercase headline.
**M2 · Event · answer for several.** 250px full-bleed photo header with a two-stop scrim, circular back button at the safe-area top, `HOME GAME` club-red badge and a 38px condensed uppercase title. Body: detail card (Face-off / Meet / Where + address / Kit as label-value rows with 78px labels) → "Your answers" card with **one three-state segmented control per person** (In / Maybe / Out, 44px), each person shown with avatar, name, team · number · position, and a `NO REPLY` pill where unanswered; "Add a note for the coach" affordance below → squad-response card with a stacked in/out/silent bar and counts.
**M3 · Calendar.** Navy header with title, member-scope pill, and List / Month / Games-only filter chips. Body is a 1px-gapped list on a `line` background, grouped under sticky `THIS WEEK` / `NEXT WEEK` labels. Each row: day-of-week + big condensed date, a 3px colour bar for event type (ice = `ice`, other = `warn`) or a 4px left border in club red for games, title, meta (time · team · which of my people), and a status pill (In / Out / Reply / 1 open / Optional).
**M4 · News article.** 330px portrait photo, three-stop scrim, back button, `ice` eyebrow (team · date), 40px condensed uppercase headline stacked over three lines. Body on white: 18px semibold lede, 16px body paragraphs, tag pills, then a byline row with avatar and a Share secondary button.
**M5 · Me & my people.** Navy header with 56px avatar, name, "Member since · role". Body: "People I manage" card — one row per managed person plus the account holder marked `(me)`, each with team · number · licence state (problems in `clubDark`) and a chevron → settings list (Personal details, Household & contacts, Payments & dues with a `1 OPEN` pill, Notifications) → dark "Coach mode" promo card with an `ice` icon tile → version line.
**M6 · Edit personal info.** White sticky header: back chevron, subject's name, Save button in club red. Body: warning banner for the missing medical form → grouped label-value cards (Identity, Contact, Emergency, Consent). Values are 16px medium; mono for dates, register numbers and phone numbers. Consent rows carry 48×28 toggles.
**M7 · Notifications.** Navy header: "Inbox", "Mark all read" in `ice`, filter chips (All / Action `3` / Club). Body grouped by Today / Earlier this week, rows on a 1px-gapped list. Actionable rows carry a **4px left border** (`club` for action-now, `warn` for warnings) and their action inline: In/Out buttons, Upload, Pay. Informational rows are flat; read rows drop to `opacity-[.72]`. Footer line points at Me → Notifications for push preferences.
### Coach mode (mobile, dark chrome)
**C1 · Today.** `ink` header: `ice` crest, team name, "Head coach · name", a team-picker pill, then the role switcher with Coach active (`bg-ice`). The body is a light sheet that **overlaps the header with a 20px top radius** — this is the mode's signature. Content: three stat tiles (Squad / In Sat / Silent, silent in club red) → tonight's session card with an `ink` header strip (`TONIGHT · 19:15` in `ice`) and a 50px `bg-ice` "Check attendance" CTA + overflow button → "Needs you" list, each item a card with a 4px left border by severity (line-up = `club`, silent players = `warn`, member blocker = `ice`) and a right-aligned action → "Also yours": a single `navy` card surfacing the coach's *member-side* obligation, so the two hats never fight.
**C2 · Bench attendance.** `ink` header with back chevron, "Attendance", session meta, and a progress bar + `14/19` counter. Light sheet: filter chips (All 19 / Silent 5 / Goalies) then a 1px-gapped roster list. Each row: jersey number (condensed 22px tabular), name, position, and a **joined 92×44 two-button control** — check (left, `bg-ok` when in) and cross (right, `bg-club` when out); unset is `bg-rule` with `dim` glyphs. Silent players' rows tint `rowWarn`. Fixed white footer with a 52px `bg-ink` "Save attendance" button.
**C3 · Game selection / line-up.** Fully dark screen. Header: back, "Line-up", opponent + date, `bg-ice` Publish button, then a mono-ish meta row (dressed / goalies / scratched). Body: one `navy` card per unit (Line 1, Line 2, Defence pairs) containing a grid of player tiles — `bg-steel rounded-[10px]` with a 26px condensed jersey number over a surname; empty slots are `border-[1.5px] border-dashed border-[#2C3B56]` reading `EMPTY`. Below: "Available · drag into a slot" pills; unavailable players (out / silent) are shown at `opacity-50` with the reason appended.
**C4 · Create event.** White sticky header: Cancel / "New event" / Create (`bg-ice`). Body: three event-type tiles (Practice active in `ink`, Game, Other) → label-value card (Title, Date + Time side by side, Location) → "Who" pills (team with count active, other teams, Goalies only, Pick players) → options card (Ask for attendance toggle, Answers close row, Repeat weekly toggle with the resulting event count) → an `infoBg` note stating how many members get notified and how many have a clash.
**C5 · Post news.** White sticky header with a club-red Publish. Keyboard is up (this screen is shown mid-composition). 120px cover slot, then the composer: condensed uppercase 28px headline, a 40×2 club-red rule, 16px body with a **club-red caret** at the insertion point, tag pills, and an Audience row ("U16 families · also on club website").
**C6 · Add members to team.** `ink` header with back, "Add to U16", squad count, and a `bg-steel` search field. Light sheet: filter chips (Suggested / Age eligible / No team), then sections — "Moving up from U14", "New this season" — of rows with avatar, name, `year · position · licence state`, and a 28px square checkbox (`bg-ok` with a check when selected, `border-[1.5px] border-stroke` when not). Members with a licence problem show it in `clubDark`. Fixed white footer: "2 selected / Squad becomes 21" beside a 52px `bg-ice` Add button.
### Club management (desktop, 1440×900 reference)
Shared shell: **236px `bg-ink` sidebar** (crest + club name + `RosterChief · management` in mono, then 40px nav items in Barlow Condensed 700 16px `tracking-[.06em]` uppercase; active item `bg-club text-white rounded-lg`; expanded sub-items are 2830px 14px rows indented 22px, active in white semibold, counts as pills) + **64px white topbar** (screen title, mono context, spacer, then secondary actions and one club-red primary) + content on `paper`. Several screens add a 4854px filter/tab strip under the topbar. **Every management screen fits 900px without internal scrolling** — keep that constraint.
**D1 · Club home.** Five KPI cards (Members, Awaiting approval, Dues collected, Licences missing, Turnout) with 44px condensed numerals and a delta line. Then a 1.35fr/1fr split: left — "Needs attention" list where each row has a 6px severity bar, a title, a detail line and a right-aligned action (Review / Export / Chase / Assign), and below it a "Membership by team" bar chart (ten bars, the focused team in club red, the rest `navy`); right — a dark "This weekend" card (three fixture rows with big condensed dates) over a "Recent activity" card (mono timestamps + actor-first sentences).
**D2 · Members list.** Topbar with count and Import CSV + New member. Filter strip: search field, applied filters as removable `bg-ink` chips, `+ Filter`, saved-view dropdown. **Bulk bar** (`bg-ink`, 48px): "3 selected", divider, then Move to team / Assign role / Send message / Create invoice in `ice`, Clear on the right. Table columns: checkbox · No. (condensed 18px) · Member (30px avatar + name + `year · sex`) · Team · position · Licence (mono, `okText` or `clubDark` "missing") · Dues pill · Attendance (mono %) · Household. Selected rows tint `rowSel`. Pager row at the bottom.
**D7 · Member detail.** The members table dimmed to `opacity-50` under a `rgba(11,18,32,.34)` scrim, with a **620px right drawer**. Drawer header: 56px avatar, name + club-red `#9`, a mono provenance line (`member since · id · team · household`), Message + close. Tabs: Profile / Attendance / Finance / Documents / History. Profile body: warning banner naming both blockers with a Request action → two cards side by side (Identity as label-value rows with mono values; Household listing every related person with their relationship, payer and staff roles, plus a sibling-discount note) → season card with a 12-bar attendance sparkline (`ok` present, `club` absent, `edge` upcoming) beside Present / Absent / No-reply numerals → three small stat cards (Open balance, Plan, App). Footer: Save changes (club red), Move team (secondary), and **End membership as red text, never a button**.
**D3 · Sign-up intake & approval.** Two-pane: left, the queue table (Applicant with source line · Born · Wants · Checks pill · Age, oldest highlighted with a 3px `ice` left border and `rowFocus` tint); right, a **420px detail pane** — applicant header, Checks list (20px square icons, `ok` for passes, `warn` with `!` for gaps), "Place in" team buttons, a fee-plan mini-table ending in an instalment row on `subhead`, and the applicant's own note as a quote. Footer: "Approve & invoice" (`bg-ok`, flex-1) + Hold (secondary).
**D4 · Team & staff assignment.** 180px team cover photo with a left-weighted scrim, `ice` category eyebrow, 52px condensed team name, meta row, and Export roster / Add player buttons bottom-right. Tab strip: Roster / Staff / Schedule / Attendance / Results / Settings. Content 1.55fr/1fr: left, the roster table (No. · Player with C/A letters in club red · Position · Shoots · Status pill; attention rows tinted); right, a Staff card (avatar, name, `role · rights`, mono "since YYYY"), a "Squad make-up" card (Goalies / Defence / Forwards numerals + the federation minimum stated in prose), and a dark "Blocking the season start" card listing blockers with `ice` actions.
**D5 · Season calendar planning.** Sidebar gains an "Ice resources" legend (main rink `ice`, training rink `violet`, off-ice `warn`, games `club`). Topbar: week number, mono date range, prev/next, Week/Month/Season switch, "Plan recurring". Main: a 7-column week grid with a 54px mono hour gutter (17:0023:00) and absolutely positioned event blocks coloured by resource; the focused team's block is `bg-ink` with a 2px club-red border and shows its attendance split. Right rail (300px): a "Plan recurring" form (Team / Pattern / Range / Skip) ending in "Generate 32 events", a Conflicts list (double booking in danger colours, players in two teams in warn colours), and a footnote that publishing pushes to member apps and the public site together.
**D6 · Dues & billing.** Topbar: Export SEPA / Send reminders / New invoice run. Row one: a dark "Collected" card (46px `€ 84.240`, a `68%` figure in `ice`, a three-segment progress bar, target and last-year comparison) plus three light cards (Open, Overdue 30+ in club red, Instalment plans). Row two 1.7fr/1fr: left, the invoice table (mono invoice number · Household · For · Amount · Due · Age, overdue ages in `clubDark`) with Overdue/All chips in its header; right, an "Aging" card (four labelled bars: not due `ink`, 130 `warn`, 3160 `club`, 60+ `#8C0019`) over a "Reminder ladder" card (four numbered steps escalating in colour) with a note that suspension is a club setting and never automatic for youth.
**D8 · News list & editor.** Three panes. **400px list pane:** topbar with New post, filter chips (All 42 / Drafts 3 / Scheduled 1), then post rows — status pill + mono meta (date · reads, or "edited N ago"), condensed uppercase headline, `author · team · category`; the open draft is tinted `rowSel` with a 3px club-red left border. **Editor:** its own topbar (mono autosave state, Preview / Schedule / Publish), then a white article card with a 190px cover slot and the article rendered at final typography — club-red category eyebrow, 42px condensed uppercase headline, 56×3 club-red rule, semibold lede, body paragraphs, club-red caret. **300px right rail:** Audience toggles (team families / whole club / public website), a push-reach note, tags, and a note that coaches can post to their own team from the app while club-wide and website posts need a news role.
**D9 · Club identity & branding.** Topbar shows "unsaved changes" in `warnText` with Discard / Save. Two columns. Left: **Club** card (Club name, Short code in mono, Tagline, Website in mono, Federation), **Colours** card (primary + secondary swatch inputs with mono hex, an "accents only" note in its header, and a contrast-check row showing computed ratios with ✓), **Logo & wordmark** card (76px dashed drop zones for crest SVG and wordmark). Right: **Live preview** card with App / Website / Email tabs — a 232px phone mock (header, role switcher, hero card, skeleton rows) beside a website-header mock and a "Where the brand shows up" list that ends with an explicit *never* (status colours, tables, form fields); below it an **Advanced** card (custom stylesheet with file size and edited date, own domain with a verified state) marked "enabled by RosterChief".
### Control panel (desktop, 1440×900)
Deliberately industrial: 4px radii, hairline `edge` borders, mono figures, no decoration. Shared shell: **52px `bg-ink` command bar** (mark + `RosterChief` + mono `control`, then mono tabs — active tab `bg-steel` with a 2px `ice` bottom border — spacer, a `⌘K run command` field or a primary action, and a live status dot) over a **34px white breadcrumb/metrics strip** (mono: environment, deploy, p95, queue depth, alert count in `clubDark`).
**P1 · Platform health.** Six KPI tiles (clubs live, members, WAU, MRR, zero-event clubs in `warn`, failed jobs in `club`) with mono labels and 38px condensed numerals. Below, 1.6fr/1fr: left, a stacked 12-week sign-up chart (youth `ice` over adult `ink`, square bars, mono week ticks) over a "Club health" table (club · members · WAU · events 30d · plan · risk pill, sorted by risk, the reference tenant tinted `rowFocus`); right, a dark **Alerts** card (mono entries with 2px severity left borders), a **Feature adoption** card (mono flag names with `n/34` counts and square progress bars), and a **Job log** card (mono `time · ok|err · job · detail`).
**P2 · Club provisioning & feature flags.** Command bar carries a club-red "Provision club" action; breadcrumb strip shows `clubs / slug / settings` plus club id and creation date. Left rail (280px) is a mono club list with health dots and member counts, active club inverted to `bg-ink`. Content: club header (64px crest, 34px condensed name, mono domain/federation/counts line, then Impersonate / Audit log / Save). Below, two columns: left, a **Branding** card (primary + secondary swatch fields, logo and wordmark previews, and a note that colours apply to accents only) over a mono **Plan & billing** card (plan, seats, monthly, renews, last invoice in `ok`); right, a **Feature flags** table — one row per flag with the mono flag name, a plain-language description, an optional `beta` / `soon` chip, and a toggle. Flags shown: `public_site`, `online_payments`, `lineups`, `licence_sync_rbihf`, `instalment_plans`, `licence_suspension`, `shop_beta`, `season_registration`, `custom_stylesheet`, `multi_sport`.
---
## Interactions & behaviour
**Mode switching.** The switcher renders only if the account has ≥1 staff assignment. Switching swaps the tab bar, the chrome palette (navy/club ↔ ink/ice) and the navigation stack; each mode keeps its own stack position. Persist the last mode per device and restore on launch. Deep links from a notification open the correct mode regardless of the stored one.
**Person scope.** The person switcher is a horizontally scrolling chip row. Changing it re-scopes the current screen without navigating. Managed people come from the household/family relation; "Me" appears when the account holder is themselves a member. Calendar has an extra "All members" scope.
**Attendance.** Three states — in / maybe / out — plus an implicit *no reply*. Answers close at a per-event deadline (default 24h before start); after that the control becomes read-only with the reason shown. Coach-side attendance (C2) records actual presence, which is a separate axis from the member's RSVP; both feed the attendance percentage on the member record.
**Line-up.** Drag a player from the Available row into a unit slot; slots accept one player and swap on drop. Out and silent players stay visible but non-draggable at 50% opacity. Publish notifies only selected players and writes the line-up to the game record.
**Recurring events.** The planner previews the count before writing ("Generate 32 events"). Conflicts are computed against ice resources and against members in two teams, and are shown before generation, not after.
**Bulk actions.** Selecting rows reveals the dark bulk bar; the count is authoritative and actions apply to the selection, not the filter. Clear deselects without resetting filters.
**Approval.** "Approve & invoice" creates the membership, places the person in the chosen team, applies the fee plan, and issues the invoice in one action. Open checks do not block approval — they carry over as tasks on the member record.
**Motion.** Restrained. Sheet transitions 240ms `cubic-bezier(.2,.8,.2,1)`; drawer slide 240ms; pill/toggle state 120ms; counters may count up on first paint (≤600ms) but nothing loops. No parallax, no decorative animation.
**States to build that the mocks imply.** Empty (no events / no news / no managed people), loading skeletons matching card geometry (see the D9 preview's skeleton rows for the intended treatment), offline banner for the coach at the rink (attendance must queue and sync), form validation inline under the field in `clubDark`, and permission-denied where a coach lacks a right (hide, don't disable, except where the absence would be confusing).
## State
Mobile: `mode` (member|coach, persisted), `scopePerson`, `activeTeam` (coach), per-event `rsvp[personId]`, attendance draft `{memberId: in|out|unset}` (offline-queued), notification read state, filter selections.
Desktop: route, table filters + saved view, selection set, drawer target + tab, editor draft with autosave timestamp, dirty-form flag (D9 shows it explicitly in the topbar).
## Assets
- **Fonts:** Barlow, Barlow Condensed, IBM Plex Mono (Google Fonts, all OFL).
- **Icons:** inline 24×24 stroke-2 SVGs, `currentColor`-ready — bell, home, calendar, news, person, clock, person-plus, plus, chevron, check, cross, search, chart, building, dots. Swap for the codebase's existing icon set if one exists; keep 21px at 2px stroke on mobile tabs.
- **Crest:** clip-path polygon fallback plus `uploads/rosterchief-dark.svg` (the RosterChief shield). Club crests come from `Club.logo`.
- **Photography: not included.** Every `<image-slot>` marks a required real photo: M1 hero + news cover, M2 game action, M4 article portrait, C5 news cover, D4 team cover, D8 article cover.
## Files in this bundle
| File | What it is |
|---|---|
| `RosterChief Platform.dc.html` | The design document — all 25 screens. Open in a browser; it is the visual source of truth. |
| `ios-frame.jsx` | Presentation-only iOS bezel used by the mobile screens. Not for production. |
| `image-slot.js` | Photo placeholder component. Not for production. |
| `support.js` | Runtime for the design document. **Do not port.** |
| `rosterchief-dark.svg` | RosterChief shield mark. |
| `github.md` | Repo association and the screen → Django app map. |
## Suggested build order
1. Tokens: Tailwind config, fonts, per-tenant CSS custom properties for club colours.
2. Primitives: button, pill, toggle, chip, label-value row, table row, card, tab bar, app header + role switcher.
3. Member mode M1 → M3 → M2 → M7 (the RSVP loop is the product's core).
4. Coach mode C1 → C2 → C3 (attendance and line-up are the reason coaches install anything).
5. Management D2 → D7 → D3 (member data, then intake), then D4, D5, D6, D8, D9.
6. Control panel P1, P2.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
repo: bsiebens/RosterChief
branch: main
## Last sync
date: 2026-08-16T00:00:00Z
### Updated in this project
- Clean-sheet restructure into four surfaces: control panel, club management, and one mobile app with Coach / Member modes (role switcher in the header, remembered per device).
- "Parents app" renamed and rethought as the Member app — adult members and parents use the same screens, with a person switcher on anything per-member.
- New design language: Barlow Condensed display, scoreboard numerals, navy/red core, ice-blue coach accent; club theming limited to primary + secondary colour, logo and wordmark.
- Added member detail drawer, news list + editor, club branding settings and the member notification inbox.
- Control panel moved to an industrial light workspace under a dark command bar, monospaced figures throughout.
## Sync history
date: 2026-08-08T16:59:19Z — control panel rebuilt around the real statistics service; club detail stat groups; dark hero band.
date: 2026-08-08T16:18:27Z — first clean-sheet design for three layers; domain model from the repo's Django apps.
## Screen map
| Screen | Repo files |
| --- | --- |
| Foundations (palette, type, status, role switcher, surface map) | club/models.py (Club.primary_color, secondary_color, logo) |
| M1M6 Member app: home, event RSVP for several, calendar, news article, me &amp; my people, edit personal info | news/models.py, events/models.py (Attendance), members/models.py (Family), club/models.py (ClubMembership) |
| C1C6 Coach mode: today, bench attendance, line-up / game selection, create event, post news, add members to team | events/models.py, teams/models.py (Position, StaffAssignment), news/models.py |
| D1D6 Management: club home, members list &amp; bulk actions, sign-up intake &amp; approval, team &amp; staff assignment, season calendar planning, dues &amp; billing | management/urls.py, members/models.py, teams/models.py, events/models.py, billing/models.py |
| D7D9 Management: member detail drawer, news list &amp; editor, club identity &amp; branding | members/models.py, news/models.py, club/models.py (primary_color, secondary_color, logo, custom stylesheet) |
| M7 Member app: notification inbox | events/models.py (Attendance), news/models.py, billing/models.py |
| P1P2 Control panel: platform health, club provisioning &amp; feature flags | controlpanel/services/statistics.py, controlpanel/views.py, features/models.py, billing/models.py |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,352 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
//
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
// and home indicator (props: title, dark, keyboard):
//
// <IOSDevice title="Settings">
// ...your screen content...
// </IOSDevice>
// <IOSDevice dark title="Search" keyboard>…</IOSDevice>
/* END USAGE */
// ─────────────────────────────────────────────────────────────
// Status bar
// ─────────────────────────────────────────────────────────────
function IOSStatusBar({ dark = false, time = '9:41' }) {
const c = dark ? '#fff' : '#000';
return (
<div style={{
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
padding: '21px 24px 19px', boxSizing: 'border-box',
position: 'relative', zIndex: 20, width: '100%',
}}>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
<span style={{
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
fontSize: 17, lineHeight: '22px', color: c,
}}>{time}</span>
</div>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
<svg width="19" height="12" viewBox="0 0 19 12">
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
</svg>
<svg width="17" height="12" viewBox="0 0 17 12">
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
</svg>
<svg width="27" height="13" viewBox="0 0 27 13">
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Liquid glass pill — blur + tint + shine
// ─────────────────────────────────────────────────────────────
function IOSGlassPill({ children, dark = false, style = {} }) {
return (
<div style={{
height: 44, minWidth: 44, borderRadius: 9999,
position: 'relative', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: dark
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
...style,
}}>
{/* blur + tint */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
}} />
{/* shine */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Navigation bar — glass pills + large title
// ─────────────────────────────────────────────────────────────
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
const text = dark ? '#fff' : '#000';
const pillIcon = (content) => (
<IOSGlassPill dark={dark}>
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{content}
</div>
</IOSGlassPill>
);
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px',
}}>
{/* back chevron */}
{pillIcon(
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{/* trailing ellipsis */}
{trailingIcon && pillIcon(
<svg width="22" height="6" viewBox="0 0 22 6">
<circle cx="3" cy="3" r="2.5" fill={muted}/>
<circle cx="11" cy="3" r="2.5" fill={muted}/>
<circle cx="19" cy="3" r="2.5" fill={muted}/>
</svg>
)}
</div>
{/* large title */}
<div style={{
padding: '0 16px',
fontFamily: '-apple-system, system-ui',
fontSize: 34, fontWeight: 700, lineHeight: '41px',
color: text, letterSpacing: 0.4,
}}>{title}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Grouped list (inset card, r:26) + row (52px)
// ─────────────────────────────────────────────────────────────
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
const text = dark ? '#fff' : '#000';
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
return (
<div style={{
display: 'flex', alignItems: 'center', minHeight: 52,
padding: '0 16px', position: 'relative',
fontFamily: '-apple-system, system-ui', fontSize: 17,
letterSpacing: -0.43,
}}>
{icon && (
<div style={{
width: 30, height: 30, borderRadius: 7, background: icon,
marginRight: 12, flexShrink: 0,
}} />
)}
<div style={{ flex: 1, color: text }}>{title}</div>
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
{chevron && (
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{!isLast && (
<div style={{
position: 'absolute', bottom: 0, right: 0,
left: icon ? 58 : 16, height: 0.5, background: sep,
}} />
)}
</div>
);
}
function IOSList({ header, children, dark = false }) {
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const bg = dark ? '#1C1C1E' : '#fff';
return (
<div>
{header && (
<div style={{
fontFamily: '-apple-system, system-ui', fontSize: 13,
color: hc, textTransform: 'uppercase',
padding: '8px 36px 6px', letterSpacing: -0.08,
}}>{header}</div>
)}
<div style={{
background: bg, borderRadius: 26,
margin: '0 16px', overflow: 'hidden',
}}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame
// ─────────────────────────────────────────────────────────────
function IOSDevice({
children, width = 402, height = 874, dark = false,
title, keyboard = false,
}) {
return (
// data-om-starter: inert presence marker — Claude Design's starter-usage
// probe reads it; it renders nothing. Keep it on this root element.
<div data-om-starter="ios-frame" style={{
width, height, borderRadius: 48, overflow: 'hidden',
position: 'relative', background: dark ? '#000' : '#F2F2F7',
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
fontFamily: '-apple-system, system-ui, sans-serif',
WebkitFontSmoothing: 'antialiased',
}}>
{/* dynamic island */}
<div style={{
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
}} />
{/* status bar (absolute) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
<IOSStatusBar dark={dark} />
</div>
{/* nav + content */}
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{keyboard && <IOSKeyboard dark={dark} />}
</div>
{/* home indicator — always on top */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
paddingBottom: 8, pointerEvents: 'none',
}}>
<div style={{
width: 139, height: 5, borderRadius: 100,
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
}} />
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — iOS 26 liquid glass
// ─────────────────────────────────────────────────────────────
function IOSKeyboard({ dark = false }) {
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
// special-key icons
const icons = {
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
<div key={k} style={{
height: 42, borderRadius: 8.5,
flex: flex ? 1 : undefined, width: w, minWidth: 0,
background: ret ? '#08f' : keyBg,
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '-apple-system, "SF Compact", system-ui',
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
}}>{content}</div>
);
const row = (keys, pad = 0) => (
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
{keys.map(l => key(l, { flex: true, k: l }))}
</div>
);
return (
<div style={{
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
padding: '11px 0 2px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: dark
? '0 -2px 20px rgba(0,0,0,0.09)'
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
}}>
{/* liquid glass bg — same recipe as nav pills */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
}} />
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
pointerEvents: 'none',
}} />
{/* autocorrect bar */}
<div style={{
display: 'flex', gap: 20, alignItems: 'center',
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{['"The"', 'the', 'to'].map((w, i) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
<div style={{
flex: 1, textAlign: 'center',
fontFamily: '-apple-system, system-ui', fontSize: 17,
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
}}>{w}</div>
</React.Fragment>
))}
</div>
{/* key layout */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 13,
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], 20)}
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
{key(icons.shift, { w: 45, k: 'shift' })}
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
</div>
{key(icons.del, { w: 45, k: 'del' })}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
{key('', { flex: true, k: 'space' })}
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
</div>
</div>
{/* bottom spacer (emoji+mic area, icons omitted) */}
<div style={{ height: 56, width: '100%', position: 'relative' }} />
</div>
);
}
Object.assign(window, {
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
});

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200">
<path d="M50 4 L90 18 V50 C90 76 72 92 50 98 C28 92 10 76 10 50 V18 Z" fill="#0F1A2E"></path>
<rect x="27" y="30" width="46" height="8" rx="4" fill="#FFFFFF"></rect>
<rect x="27" y="44" width="34" height="8" rx="4" fill="#FFFFFF"></rect>
<path d="M27 80 L50 62 L73 80 L66 85 L50 72 L34 85 Z" fill="#FFFFFF"></path>
</svg>

After

Width:  |  Height:  |  Size: 417 B

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,20 @@ individually ``invited_members``, minus any ``excluded_members`` -- or, for a
event's season instead of teams/groups (the two are mutually exclusive, see
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
but only for events that are still in the future — history is never rewritten.
A member provisionally rostered by management.views.SignupPlaceInTeamView
(on a team, but still PENDING -- their sign-up isn't fully processed yet) is
still subtracted back out here if an open onboarding requirement blocks this
event's kind -- see club.services.onboarding.blocked_member_ids_for_event.
Explicitly ``invited_members`` bypasses that: a named, individual invite is a
deliberate staff decision that should win regardless.
"""
from django.db.models import Count, Q
from django.utils import timezone
from club.models import ClubMembership, Season
from club.services.onboarding import blocked_member_ids_for_event
from events.models import Attendance, Event
from members.models import Member
from teams.models import TeamMembership
@@ -44,7 +52,12 @@ def effective_members(event):
if group_ids:
member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True))
member_ids.update(event.invited_members.values_list("id", flat=True))
invited_ids = set(event.invited_members.values_list("id", flat=True))
if season is not None:
member_ids.difference_update(blocked_member_ids_for_event(event.club, season, event.kind) - invited_ids)
member_ids.update(invited_ids)
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))
return Member.objects.filter(id__in=member_ids)

220
events/services/calendar.py Normal file
View File

@@ -0,0 +1,220 @@
"""Date-range math and grid layout for the Events page's Week/Month/Season calendar
views (management/views.py's EventListView, template event_list.html). Kept separate
from the view itself since none of this touches the request/queryset-scoping layer --
it only turns "an anchor date + a list of already-visible events" into the shapes each
template needs.
Three grids, one per granularity:
- week_grid: a 7-day x hour-gutter layout with absolutely-positioned blocks (top/height
as a percentage of the visible hour span), including simple side-by-side column
layout for events that overlap in time on the same day.
- month_grid: a standard 6-row calendar grid, each day carrying its own event list
(title + kind, no time-of-day positioning -- there's no room for it at that scale).
- season_grid: one month_grid per month spanning the season, but day cells carry only
a count (a full title list is illegible at that scale) -- see D5-alike "Season" view.
"""
import calendar as _calendar
import datetime
from django.utils import timezone
#: The week grid's default visible hours -- expanded automatically (see week_grid)
#: if an event falls outside it, so nothing is ever clipped out of view.
DEFAULT_DAY_START_HOUR = 8
DEFAULT_DAY_END_HOUR = 22
#: Floor on a block's rendered height, in percent of the visible hour span -- a very
#: short event (a 15-minute weigh-in) would otherwise render as a sliver too thin to
#: click or read.
MIN_BLOCK_HEIGHT_PCT = 4.0
def week_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
"""Monday..Sunday of the week containing ``anchor``."""
start = anchor - datetime.timedelta(days=anchor.weekday())
return start, start + datetime.timedelta(days=6)
def month_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
"""First..last day of the month containing ``anchor``."""
last_day = _calendar.monthrange(anchor.year, anchor.month)[1]
return anchor.replace(day=1), anchor.replace(day=last_day)
def add_months(anchor: datetime.date, months: int) -> datetime.date:
"""``anchor`` shifted by whole months, clamped to day 1 -- only ever used to
step between month-starts (month_bounds/season month list), so the
day-of-month is never meaningful to preserve."""
month_index = anchor.month - 1 + months
year = anchor.year + month_index // 12
month = month_index % 12 + 1
return datetime.date(year, month, 1)
def _local_span(event) -> tuple[datetime.datetime, datetime.datetime]:
"""An event's start/end in local time, end defaulting to +1h when unset
(mirrors the "assumed duration" read-time fallback events.models.Event
documents for non-GAME kinds -- this is a display concern, so it doesn't
touch the stored field)."""
start = timezone.localtime(event.start)
end = timezone.localtime(event.end) if event.end else start + datetime.timedelta(hours=1)
if end <= start:
end = start + datetime.timedelta(hours=1)
return start, end
def _assign_columns(blocks: list[dict]) -> None:
"""Side-by-side layout for same-day events that overlap in time -- sets
``left_pct``/``width_pct`` on each block dict in place. Greedy interval
scheduling: sort by start, hand each event the lowest-numbered column
whose previous occupant has already ended, and once a run of mutually
overlapping events (a "cluster") is fully placed, every block in it shares
that cluster's column count as its width divisor -- otherwise an event
that only overlaps one neighbour would render at 1/3 width just because
the *neighbour* also overlaps something else further along."""
blocks.sort(key=lambda b: (b["start"], b["end"]))
columns: list[datetime.datetime] = [] # end time currently occupying each column
cluster: list[dict] = []
cluster_end = None
def flush(cluster_blocks):
if not cluster_blocks:
return
width = max(b["column"] for b in cluster_blocks) + 1
for b in cluster_blocks:
b["width_pct"] = round(100 / width, 2)
b["left_pct"] = round(b["column"] * 100 / width, 2)
for block in blocks:
if cluster_end is not None and block["start"] >= cluster_end:
flush(cluster)
cluster, columns, cluster_end = [], [], None
placed = False
for index, occupied_until in enumerate(columns):
if block["start"] >= occupied_until:
columns[index] = block["end"]
block["column"] = index
placed = True
break
if not placed:
columns.append(block["end"])
block["column"] = len(columns) - 1
cluster.append(block)
cluster_end = max(cluster_end, block["end"]) if cluster_end else block["end"]
flush(cluster)
def week_grid(events, week_start: datetime.date) -> dict:
"""``events`` (already club/visibility/season-scoped) laid out across the
Monday..Sunday week starting ``week_start``. Returns the hour gutter
bounds plus, per day, a list of blocks each carrying top/height/left/width
percentages for absolute positioning against a single shared-height grid."""
week_end = week_start + datetime.timedelta(days=6)
day_start_hour, day_end_hour = DEFAULT_DAY_START_HOUR, DEFAULT_DAY_END_HOUR
by_day: dict[datetime.date, list] = {week_start + datetime.timedelta(days=i): [] for i in range(7)}
spans = []
for event in events:
start, end = _local_span(event)
if not (week_start <= start.date() <= week_end):
continue
spans.append((event, start, end))
day_start_hour = min(day_start_hour, start.hour)
end_hour_frac = end.hour + end.minute / 60 + (1 if end.second or end.microsecond else 0)
day_end_hour = max(day_end_hour, int(end_hour_frac) + (1 if end_hour_frac % 1 else 0))
span_hours = max(day_end_hour - day_start_hour, 1)
for event, start, end in spans:
start_frac = max(0.0, (start.hour + start.minute / 60) - day_start_hour)
end_frac = min(float(span_hours), (end.hour + end.minute / 60) - day_start_hour)
by_day[start.date()].append(
{
"event": event,
"start": start,
"end": end,
"top_pct": round(100 * start_frac / span_hours, 2),
"height_pct": max(round(100 * (end_frac - start_frac) / span_hours, 2), MIN_BLOCK_HEIGHT_PCT),
}
)
days = []
for i in range(7):
day = week_start + datetime.timedelta(days=i)
blocks = by_day[day]
_assign_columns(blocks)
days.append({"date": day, "is_today": day == timezone.localdate(), "blocks": blocks})
return {
"week_start": week_start,
"week_end": week_end,
"days": days,
"hours": list(range(day_start_hour, day_end_hour + 1)),
"day_start_hour": day_start_hour,
"day_end_hour": day_end_hour,
}
def month_grid(events, anchor: datetime.date) -> dict:
"""A standard calendar grid (always full weeks, so always a multiple of 7
cells) for the month containing ``anchor``, each day carrying the events
that start on it. Cells outside the month stay in the grid (so the week
rows line up) but are flagged ``in_month=False`` for the template to dim."""
month_start, month_end = month_bounds(anchor)
grid_start = month_start - datetime.timedelta(days=month_start.weekday())
weeks_needed = -(-((month_end - grid_start).days + 1) // 7) # ceil div
grid_end = grid_start + datetime.timedelta(days=weeks_needed * 7 - 1)
by_day: dict[datetime.date, list] = {}
for event in events:
start, _end = _local_span(event)
day = start.date()
if grid_start <= day <= grid_end:
by_day.setdefault(day, []).append(event)
today = timezone.localdate()
weeks = []
day = grid_start
while day <= grid_end:
week = []
for _ in range(7):
week.append(
{
"date": day,
"in_month": day.month == anchor.month,
"is_today": day == today,
"events": by_day.get(day, []),
}
)
day += datetime.timedelta(days=1)
weeks.append(week)
return {"month_start": month_start, "month_end": month_end, "weeks": weeks}
def season_grid(events, season) -> list[dict]:
"""One compact month_grid per month spanning ``season``, day cells
trimmed to just a count (see module docstring) -- events are split up
front by month so each month's grid only scans its own slice, not the
whole season's list."""
events_by_month: dict[tuple[int, int], list] = {}
for event in events:
start, _end = _local_span(event)
events_by_month.setdefault((start.year, start.month), []).append(event)
months = []
cursor = season.start_date.replace(day=1)
end_month = season.end_date.replace(day=1)
while cursor <= end_month:
grid = month_grid(events_by_month.get((cursor.year, cursor.month), []), cursor)
for week in grid["weeks"]:
for cell in week:
cell["count"] = len(cell["events"])
del cell["events"]
months.append({"label": cursor, "weeks": grid["weeks"]})
cursor = add_months(cursor, 1)
return months

28
events/tasks.py Normal file
View File

@@ -0,0 +1,28 @@
"""Celery task behind the `extend-event-series` beat schedule entry (see
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
Mirrors `manage.py extend_event_series` exactly -- that command still exists, unchanged, for
manual use from a shell (see events/management/commands/extend_event_series.py).
"""
from celery import shared_task
from events.models import EventSeries
from events.services import generate_occurrences, horizon
from features.models import Maintenance
@shared_task(name="events.tasks.extend_event_series")
def extend_event_series():
if Maintenance.is_on():
# Loud, not silent: a job that quietly skips itself while the platform is closed is
# how a rolling horizon quietly runs dry. Raising here is what turns it into a
# Failure on the control panel's Jobs tab instead of nothing happening at all.
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
until = horizon()
total = 0
for series in EventSeries.objects.all():
total += len(generate_occurrences(series, until))
return f"Generated {total} occurrence(s) across {EventSeries.objects.count()} series."

View File

@@ -1,7 +1,8 @@
from datetime import timedelta
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from io import StringIO
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError
@@ -9,7 +10,8 @@ from django.test import TestCase
from django.utils import timezone
from waffle import get_waffle_flag_model
from club.models import Club, ClubMembership, Season
from club.models import Club, ClubMembership, OnboardingRequirement, Season
from club.services.onboarding import mark_bypassed, mark_complete
from members.models import Group, GroupMembership, Member
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
@@ -28,6 +30,7 @@ from .services import (
team_attendance_rate,
team_no_shows,
)
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
@@ -257,6 +260,66 @@ class EffectiveMembersTests(EventsTestBase):
self.assertEqual(self.attendee_ids(event), {dave.id})
# --- onboarding-requirement gating (club.services.onboarding.blocked_member_ids_for_event) ---
def make_membership(self, member, **kwargs):
kwargs.setdefault("status", ClubMembership.StatusChoices.ACTIVE)
return ClubMembership.objects.create(club=self.club, member=member, season=self.season, **kwargs)
def test_an_open_blocking_requirement_excludes_the_member_for_that_kind(self):
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.bob.id})
def test_the_same_open_requirement_does_not_block_an_unlisted_kind(self):
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.TRAINING)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_a_completed_blocking_requirement_stops_excluding_the_member(self):
membership = self.make_membership(self.alice)
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
mark_complete(membership, requirement, user=staff)
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_a_bypassed_blocking_requirement_also_stops_excluding_the_member(self):
membership = self.make_membership(self.alice)
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
mark_bypassed(membership, requirement, user=staff, note="waived")
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_an_explicit_invite_overrides_the_block(self):
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
event.invited_members.set([self.alice])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
def test_a_member_with_no_club_membership_at_all_is_unaffected_either_way(self):
# Alice/Bob have a TeamMembership but no ClubMembership in the base fixture --
# blocked_member_ids_for_event has nothing to look up for them, and they were
# never excluded by it to begin with (this is really a "doesn't crash" check).
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
class AttendanceSyncTests(EventsTestBase):
def test_setting_teams_creates_attendance_for_roster(self):
@@ -1289,3 +1352,119 @@ class RefereeServiceTests(EventsTestBase):
assignment.refresh_from_db()
self.assertEqual(assignment.km_total, Decimal("0"))
self.assertEqual(assignment.total_payable, Decimal("25.00"))
class CalendarGridTests(EventsTestBase):
"""events.services.calendar -- date-range math and grid layout behind the
Events page's Week/Month/Season views."""
def at(self, day, hour, minute=0):
return timezone.make_aware(datetime.combine(day, time(hour, minute)))
def test_week_bounds_returns_monday_to_sunday(self):
start, end = week_bounds(date(2026, 8, 19)) # a Wednesday
self.assertEqual(start, date(2026, 8, 17))
self.assertEqual(end, date(2026, 8, 23))
def test_month_bounds_returns_first_and_last_day(self):
start, end = month_bounds(date(2026, 2, 10))
self.assertEqual(start, date(2026, 2, 1))
self.assertEqual(end, date(2026, 2, 28))
def test_add_months_rolls_over_the_year(self):
self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1))
def test_week_grid_places_an_event_within_the_default_hours(self):
monday = date(2026, 8, 17)
event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30))
grid = week_grid([event], monday)
self.assertEqual(grid["day_start_hour"], 8)
self.assertEqual(grid["day_end_hour"], 22)
span = 22 - 8
block = grid["days"][0]["blocks"][0]
self.assertAlmostEqual(block["top_pct"], 100 * (10 - 8) / span, places=2)
self.assertAlmostEqual(block["height_pct"], 100 * 1.5 / span, places=2)
def test_week_grid_expands_the_hours_for_an_early_or_late_event(self):
monday = date(2026, 8, 17)
event = self.make_event(start=self.at(monday, 6), end=self.at(monday, 23))
grid = week_grid([event], monday)
self.assertEqual(grid["day_start_hour"], 6)
self.assertEqual(grid["day_end_hour"], 23)
def test_week_grid_excludes_events_outside_the_week(self):
monday = date(2026, 8, 17)
event = self.make_event(start=self.at(monday + timedelta(days=7), 10))
grid = week_grid([event], monday)
self.assertFalse(any(day["blocks"] for day in grid["days"]))
def test_week_grid_splits_overlapping_events_into_columns(self):
monday = date(2026, 8, 17)
first = self.make_event(title="Training A", start=self.at(monday, 10), end=self.at(monday, 11))
second = self.make_event(title="Training B", start=self.at(monday, 10, 30), end=self.at(monday, 11, 30))
grid = week_grid([first, second], monday)
blocks = grid["days"][0]["blocks"]
self.assertEqual(len(blocks), 2)
self.assertEqual({block["width_pct"] for block in blocks}, {50.0})
self.assertEqual({block["left_pct"] for block in blocks}, {0.0, 50.0})
def test_week_grid_gives_non_overlapping_events_full_width(self):
monday = date(2026, 8, 17)
first = self.make_event(title="Morning", start=self.at(monday, 9), end=self.at(monday, 10))
second = self.make_event(title="Evening", start=self.at(monday, 18), end=self.at(monday, 19))
grid = week_grid([first, second], monday)
blocks = grid["days"][0]["blocks"]
self.assertTrue(all(block["width_pct"] == 100.0 for block in blocks))
def test_month_grid_always_spans_full_weeks_of_seven_days(self):
grid = month_grid([], date(2026, 2, 1)) # Feb 2026 starts on a Sunday
self.assertTrue(all(len(week) == 7 for week in grid["weeks"]))
self.assertEqual(grid["weeks"][0][0]["date"].weekday(), 0) # Monday
def test_month_grid_flags_days_outside_the_month(self):
grid = month_grid([], date(2026, 2, 1))
self.assertFalse(grid["weeks"][0][0]["in_month"]) # January spillover
in_month_cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 1))
self.assertTrue(in_month_cell["in_month"])
def test_month_grid_buckets_events_under_their_start_day(self):
event = self.make_event(start=self.at(date(2026, 2, 12), 14))
grid = month_grid([event], date(2026, 2, 1))
cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 12))
self.assertEqual(cell["events"], [event])
def test_season_grid_spans_every_month_of_the_season(self):
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
months = season_grid([], season)
self.assertEqual(len(months), 12)
self.assertEqual(months[0]["label"], date(2026, 8, 1))
self.assertEqual(months[-1]["label"], date(2027, 7, 1))
def test_season_grid_cells_carry_a_count_not_the_event_list(self):
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
event = self.make_event(start=self.at(date(2026, 9, 5), 10))
months = season_grid([event], season)
september = next(month for month in months if month["label"] == date(2026, 9, 1))
cell = next(cell for week in september["weeks"] for cell in week if cell["date"] == date(2026, 9, 5))
self.assertEqual(cell["count"], 1)
self.assertNotIn("events", cell)

38
features/jobs.py Normal file
View File

@@ -0,0 +1,38 @@
"""Registry of the platform jobs Celery Beat runs on a schedule (see
rosterchief/settings.CELERY_BEAT_SCHEDULE).
Keyed on each task's dotted Celery name -- the same string a JobRun row carries in `name`
-- so features/signals.py can tell a tracked platform job apart from any other Celery task
that might get added later without a job to show for it, and so the control panel's Jobs
tab can label a JobRun without importing the task function itself.
"""
from django.utils.translation import gettext_lazy as _
JOB_REGISTRY = {
"events.tasks.extend_event_series": {
"label": _("Extend event series"),
"description": _("Materialises recurring event occurrences up to the rolling horizon, so the calendar never runs dry."),
"schedule": _("Daily at 03:00"),
},
"billing.tasks.renew_subscriptions": {
"label": _("Renew subscriptions"),
"description": _("Opens the next billing period for clubs whose current one is running out."),
"schedule": _("Daily at 04:00"),
},
"billing.tasks.send_billing_reminders": {
"label": _("Send billing reminders"),
"description": _("Emails club admins about outstanding platform fees, once per escalation level."),
"schedule": _("Daily at 05:00"),
},
"billing.tasks.archive_overdue_clubs": {
"label": _("Archive overdue clubs"),
"description": _("Archives clubs unpaid past their grace period."),
"schedule": _("Daily at 06:00"),
},
"club.tasks.generate_seasons": {
"label": _("Generate seasons"),
"description": _("Generates the next two years of season rows for every active club, so signups and rosters never hit a missing season."),
"schedule": _("Monthly, 1st at 05:00"),
},
}

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-08-16 14:01
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('features', '0002_maintenance'),
]
operations = [
migrations.CreateModel(
name='JobRun',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('task_id', models.CharField(max_length=255, unique=True, verbose_name='task id')),
('name', models.CharField(help_text='Dotted Celery task name, e.g. billing.tasks.renew_subscriptions.', max_length=255, verbose_name='task name')),
('status', models.CharField(choices=[('started', 'Started'), ('success', 'Success'), ('failure', 'Failed')], default='started', max_length=10, verbose_name='status')),
('started_at', models.DateTimeField(verbose_name='started at')),
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='finished at')),
('detail', models.TextField(blank=True, help_text='What the task returned, on success.', verbose_name='detail')),
('error', models.TextField(blank=True, help_text='What the task raised, on failure.', verbose_name='error')),
],
options={
'verbose_name': 'job run',
'verbose_name_plural': 'job runs',
'ordering': ['-started_at'],
},
),
]

View File

@@ -151,3 +151,38 @@ class Maintenance(UUIDModel):
maintenance.save()
return maintenance
class JobRun(UUIDModel):
"""One execution of a scheduled platform job -- see features/jobs.py for the registry
of what each job is, and rosterchief/settings.CELERY_BEAT_SCHEDULE for when it runs.
Written entirely by the Celery signal handlers in features/signals.py: individual tasks
(billing/tasks.py, club/tasks.py, events/tasks.py) don't touch this model, so a task
that raises still gets a row -- the signal fires regardless of how the task ended.
"""
class Status(models.TextChoices):
STARTED = "started", _("Started")
SUCCESS = "success", _("Success")
FAILURE = "failure", _("Failed")
task_id = models.CharField(_("task id"), max_length=255, unique=True)
name = models.CharField(_("task name"), max_length=255, help_text=_("Dotted Celery task name, e.g. billing.tasks.renew_subscriptions."))
status = models.CharField(_("status"), max_length=10, choices=Status.choices, default=Status.STARTED)
started_at = models.DateTimeField(_("started at"))
finished_at = models.DateTimeField(_("finished at"), null=True, blank=True)
detail = models.TextField(_("detail"), blank=True, help_text=_("What the task returned, on success."))
error = models.TextField(_("error"), blank=True, help_text=_("What the task raised, on failure."))
class Meta:
verbose_name = _("job run")
verbose_name_plural = _("job runs")
ordering = ["-started_at"]
def __str__(self):
return f"{self.name} · {self.started_at:%Y-%m-%d %H:%M}"
@property
def duration(self):
return None if self.finished_at is None else self.finished_at - self.started_at

View File

@@ -1,14 +1,18 @@
"""Keep waffle's flag cache honest when club targeting changes.
"""Keep waffle's flag cache honest when club targeting changes, and keep a JobRun history
of the scheduled platform jobs (see features/jobs.py).
waffle caches a flag's M2M ids and only flushes on ``save()``. Editing an M2M
does not call ``save()``, so adding or removing a club would otherwise leave a
stale cached set and the flag would keep answering with the old value.
"""
from celery.signals import task_failure, task_postrun, task_prerun
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.utils import timezone
from .models import Flag
from .jobs import JOB_REGISTRY
from .models import Flag, JobRun
FLUSH_ACTIONS = {"post_add", "post_remove", "post_clear"}
@@ -24,3 +28,41 @@ def flush_flag_club_cache(sender, instance, action, reverse, pk_set, **kwargs):
# Reverse edit (club.flags.add(flag)): flush each flag touched.
for flag in Flag.objects.filter(pk__in=pk_set or []):
flag.flush()
@task_prerun.connect
def record_job_start(sender=None, task_id=None, task=None, **kwargs):
"""One JobRun row per task execution, for the jobs in JOB_REGISTRY only -- an
unregistered Celery task (should one ever be added without a job to show for it)
is not the control panel Jobs tab's business."""
if task is None or task.name not in JOB_REGISTRY:
return
JobRun.objects.create(task_id=task_id, name=task.name, status=JobRun.Status.STARTED, started_at=timezone.now())
@task_postrun.connect
def record_job_finish(sender=None, task_id=None, task=None, retval=None, state=None, **kwargs):
"""Closes the row record_job_start opened. Fires whether the task succeeded or raised --
on success, ``retval`` is whatever the task returned (see billing/tasks.py, club/tasks.py,
events/tasks.py: each returns a short human summary for this) and becomes JobRun.detail.
On failure ``retval`` is not reliably the exception, so record_job_failure below (driven
by the dedicated task_failure signal instead) fills in JobRun.error."""
if task is None or task.name not in JOB_REGISTRY:
return
succeeded = state == "SUCCESS"
JobRun.objects.filter(task_id=task_id).update(
status=JobRun.Status.SUCCESS if succeeded else JobRun.Status.FAILURE,
finished_at=timezone.now(),
detail=str(retval)[:4000] if succeeded else "",
)
@task_failure.connect
def record_job_failure(sender=None, task_id=None, exception=None, **kwargs):
task_name = getattr(sender, "name", None)
if task_name not in JOB_REGISTRY:
return
JobRun.objects.filter(task_id=task_id).update(error=str(exception)[:4000])

View File

@@ -1,6 +1,8 @@
from io import StringIO
from unittest.mock import patch
from allauth.mfa.models import Authenticator
from celery import shared_task
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.management import call_command
@@ -10,7 +12,8 @@ from waffle import flag_is_active, get_waffle_flag_model
from club.models import Club
from .models import Maintenance
from .jobs import JOB_REGISTRY
from .models import JobRun, Maintenance
Flag = get_waffle_flag_model()
User = get_user_model()
@@ -259,3 +262,63 @@ class MaintenanceCommandTests(TestCase):
Maintenance.start()
self.run_command("migrate", "--check") # raises SystemExit only if migrations are pending
@shared_task(name="features.tests.succeed")
def _succeed_task():
return "did the thing"
@shared_task(name="features.tests.fail")
def _fail_task():
raise RuntimeError("boom")
@shared_task(name="features.tests.untracked")
def _untracked_task():
return "quiet"
class JobRunTests(TestCase):
"""The Celery signal wiring in features/signals.py, exercised against throwaway tasks
(module-level, like any real task -- Celery's registry gets confused if the same task
name is redefined per-test) rather than the real billing/club/events ones: what matters
here is that a JobRun row appears for anything in JOB_REGISTRY and only for that, not the
domain logic of any one scheduled job (each of those has its own tests alongside its
management command)."""
def setUp(self):
self.succeed_task, self.fail_task, self.untracked_task = _succeed_task, _fail_task, _untracked_task
patcher = patch.dict(JOB_REGISTRY, {"features.tests.succeed": {}, "features.tests.fail": {}})
patcher.start()
self.addCleanup(patcher.stop)
def test_a_successful_run_is_recorded(self):
# .apply() runs the task synchronously, in-process, regardless of
# CELERY_TASK_ALWAYS_EAGER -- exactly what the task_prerun/task_postrun signal
# handlers in features/signals.py are wired to react to either way.
self.succeed_task.apply()
run = JobRun.objects.get(name="features.tests.succeed")
self.assertEqual(run.status, JobRun.Status.SUCCESS)
self.assertEqual(run.detail, "did the thing")
self.assertEqual(run.error, "")
self.assertIsNotNone(run.started_at)
self.assertIsNotNone(run.finished_at)
def test_a_failed_run_is_recorded(self):
# A real worker never raises a task's exception back into whoever called .delay() --
# it's async, the caller is long gone by the time the task runs -- so eager mode
# doesn't either (CELERY_TASK_EAGER_PROPAGATES is left at its default False; see
# rosterchief/settings.py). The result carries FAILURE instead, same as production.
result = self.fail_task.apply()
self.assertEqual(result.state, "FAILURE")
run = JobRun.objects.get(name="features.tests.fail")
self.assertEqual(run.status, JobRun.Status.FAILURE)
self.assertIn("boom", run.error)
def test_a_task_outside_the_registry_is_not_tracked(self):
self.untracked_task.apply()
self.assertFalse(JobRun.objects.filter(name="features.tests.untracked").exists())

View File

@@ -10,7 +10,7 @@ action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news
from waffle import flag_is_active
from billing.services.notices import club_billing_notice
from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
from club.services.access import can_add_news, can_manage_members, has_management_access, is_club_admin, is_coach_manager
from members.models import ParentClaim
#: Every management URL name, mapped to the nav item it should light up --
@@ -126,6 +126,57 @@ _NAV_SECTIONS = {
"invoice_list": "invoice_list",
"form_list": "form_list",
"submission_list": "form_list",
"club_settings": "club_settings",
"onboarding_requirement_list": "onboarding_requirement_list",
"onboarding_requirement_create": "onboarding_requirement_list",
"onboarding_requirement_update": "onboarding_requirement_list",
"onboarding_requirement_delete": "onboarding_requirement_list",
"member_requirement_complete": "member_list",
"member_requirement_bypass": "member_list",
"member_requirement_incomplete": "member_list",
"member_requirement_document": "member_list",
"signup_list": "signup_list",
"signup_approve_all_clean": "signup_list",
"signup_approve_one": "signup_list",
"signup_place_in_team": "signup_list",
}
#: The redesigned sidebar has two levels (design_handoff_rosterchief_platform/README.md,
#: D1's 236px sidebar: Overview/Members/Teams/Calendar/News/Finance/Settings, each except
#: News expandable into sub-items -- D2 shows Members expanded as All members/Sign-ups/
#: Households/Roles). This maps each *leaf* nav value from _NAV_SECTIONS above to which
#: top-level item should be open/highlighted; management/templates/management/_nav_items.html
#: reads `nav_section` for that, and `nav` (unchanged) for which sub-item.
#:
#: Membership dues tracking moved from Members to Finance here versus the old flat nav --
#: D6 "Dues & billing" is a Finance concern in the design, not a People one.
_TOP_SECTION = {
"home": "overview",
"member_list": "members",
"signup_list": "members",
"family_list": "members",
"parent_claim_list": "members",
"group_list": "members",
"membership_list": "finance",
"team_list": "teams",
"referee_list": "teams",
"news_list": "news",
"event_list": "calendar",
"location_list": "calendar",
"opponent_list": "calendar",
"sponsor_list": "settings",
"product_list": "finance",
"order_list": "finance",
"discount_list": "finance",
"invoice_list": "finance",
"form_list": "settings",
"club_settings": "settings",
"onboarding_requirement_list": "settings",
"role_list": "settings",
"position_list": "settings",
"referee_level_list": "settings",
"referee_management": "settings",
}
@@ -135,17 +186,24 @@ def active_nav_section(request):
some other app can never leak into this."""
match = request.resolver_match
if match is None or match.namespace != "management":
return {"nav": None}
return {"nav": None, "nav_section": None}
return {"nav": _NAV_SECTIONS.get(match.url_name)}
nav = _NAV_SECTIONS.get(match.url_name)
return {"nav": nav, "nav_section": _TOP_SECTION.get(nav)}
def is_admin(request):
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"is_club_admin": False}
return {"is_club_admin": False, "can_manage_members": False}
return {"is_club_admin": is_club_admin(request.user, club)}
return {
"is_club_admin": is_club_admin(request.user, club),
# Gates the nav items MemberAdminRequiredMixin also gates at the view layer
# (Members/Teams/Referee setup/Onboarding requirements) -- real ADMIN (which
# already includes the platform-superuser bypass) or MEMBER_ADMIN.
"can_manage_members": can_manage_members(request.user, club),
}
def billing_notice(request):
@@ -221,13 +279,14 @@ def sidebar_counters(request):
the same shape RefereeManagementDashboardView's own kpi_no_referee uses for
its default "next 10" range).
Admin-only, matching how _nav_items.html itself gates both links (`{% if
is_club_admin %}`) -- a coach never sees either link, so there's no reason
to run either query for them. Always an int when shown, never hidden at 0:
"the queue is empty" and "nobody checked" have to read differently.
Gated on can_manage_members (real ADMIN or MEMBER_ADMIN), matching how
_nav_items.html itself gates both links -- a coach never sees either link, so
there's no reason to run either query for them. Always an int when shown,
never hidden at 0: "the queue is empty" and "nobody checked" have to read
differently.
"""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated or not is_club_admin(request.user, club):
if club is None or not request.user.is_authenticated or not can_manage_members(request.user, club):
return {"pending_parent_claims_count": None, "games_missing_referees_count": None}
# Imported here rather than at module level to keep this module's own import

View File

@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment, Sponsor
from club.models import Club, ClubMembership, ClubRole, FeePayment, OnboardingRequirement, Sponsor
from club.services.access import groups_manageable_by, is_club_admin, teams_managed_by
from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError, extract_team_id
@@ -137,6 +137,11 @@ class TeamMembershipForm(forms.ModelForm):
members = members.exclude(pk__in=taken)
self.fields["member"].queryset = members
self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False)
# The model field itself is optional (SignupTeamPlacementForm leaves it
# blank on purpose -- see that form's own docstring), but adding/editing a
# roster spot here is still expected to pick one; only the Sign-up page's
# own placement skips it.
self.fields["position"].required = True
def clean(self):
cleaned = super().clean()
@@ -865,3 +870,91 @@ class RecordFeePaymentForm(forms.Form):
method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
class ClubSettingsForm(forms.ModelForm):
"""A club's own self-service identity/branding editor (management:club_settings) --
the club-facing equivalent of controlpanel's ClubForm, minus everything only
platform staff should touch: `slug` (the club's subdomain -- changing it breaks
every link the club has ever shared) and the season fields (governed by
club.services.seasons, not something to edit casually from a settings form)."""
class Meta:
model = Club
fields = ["name", "legal_name", "contact_email", "logo", "primary_color", "secondary_color"]
widgets = {
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
}
class OnboardingRequirementForm(forms.ModelForm):
#: Not a model field lookup -- blocked_event_kinds is a plain JSONField (see
#: OnboardingRequirement's own docstring for why: importing Event.EventKind at
#: the model layer would create a club<->events import cycle). This is the one
#: place that actually validates against real event kinds.
blocked_event_kinds = forms.MultipleChoiceField(
label=_("Blocks selection for"),
choices=Event.EventKind.choices,
required=False,
widget=forms.CheckboxSelectMultiple,
help_text=_("Event kinds a member can't be invited to or selected for while this is open. Leave unchecked for a purely informational requirement."),
)
class Meta:
model = OnboardingRequirement
fields = ["name", "description", "requires_document", "blocked_event_kinds", "is_active", "order"]
class RequirementCompletionForm(forms.Form):
"""Marks one OnboardingRequirement complete for one membership -- see
club.services.onboarding.mark_complete. `document` is only meaningful when the
requirement itself has `requires_document=True`; the view doesn't reject an
upload against a requirement that doesn't ask for one, it's just unused."""
document = forms.FileField(label=_("Document"), required=False)
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
class SignupTeamPlacementForm(forms.ModelForm):
"""Places one member (fixed by the view, not a form field) onto a team from
the Sign-up page -- team only, picked from big buttons rather than a form.
Position is left blank entirely (TeamMembership.position is nullable) rather
than guessed at: which position/number they end up with is the team
manager's own call to make correctly later (team_roster_update), and a
blank position is also what groups every just-placed, not-yet-sorted member
together (e.g. via the API) until then.
Deliberately does NOT filter through eligible_roster_members like
TeamMembershipForm's own member queryset does -- the whole point here is
placing a still-PENDING member so a team's coach can already see them and the
roster count is accurate, before their status/documents are actually clean.
See club.services.onboarding.blocked_member_ids_for_event for what still
gates their event invitations in the meantime."""
class Meta:
model = TeamMembership
fields = ["team"]
def __init__(self, *args, club=None, season=None, member=None, **kwargs):
# Pre-seed the instance (not just self.season/self.member) -- same reasoning
# as TeamRosterAddView's own form_kwargs comment: TeamMembership.clean()'s
# validate_club_scope needs season/member set on the instance *before*
# is_valid() runs, since ModelForm._post_clean() calls instance.clean()
# straight after copying the form's own fields (team, the only real one
# here) onto it -- season/member never being form fields, they'd
# otherwise still be unset at that point. position is left at its default
# (None) -- see the class docstring.
kwargs.setdefault("instance", TeamMembership(season=season, member=member))
super().__init__(*args, **kwargs)
self.fields["team"].queryset = Team.objects.filter(club=club)
class RequirementBypassForm(forms.Form):
"""Marks one OnboardingRequirement as not needed for one member -- see
club.services.onboarding.mark_bypassed. A note is required here (unlike
RequirementCompletionForm's optional one): "why" is the whole point of a
bypass in a way it isn't for an ordinary completion."""
note = forms.CharField(label=_("Why isn't this needed?"), widget=forms.Textarea(attrs={"rows": 2}))

View File

@@ -0,0 +1,111 @@
{% load static lucide ui i18n %}
{% comment %}
Standalone shell for allauth screens (password change/reset, MFA, passkeys, recovery
codes) and 403.html/maintenance.html, rendered whenever the request path is under
/manage/ (see club/context_processors.py: MANAGEMENT_BASE_TEMPLATE). Without this, a
club staff member clicking "Change password" or "Manage MFA" from the management app's
user menu would get bounced onto _club_base.html's old daisyUI chain -- the club's
*public* skin, not the management app they were just in. Same reasoning and structure as
controlpanel/_auth_base.html (its own fork of this same problem for the platform side),
but themed with assets/management.css and this club's own colours, matching
management/base.html.
Block names match what templates/allauth/layouts/base.html and templates/403.html/
maintenance.html target (head_title, extra_head, main, extra_body) -- same contract as
controlpanel/_auth_base.html and _club_base.html, so none of those shared templates need
to know this base exists.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block head_title %}{% endblock head_title %} &middot; {{ club.name }}
</title>
<link rel="stylesheet" href="{% static 'css/management.css' %}">
{% if club.secondary_color %}
<style>
:root {
--tenant-club: {{ club.secondary_color }};
--tenant-club-dark: color-mix(in srgb, {{ club.secondary_color }} 80%, black);
}
</style>
{% endif %}
{% 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">
<div class="flex w-full max-w-md items-center justify-between gap-4 bg-ink py-1">
<a class="flex min-w-0 items-center gap-2.5" href="{% url 'management:home' %}">
{% if club.logo %}
<img class="crest h-8 w-8 shrink-0 object-cover" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="crest flex h-8 w-8 shrink-0 items-center justify-center bg-club">
<span class="font-display text-[11px] font-extrabold text-white">{{ club.initials }}</span>
</span>
{% endif %}
<span class="min-w-0 truncate font-display text-lg font-extrabold tracking-[.08em] text-white uppercase">{{ club.name }}</span>
</a>
<a class="flex shrink-0 items-center gap-1.5 font-mono text-xs text-on-dark-dim hover:text-white" href="{% url 'management:home' %}">
{% lucide "arrow-left" size=14 %} {% trans "Back to management" %}
</a>
</div>
<main class="flex w-full max-w-md flex-1 flex-col justify-center gap-4">
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert {{ alert.css }}" role="alert">
{% lucide alert.icon size=18 %}
<div>
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
<div class="text-sm">{{ alert.body }}</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% block main %}{% endblock main %}
</main>
<p class="font-mono text-[11px] text-on-dark-faint">&copy; {% now "Y" %} RosterChief</p>
{% comment %}
Same data-otp enhancement as controlpanel/_auth_base.html -- see that file's own
comment for why the real <input> stays invisible-but-focusable and each typed
character is mirrored into its own <span> instead of trying to align the real
glyphs with the box pitch via CSS alone.
{% 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>
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

View File

@@ -9,59 +9,57 @@ management.views.group_by_family: family/guardians/children/others/all) and
passes its own URL so admins land back on the member they were viewing; family_detail.html
leaves it unset, since staying on the family page is already the right place there.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Type" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for person in group.all %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}, {{ person.first_name }}</a></td>
<td data-label="{% trans 'Email' %}">{{ person.contact_email|default:"-" }}</td>
<td data-label="{% trans 'Type' %}">
{% if is_club_admin %}
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="role" class="select select-bordered select-sm" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}">
{% for value, label in family_role_choices %}
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Type" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for person in group.all %}
<tr>
<td><a class="link link-hover font-semibold text-ink" href="{% url 'management:member_detail' person.pk %}">{{ person.last_name }}, {{ person.first_name }}</a></td>
<td class="text-sm text-muted">{{ person.contact_email|default:"—" }}</td>
<td>
{% if is_club_admin %}
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="role" class="select w-auto" onchange="this.form.requestSubmit()" aria-label="{% trans 'Role in family' %}">
{% for value, label in family_role_choices %}
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
</form>
{% else %}
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex flex-wrap justify-end gap-1">
{% if person.grant_login_form %}
<button class="btn btn-outline btn-xs" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
{% lucide "key-round" size=13 %} {% trans "Grant login" %}
</button>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex flex-wrap justify-end gap-1">
{% if person.grant_login_form %}
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('grant_login_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Grant login' %}">
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
</button>
{% endif %}
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
{% lucide "user-x" size=14 %} {% trans "Remove" %}
</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}">
{% lucide "trash-2" size=14 %} {% trans "Delete" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a class="btn btn-outline btn-xs" href="{% url 'management:member_detail' person.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=13 %} {% trans "Edit" %}</a>
<button class="btn btn-xs btn-outline btn-error" type="button" onclick="document.getElementById('remove_family_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Remove from family' %}">
{% lucide "user-x" size=13 %} {% trans "Remove" %}
</button>
<button class="btn btn-xs btn-outline btn-error" type="button" onclick="document.getElementById('member_delete_modal_{{ group.family.pk }}_{{ person.pk }}').showModal()" aria-label="{% trans 'Delete' %}">
{% lucide "trash-2" size=13 %} {% trans "Delete" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if is_club_admin %}
{% trans "Delete member" as delete_member_title %}

View File

@@ -1,5 +1,5 @@
{% extends "management/base.html" %}
{% load i18n %}
{% load i18n lucide %}
{% comment %}
Shared placeholder for every entity that doesn't have its own list template yet
@@ -11,23 +11,26 @@
{% block heading %}{{ page_title }}{% endblock heading %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for object in object_list %}
<tr>
<td>{{ object }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">{% trans "Nothing here yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info">
{% lucide "construction" size=18 %}
<span>{% trans "This section is still being built. For now it only lists what's on file." %}</span>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for object in object_list %}
<tr>
<td>{{ object }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center text-muted">{% trans "Nothing here yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock panel %}

View File

@@ -6,8 +6,8 @@
<tr class="bulk-add-row">
<td>
{{ form.member }}
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.member.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td class="w-12">
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>

View File

@@ -1,74 +1,109 @@
{% load i18n lucide %}
{% comment %}
The management nav, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
just so the nav never shows a link they can't follow.
The two-level sidebar (design_handoff_rosterchief_platform/README.md, D1/D2): seven
top-level sections -- Overview/Members/Teams/Calendar/News/Finance/Settings -- each
except News expanding into sub-items while it (or a sub-item) is active. `nav`/
`nav_section` come from management.context_processors.active_nav_section.
`nav` (management.context_processors.active_nav_section) is the current page's
section, derived from the resolved URL name -- `menu-active` is daisyUI's active
state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
Admin-only sub-items are hidden here for plain staff -- the views are gated
regardless (ClubAdminRequiredMixin), this is just so the nav never shows a link
they can't follow. Same reasoning as the old flat nav this replaces.
{% endcomment %}
<li>
<a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a>
</li>
<a class="nav-item {% if nav_section == 'overview' %}active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=17 %} {% trans "Overview" %}</a>
<li class="menu-title">{% trans "People" %}</li>
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
<li><a class="{% if nav == 'family_list' %}menu-active{% endif %}" href="{% url 'management:family_list' %}">{% lucide "home" size=16 %} {% trans "Families" %}</a></li>
{% if is_club_admin %}
<li>
<a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">
{% lucide "inbox" size=16 %} {% trans "Parent claims" %}
<span class="badge badge-sm ml-auto {% if pending_parent_claims_count %}badge-error{% else %}badge-neutral{% endif %}">{{ pending_parent_claims_count }}</span>
</a>
</li>
{% endif %}
{% if is_club_admin %}
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
<li><a class="{% if nav == 'group_list' %}menu-active{% endif %}" href="{% url 'management:group_list' %}">{% lucide "users-round" size=16 %} {% trans "Groups" %}</a></li>
{% endif %}
<div>
<a class="nav-item {% if nav_section == 'members' %}active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=17 %} {% trans "Members" %}</a>
{% if nav_section == 'members' %}
<div class="flex flex-col">
<a class="nav-subitem {% if nav == 'member_list' %}active{% endif %}" href="{% url 'management:member_list' %}">{% trans "All members" %}</a>
{% if is_club_admin %}
{# Sign-up is admin only (finance-adjacent -- fee status feeds it) even though parent claims/households/groups below open up to MEMBER_ADMIN too. #}
<a class="nav-subitem {% if nav == 'signup_list' %}active{% endif %}" href="{% url 'management:signup_list' %}">{% trans "Sign-up" %}</a>
{% endif %}
{% if can_manage_members %}
{# Membership dues/fee tracking (management:membership_list) lives under Finance, not here -- see that section below. #}
<!--
<a class="nav-subitem flex items-center gap-2 {% if nav == 'parent_claim_list' %}active{% endif %}" href="{% url 'management:parent_claim_list' %}">{% trans "Parent claims" %}
{% if pending_parent_claims_count %}<span class="badge badge-error badge-xs">{{ pending_parent_claims_count }}</span>{% endif %}
</a>
-->
{% endif %}
<a class="nav-subitem {% if nav == 'family_list' %}active{% endif %}" href="{% url 'management:family_list' %}">{% trans "Households" %}</a>
{% if can_manage_members %}
<a class="nav-subitem {% if nav == 'group_list' %}active{% endif %}" href="{% url 'management:group_list' %}">{% trans "Groups" %}</a>
{% endif %}
</div>
{% endif %}
</div>
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
<li>
<a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">
{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
<span class="badge badge-sm ml-auto {% if games_missing_referees_count %}badge-error{% else %}badge-neutral{% endif %}">{{ games_missing_referees_count }}</span>
</a>
</li>
{% endif %}
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
<div>
<a class="nav-item {% if nav_section == 'teams' %}active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=17 %} {% trans "Teams" %}</a>
{% if nav_section == 'teams' %}
<div class="flex flex-col">
<a class="nav-subitem {% if nav == 'team_list' %}active{% endif %}" href="{% url 'management:team_list' %}">{% trans "All teams" %}</a>
<a class="nav-subitem {% if nav == 'referee_list' %}active{% endif %}" href="{% url 'management:referee_list' %}">{% trans "Referees" %}</a>
</div>
{% endif %}
</div>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
<div>
<a class="nav-item {% if nav_section == 'calendar' %}active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=17 %} {% trans "Calendar" %}</a>
{% if nav_section == 'calendar' %}
<div class="flex flex-col">
<a class="nav-subitem {% if nav == 'event_list' %}active{% endif %}" href="{% url 'management:event_list' %}">{% trans "Events" %}</a>
{% if has_management_position %}
<a class="nav-subitem {% if nav == 'location_list' %}active{% endif %}" href="{% url 'management:location_list' %}">{% trans "Locations" %}</a>
<a class="nav-subitem {% if nav == 'opponent_list' %}active{% endif %}" href="{% url 'management:opponent_list' %}">{% trans "Opponents" %}</a>
{% endif %}
</div>
{% endif %}
</div>
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
{% if has_management_position %}
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% endif %}
<a class="nav-item {% if nav_section == 'news' %}active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=17 %} {% trans "News" %}</a>
{% if is_club_admin %}
<li class="menu-title">{% trans "Sponsors" %}</li>
<li><a class="{% if nav == 'sponsor_list' %}menu-active{% endif %}" href="{% url 'management:sponsor_list' %}">{% lucide "handshake" size=16 %} {% trans "Sponsors" %}</a></li>
<div>
<a class="nav-item {% if nav_section == 'finance' %}active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=17 %} {% trans "Finance" %}</a>
{% if nav_section == 'finance' %}
<div class="flex flex-col">
<a class="nav-subitem {% if nav == 'membership_list' %}active{% endif %}" href="{% url 'management:membership_list' %}">{% trans "Dues & billing" %}</a>
{% if shop_enabled %}
<a class="nav-subitem {% if nav == 'product_list' %}active{% endif %}" href="{% url 'management:product_list' %}">{% trans "Products" %}</a>
<a class="nav-subitem {% if nav == 'order_list' %}active{% endif %}" href="{% url 'management:order_list' %}">{% trans "Orders" %}</a>
<a class="nav-subitem {% if nav == 'discount_list' %}active{% endif %}" href="{% url 'management:discount_list' %}">{% trans "Discounts" %}</a>
<a class="nav-subitem {% if nav == 'invoice_list' %}active{% endif %}" href="{% url 'management:invoice_list' %}">{% trans "Invoices" %}</a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if is_club_admin and shop_enabled %}
<li class="menu-title">{% trans "Shop" %}</li>
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
{% endif %}
{% if is_club_admin and forms_enabled %}
<li class="menu-title">{% trans "Forms" %}</li>
<li><a class="{% if nav == 'form_list' %}menu-active{% endif %}" href="{% url 'management:form_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Forms" %}</a></li>
{% if is_club_admin or can_manage_members %}
<div>
{# MEMBER_ADMIN has no Club identity access, so their landing click on "Settings" itself goes to the first sub-item they can actually reach. #}
<a class="nav-item {% if nav_section == 'settings' %}active{% endif %}" href="{% if is_club_admin %}{% url 'management:club_settings' %}{% else %}{% url 'management:onboarding_requirement_list' %}{% endif %}">{% lucide "settings" size=17 %} {% trans "Settings" %}</a>
{% if nav_section == 'settings' %}
<div class="flex flex-col">
{% if is_club_admin %}
<a class="nav-subitem {% if nav == 'club_settings' %}active{% endif %}" href="{% url 'management:club_settings' %}">{% trans "Club identity" %}</a>
{% endif %}
<a class="nav-subitem {% if nav == 'onboarding_requirement_list' %}active{% endif %}" href="{% url 'management:onboarding_requirement_list' %}">{% trans "Onboarding requirements" %}</a>
{% if is_club_admin %}
<a class="nav-subitem {% if nav == 'role_list' %}active{% endif %}" href="{% url 'management:role_list' %}">{% trans "Roles" %}</a>
<a class="nav-subitem {% if nav == 'position_list' %}active{% endif %}" href="{% url 'management:position_list' %}">{% trans "Positions" %}</a>
{% endif %}
<a class="nav-subitem {% if nav == 'referee_level_list' %}active{% endif %}" href="{% url 'management:referee_level_list' %}">{% trans "Referee levels" %}</a>
<a class="nav-subitem flex items-center gap-2 {% if nav == 'referee_management' %}active{% endif %}" href="{% url 'management:referee_management' %}">{% trans "Referee management" %}
{% if games_missing_referees_count %}<span class="badge badge-error badge-xs">{{ games_missing_referees_count }}</span>{% endif %}
</a>
{% if is_club_admin %}
<a class="nav-subitem {% if nav == 'sponsor_list' %}active{% endif %}" href="{% url 'management:sponsor_list' %}">{% trans "Sponsors" %}</a>
{% if forms_enabled %}
<a class="nav-subitem {% if nav == 'form_list' %}active{% endif %}" href="{% url 'management:form_list' %}">{% trans "Forms" %}</a>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -1,13 +1,20 @@
{% load i18n lucide ui %}
{% comment %}
Shared by the event detail page and the referee management dashboard --
context: event, referees (EventReferee rows, each with a .fee_form when
can_manage_referees), referee_candidates, referees_full,
can_manage_referees, and an optional next_url to return to after a POST
(defaults to the event detail page when blank).
context: event, referees (EventReferee rows -- fee/km/km_rate read straight
off the model instance, no separate form object needed), referee_candidates,
referees_full, can_manage_referees, and an optional next_url to return to
after a POST (defaults to the event detail page when blank).
The fee is entered right here, inline, rather than behind a "Fee" button
that opened a second (and on the dashboard, nested-inside-a-dialog) modal
-- that extra click-through was the easiest way for a fee to slip past
everyone's attention until PDF export time. Travel (km/rate) stays behind
a small disclosure since most assignments never need it, but posts in the
same form as the fee so there's still only one Save per referee.
{% endcomment %}
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">
<h2 class="card-title">
{% lucide "flag" size=18 %} {% trans "Referees" %}
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
{{ referees|length }} / {{ event.max_referees }}
@@ -17,40 +24,66 @@
<a class="btn btn-outline btn-sm gap-1" href="{% url 'management:event_referee_form_pdf' event.pk %}">{% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %}</a>
{% endif %}
</div>
<ul class="divide-y divide-base-200">
<ul class="divide-y">
{% for referee in referees %}
<li class="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span>
{{ referee.display_name }}
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
{% if referee.assigned_by %}<span class="text-xs opacity-60">{% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
{% if referee.total_payable %}<span class="text-xs opacity-60">{% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %}</span>{% endif %}
</span>
<li class="flex flex-wrap items-center justify-between gap-3 py-2.5">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-sm font-semibold text-ink">{{ referee.display_name }}</span>
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
{% if can_manage_referees and not referee.fee %}<span class="badge badge-warning badge-xs">{% trans "Fee not set" %}</span>{% endif %}
</div>
{% if referee.assigned_by %}<span class="text-xs text-muted">{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
</div>
{% if can_manage_referees %}
<div class="flex gap-1 shrink-0">
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ referee.pk|dom_id:"referee_fee_modal" }}').showModal()">{% lucide "euro" size=12 %} {% trans "Fee" %}</button>
<div class="flex shrink-0 items-center gap-1.5">
{% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %}
<form method="post" action="{{ fee_action_url }}?next={{ next_url|urlencode }}" class="flex items-center gap-1.5">
{% csrf_token %}
<label class="flex items-center gap-1">
<span class="font-mono text-xs text-dim">&euro;</span>
<input type="number" step="0.01" min="0" name="fee" value="{{ referee.fee }}" class="input w-20 text-right" aria-label="{% trans 'Fee' %}">
</label>
<details class="relative">
<summary class="btn btn-square btn-outline btn-xs cursor-pointer list-none" title="{% trans 'Travel reimbursement' %}">{% lucide "car" size=12 %}</summary>
<div class="absolute right-0 z-10 mt-1 flex w-44 flex-col gap-2 rounded-lg border border-line bg-white p-2.5 shadow-lg">
<label class="flex flex-col gap-0.5 text-xs text-muted">{% trans "Km" %}
<input type="number" step="0.1" min="0" name="km" value="{{ referee.km|default_if_none:'' }}" class="input">
</label>
<label class="flex flex-col gap-0.5 text-xs text-muted">{% trans "Rate/km" %}
<input type="number" step="any" min="0" name="km_rate" value="{{ referee.km_rate|default_if_none:'' }}" class="input">
</label>
</div>
</details>
<button class="btn btn-square btn-outline btn-xs" type="submit" title="{% trans 'Save fee' %}" aria-label="{% trans 'Save fee' %}">{% lucide "check" size=12 %}</button>
</form>
{% if referee.km %}
<span class="font-mono text-xs text-muted">{% blocktrans with total=referee.total_payable|floatformat:2 %}{{ total }} due{% endblocktrans %}</span>
{% endif %}
<form method="post" action="{% url 'management:event_referee_remove' event.pk referee.pk %}">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<button class="btn btn-xs btn-outline btn-error gap-1" type="submit">{% lucide "x" size=12 %} {% trans "Remove" %}</button>
<button class="btn btn-square btn-outline btn-error btn-xs" type="submit" title="{% trans 'Remove' %}" aria-label="{% trans 'Remove' %}">{% lucide "x" size=12 %}</button>
</form>
</div>
{% elif referee.total_payable %}
<span class="shrink-0 font-mono text-xs text-muted">{% blocktrans with total=referee.total_payable|floatformat:2 %}&euro;{{ total }} due{% endblocktrans %}</span>
{% endif %}
</li>
{% empty %}
<li class="py-2 text-sm opacity-60">{% trans "No referees assigned yet." %}</li>
<li class="py-2.5 text-sm text-muted">{% trans "No referees assigned yet." %}</li>
{% endfor %}
</ul>
{% if can_manage_referees %}
{% if referees_full %}
<p class="text-sm opacity-60 mt-2">{% trans "This game already has its maximum number of referees." %}</p>
{% else %}
{% if can_manage_referees and not referees_full %}
<div class="mt-3 flex flex-col gap-2 border-t border-rule pt-3">
<span class="font-mono text-[11px] tracking-[.08em] text-dim uppercase">{% trans "Add a referee" %}</span>
{% if referee_candidates %}
<form method="post" action="{% url 'management:event_referee_assign' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
<form method="post" action="{% url 'management:event_referee_assign' event.pk %}" class="flex flex-wrap items-center gap-2">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<select name="member" class="select select-bordered select-sm">
<select name="member" class="select w-auto">
{% for candidate in referee_candidates %}
<option value="{{ candidate.pk }}" {% if candidate.has_conflict %}title="{% blocktrans with events=candidate.conflict_titles %}Also expected at: {{ events }}{% endblocktrans %}"{% endif %}>
{{ candidate }}{% if candidate.has_conflict %} ⚠{% endif %}
@@ -59,32 +92,22 @@
</select>
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
</form>
<p class="text-xs opacity-60 mt-1">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
{% else %}
<p class="text-sm opacity-60 mt-2">
<p class="text-sm text-muted">
{% trans "No eligible referees for this team yet." %}
<a class="link" href="{% url 'management:referee_level_list' %}">{% trans "Link a referee level to this team." %}</a>
<a class="link link-hover" href="{% url 'management:referee_level_list' %}">{% trans "Link a referee level to this team." %}</a>
</p>
{% endif %}
<form method="post" action="{% url 'management:event_referee_add_external' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
<form method="post" action="{% url 'management:event_referee_add_external' event.pk %}" class="flex flex-wrap items-center gap-2">
{% csrf_token %}
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
<input type="text" name="name" class="input input-bordered input-sm" placeholder="{% trans 'External referee name' %}">
<input type="text" name="name" class="input w-56" placeholder="{% trans 'External referee name' %}">
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Add external" %}</button>
</form>
{% endif %}
{% endif %}
{% if can_manage_referees %}
{% trans "Referee fee" as fee_title %}
{% trans "Save" as save_label %}
{% with encoded_next=next_url|urlencode %}
{% for referee in referees %}
{% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %}
{% with fee_action_url=fee_action_url|add:"?next="|add:encoded_next %}
{% include "controlpanel/_modal_form.html" with modal_id=referee.pk|dom_id:"referee_fee_modal" title=fee_title form=referee.fee_form action_url=fee_action_url submit_label=save_label submit_icon="save" %}
{% endwith %}
{% endfor %}
{% endwith %}
<p class="text-xs text-muted">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
</div>
{% elif can_manage_referees and referees_full %}
<p class="mt-2 text-sm text-muted">{% trans "This game already has its maximum number of referees." %}</p>
{% endif %}

View File

@@ -5,28 +5,28 @@
row" button clones from -- so the two can never drift apart.
{% endcomment %}
<tr class="bulk-add-row">
<td>
<td class="py-2">
{{ form.member }}
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.member.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td>
<td class="py-2">
{{ form.position }}
{% for error in form.position.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.position.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td class="w-24">
<td class="w-24 py-2">
{{ form.jersey_number }}
{% for error in form.jersey_number.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.jersey_number.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td class="w-16 text-center">
<td class="w-16 py-2 text-center">
{{ form.is_captain }}
{% for error in form.is_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.is_captain.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td class="w-16 text-center">
<td class="w-16 py-2 text-center">
{{ form.is_alternate_captain }}
{% for error in form.is_alternate_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.is_alternate_captain.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</td>
<td class="w-12">
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
<td class="w-12 py-2">
<button class="btn btn-ghost btn-sm btn-square remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
</td>
</tr>

View File

@@ -1,90 +1,169 @@
{% extends "_club_base.html" %}
{% load i18n lucide %}
{% load static lucide ui i18n %}
{% comment %}
The club-staff shell: team managers, coaches and admins only (never parents or
players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's
block structure, extending the club's own skin instead of the platform's.
Standalone shell for club management -- does NOT extend templates/_base.html or
_club_base.html, and does not load assets/app.css or daisyUI. Same reasoning as
controlpanel/templates/controlpanel/base.html's own standalone shell, but this
surface IS club-branded (design_handoff_rosterchief_platform/README.md, "Club
theming"): the --tenant-club/--tenant-club-dark custom properties below feed
assets/management.css's --color-club/--color-club-dark tokens, set per request
from Club.secondary_color. Desktop only, matching the design's own scope for this
surface -- no mobile nav here, unlike the club-facing chrome this replaces.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block head_title %}
{% block section_title %}{% trans "Management" %}{% endblock section_title %}
{% endblock head_title %}
<title>
{% block title %}{% block panel_title %}Management{% endblock panel_title %} &middot; {{ club.name }}{% endblock title %}
</title>
{% block nav_toggle %}
<button class="btn btn-ghost btn-square lg:hidden" type="button" onclick="document.getElementById('mobile_nav_modal').showModal()" aria-label="{% trans 'Menu' %}">
{% lucide "menu" size=20 %}
</button>
{% endblock nav_toggle %}
<link rel="stylesheet" href="{% static 'css/management.css' %}">
{% if club.secondary_color %}
<style>
:root {
--tenant-club: {{ club.secondary_color }};
--tenant-club-dark: color-mix(in srgb, {{ club.secondary_color }} 80%, black);
{% comment %}
Club.secondary_content_color is already the WCAG-luminance black-or-white
choice (Club._content_color_for) -- e.g. the active nav item's own text,
which sits on --color-club and can't assume white reads on every colour.
{% endcomment %}
--tenant-club-content: {{ club.secondary_content_color }};
}
</style>
{% endif %}
{% if club.primary_color %}
<style>
:root {
{# Same WCAG black-or-white choice as secondary_content_color above, for the sidebar's own background. #}
--tenant-sidebar-bg: {{ club.primary_color }};
--tenant-sidebar-fg: {{ club.primary_content_color }};
}
</style>
{% endif %}
{% block extra_head %}{% endblock extra_head %}
</head>
{% 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 %}
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate">
<div class="flex flex-1 overflow-hidden">
<aside class="flex w-[236px] shrink-0 flex-col bg-sidebar-bg py-5">
<a class="flex items-center gap-2.5 px-[18px] pb-[22px]" href="{% url 'management:home' %}">
{% if club.logo %}
<img class="crest h-[30px] w-7 shrink-0 object-cover" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="crest flex h-[30px] w-7 shrink-0 items-center justify-center bg-club">
<span class="font-display text-[10px] font-extrabold text-club-content">{{ club.initials }}</span>
</span>
{% endif %}
<span class="min-w-0">
<span class="block truncate font-display text-[17px] leading-none font-extrabold tracking-[.06em] text-sidebar-fg uppercase">{{ club.name }}</span>
<span class="block font-mono text-[10px] text-sidebar-fg-faint">RosterChief &middot; management</span>
</span>
</a>
{% block menu %}
<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 "management/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
<nav class="flex flex-col gap-0.5 px-2.5">
{% include "management/_nav_items.html" %}
</nav>
{% 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">{% trans "Menu" %}</span>
<form method="dialog">
<button class="btn btn-ghost btn-square btn-sm" aria-label="{% trans 'Close' %}">{% lucide "x" size=18 %}</button>
</form>
<div class="flex-1"></div>
<div class="px-[18px]">
{% comment %}
<details>, not a click-driven dropdown -- no JS needed to open it, only
the outside-click/Escape listener below to close it (a <details> stays
open until its own <summary> is clicked again otherwise, which would be
the only sidebar element on the whole page that doesn't dismiss itself).
Opens upward (bottom-full): the trigger sits at the very bottom of the
sidebar, so a menu opening down would run off the viewport.
{% endcomment %}
<details id="user-menu" class="relative border-t border-sidebar-hairline pt-3.5">
<summary class="flex cursor-pointer list-none items-center gap-2.5 [&::-webkit-details-marker]:hidden">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-sidebar-chip font-display text-[13px] font-extrabold text-sidebar-fg">{% if user.member %}{{ user.member.first_name|slice:":1" }}{{ user.member.last_name|slice:":1" }}{% else %}?{% endif %}</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-semibold text-sidebar-fg">{{ user.member|default:user.email }}</span>
<span class="block text-[11px] text-sidebar-fg-faint">{% if is_club_admin %}Admin{% else %}Staff{% endif %}</span>
</span>
{% lucide "chevron-up" size=14 class="shrink-0 text-sidebar-fg-faint" %}
</summary>
<div class="absolute bottom-full left-0 z-20 mb-2 w-full rounded-xl border border-sidebar-hairline bg-sidebar-bg p-1.5 shadow-lg">
<a class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] font-medium text-sidebar-fg-dim hover:bg-sidebar-hairline hover:text-sidebar-fg" href="{% url 'account_change_password' %}">{% lucide "key-round" size=14 %} {% trans "Change password" %}</a>
<a class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] font-medium text-sidebar-fg-dim hover:bg-sidebar-hairline hover:text-sidebar-fg" href="{% url 'mfa_index' %}">{% lucide "shield-check" size=14 %} {% trans "Manage two-factor auth" %}</a>
<div class="my-1 h-px bg-sidebar-hairline"></div>
<a class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] font-medium text-sidebar-fg-dim hover:bg-sidebar-hairline hover:text-sidebar-fg" href="{% url 'account_logout' %}">{% lucide "log-out" size=14 %} {% trans "Log out" %}</a>
</div>
</details>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 shrink-0 items-center gap-4 border-b border-line bg-white px-7">
<span class="font-display text-2xl font-extrabold text-ink uppercase">{% block heading %}{% block topbar_title %}Management{% endblock topbar_title %}{% endblock heading %}</span>
{% block topbar_context %}{% endblock topbar_context %}
<div class="flex-1"></div>
<div class="flex items-center gap-2">
{% block actions %}{% endblock actions %}
</div>
</header>
{% block filter_strip %}{% endblock filter_strip %}
<main class="flex-1 overflow-y-auto">
<div class="flex flex-col gap-4 px-7 py-6">
{% if billing_notice.is_urgent and nav != "home" %}
{% include "management/_billing_notice.html" %}
{% endif %}
{% 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>
</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 %} {% trans "Light theme" %}</span>
<span data-theme-icon="dark" class="hidden items-center gap-3">{% lucide "moon" size=16 %} {% trans "Dark theme" %}</span>
<span data-theme-icon="auto" class="hidden items-center gap-3">{% lucide "sun-moon" size=16 %} {% trans "Auto theme" %}</span>
</button>
</li>
{% if has_management_access %}
<li><a href="{% url "management:home" %}">{% lucide "layout-dashboard" size=16 %} {% trans "Management" %}</a></li>
{% endif %}
{% if user.is_superuser %}
<li><a href="{% url "admin:index" %}">{% lucide "shield-cog" size=16 %} {% trans "Django admin" %}</a></li>
{% endif %}
<li class="menu-title">{% trans "Navigation" %}</li>
{% include "management/_nav_items.html" %}
</ul>
</div>
<form method="dialog" class="modal-backdrop">
<button>{% trans "close" %}</button>
</form>
</dialog>
{# flex-col below `sm`: title over a full-width, stacked column of action buttons reads far better on a phone than the same row wrapping mid-button. #}
<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-col gap-2 grow">
<h1 class="text-3xl font-bold">
{% block heading %}{% trans "Management" %}{% endblock heading %}
</h1>
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
</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>
<script>
(() => {
const menu = document.getElementById("user-menu");
if (!menu) return;
document.addEventListener("click", (event) => {
if (menu.open && !menu.contains(event.target)) menu.open = false;
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menu.open = false;
});
})();
{% comment %}
A final billing notice follows the admin onto every management page -- but only at
error level (inside 7 days of archiving, or already past it). Shown from the moment
anything is owed it would sit on every screen for weeks and train people to ignore
the one week it matters. home.html includes the same partial at every level, hence
the guard here rather than inside the partial.
{% endcomment %}
{% if billing_notice.is_urgent and nav != "home" %}
{% include "management/_billing_notice.html" %}
{% endif %}
// Search-as-you-type: any [data-autosubmit] input submits its own form a
// beat after typing stops, instead of needing a separate Search button.
// Debounced (not one submit per keystroke) so a full-page GET reload isn't
// fired on every character -- 400ms is short enough to feel immediate,
// long enough that normal typing speed never triggers it mid-word.
document.querySelectorAll("[data-autosubmit]").forEach((input) => {
let timeout;
input.addEventListener("input", () => {
clearTimeout(timeout);
timeout = setTimeout(() => input.form.requestSubmit(), 400);
});
});
</script>
{% block panel %}{% endblock panel %}
{% endblock main %}
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

View File

@@ -0,0 +1,76 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% comment %}
A club's own self-service identity/branding editor -- see
management/forms.py's ClubSettingsForm for exactly which fields are
editable here versus platform-staff-only (slug, seasons). D9 "Club
identity & branding" in the design handoff is the reference; its
live-preview and advanced (custom stylesheet / own domain) panels aren't
real features here, so this sticks to the fields the form actually has,
laid out in the same card-per-section language.
{% endcomment %}
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
{% block heading %}{% trans "Club identity" %}{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="submit" form="club-settings-form">{% lucide "save" size=16 %} {% trans "Save changes" %}</button>
{% endblock actions %}
{% block panel %}
<form id="club-settings-form" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error">
{% lucide "circle-x" size=18 %}
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-2 gap-5">
<div class="flex flex-col gap-5">
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="flex flex-col gap-4">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-center gap-3.5">
{% if club.logo %}
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
</div>
</div>
<div class="card self-start">
<div class="card-body">
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
<div class="grid grid-cols-2 gap-4">
{% form_field form.primary_color %}
{% form_field form.secondary_color %}
</div>
<div class="mt-1 flex items-center gap-2">
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.primary_color.value|default:'#fff' }}"></span>
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.secondary_color.value|default:'#fff' }}"></span>
<span class="font-mono text-xs text-muted">{% trans "Current colours" %}</span>
</div>
</div>
</div>
</div>
</form>
{% endblock panel %}

View File

@@ -2,7 +2,13 @@
{% load i18n lucide %}
{% block heading %}{{ event.title }}{% endblock heading %}
{% block subheading %}{{ event.get_kind_display }}{% endblock subheading %}
{% block topbar_context %}
<span class="badge {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
{{ event.get_kind_display }}
</span>
{% if event.is_live %}<span class="badge badge-error gap-1 animate-pulse">{% lucide "circle" size=10 %} {% trans "Live" %}</span>{% endif %}
{% endblock topbar_context %}
{% block actions %}
{% if can_manage %}
@@ -22,7 +28,7 @@
{% block panel %}
{% if event.series_id %}
<div class="alert alert-info mb-6">
<div class="alert alert-info">
{% lucide "repeat" size=20 %}
<span>
{% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %}
@@ -32,54 +38,54 @@
</div>
{% endif %}
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="grid gap-4 lg:grid-cols-2">
<div class="card">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Start" %}</dt>
<dd class="sm:text-right">{{ event.start|date:"j M Y H:i" }}</dd>
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Start" %}</dt>
<dd class="font-mono text-sm text-ink">{{ event.start|date:"j M Y H:i" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "End" %}</dt>
<dd class="sm:text-right">{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "End" %}</dt>
<dd class="font-mono text-sm text-ink">{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Gathering" %}</dt>
<dd class="sm:text-right">{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Gathering" %}</dt>
<dd class="font-mono text-sm text-ink">{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Registration deadline" %}</dt>
<dd class="sm:text-right">{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Registration deadline" %}</dt>
<dd class="font-mono text-sm text-ink">{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
<dd class="sm:text-right">{{ event.location|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Location" %}</dt>
<dd class="text-sm text-ink">{{ event.location|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
<dd class="sm:text-right">{{ event.opponent|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Opponent" %}</dt>
<dd class="text-sm text-ink">{{ event.opponent|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd class="sm:text-right">{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Teams" %}</dt>
<dd class="text-sm text-ink">{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Groups" %}</dt>
<dd class="sm:text-right">{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Groups" %}</dt>
<dd class="text-sm text-ink">{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
{% if event.club_wide %}
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Audience" %}</dt>
<dd class="sm:text-right"><span class="badge badge-primary">{% trans "Whole club" %}</span></dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Audience" %}</dt>
<dd><span class="badge badge-neutral">{% trans "Whole club" %}</span></dd>
</div>
{% endif %}
</dl>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} {% trans "RSVPs" %}</h2>
@@ -89,29 +95,26 @@
</button>
{% endif %}
</div>
<dl class="divide-y divide-base-200">
<dl>
{% for group in attendance_groups %}
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{{ group.label }}</dt>
<dd class="font-semibold tabular-nums font-mono sm:text-right">{{ group.rows|length }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{{ group.label }}</dt>
<dd class="font-mono text-sm font-semibold text-ink tabular-nums">{{ group.rows|length }}</dd>
</div>
{% endfor %}
</dl>
{% if not has_attendance_rows %}
<p class="text-sm opacity-60">{% trans "No one invited yet." %}</p>
<p class="text-sm text-muted">{% trans "No one invited yet." %}</p>
{% endif %}
</div>
</div>
</div>
{% if event.kind == "game" %}
<div class="mb-6 card bg-base-100 shadow">
<div class="card">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">
{% lucide "trophy" size=18 %} {% trans "Game" %}
{% if event.is_live %}<span class="badge badge-error gap-1 animate-pulse">{% lucide "circle" size=10 %} {% trans "Live" %}</span>{% endif %}
</h2>
<h2 class="card-title text-base">{% lucide "trophy" size=18 %} {% trans "Game" %}</h2>
{% if can_manage and event.competition %}
<form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}">
{% csrf_token %}
@@ -119,20 +122,20 @@
</form>
{% endif %}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="grid grid-cols-3 gap-4">
<div>
<div class="text-sm opacity-70">{% trans "Score" %}</div>
<div class="text-sm font-semibold tabular-nums font-mono">
<div class="text-sm text-muted">{% trans "Score" %}</div>
<div class="font-mono text-sm font-semibold tabular-nums text-ink">
{% if event.score_for is not None and event.score_against is not None %}{{ event.score_for }} - {{ event.score_against }}{% else %}—{% endif %}
</div>
</div>
<div>
<div class="text-sm opacity-70">{% trans "Competition" %}</div>
<div class="text-sm">{{ event.competition|default:"—" }}</div>
<div class="text-sm text-muted">{% trans "Competition" %}</div>
<div class="text-sm text-ink">{{ event.competition|default:"—" }}</div>
</div>
<div>
<div class="text-sm opacity-70">{% trans "External game ID" %}</div>
<div class="font-mono text-sm">{{ event.external_game_id|default:"—" }}</div>
<div class="text-sm text-muted">{% trans "External game ID" %}</div>
<div class="font-mono text-sm text-ink">{{ event.external_game_id|default:"—" }}</div>
</div>
</div>
</div>
@@ -140,14 +143,14 @@
{% endif %}
{% if event.is_home_game and not referee_management_needed %}
<div class="alert alert-info mb-6">
<div class="alert alert-info">
{% lucide "info" size=20 %}
<span>{% trans "Referees for this game are managed by the federation, not the club." %}</span>
</div>
{% endif %}
{% if referee_management_needed %}
<div class="mb-6 card bg-base-100 shadow">
<div class="card">
<div class="card-body">
{% include "management/_referee_assignment_panel.html" %}
</div>
@@ -159,19 +162,19 @@
{% if can_manage %}
<dialog id="event_delete_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
<h3 class="text-lg font-bold text-ink">
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
</h3>
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form">
{% csrf_token %}
{% if event.series_id %}
<p class="py-2 text-sm opacity-70">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="keep_record" class="checkbox">
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
<p class="py-2 text-sm text-muted">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p>
<label class="flex cursor-pointer items-start gap-2 text-sm text-slate">
<input type="checkbox" name="keep_record" class="checkbox mt-0.5">
<span>{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
</label>
{% else %}
<p class="py-2 text-sm opacity-70">{% trans "This cannot be undone." %}</p>
<p class="py-2 text-sm text-muted">{% trans "This cannot be undone." %}</p>
{% endif %}
</form>
<div class="modal-action">
@@ -190,37 +193,35 @@
{% if has_attendance_rows %}
<dialog id="rsvp_modal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">{% trans "Who responded" %}</h3>
<div class="mt-2 space-y-2">
<h3 class="text-lg font-bold text-ink">{% trans "Who responded" %}</h3>
<div class="mt-2 flex flex-col gap-2">
{% for group in attendance_groups %}
{% if group.rows %}
<details class="collapse collapse-arrow border border-base-300 bg-base-100">
<summary class="collapse-title text-sm font-medium">
<details class="collapse-arrow rounded-lg border border-line bg-white">
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-medium text-ink">
<span class="badge badge-sm
{% if group.value == "present" %}badge-success
{% elif group.value == "absent" %}badge-error
{% elif group.value == "excused" %}badge-warning
{% elif group.value == "selected" %}badge-info
{% elif group.value == "maybe" %}badge-warning badge-outline
{% elif group.value == "maybe" %}badge-warning
{% elif group.value == "not_selected" %}badge-neutral
{% else %}badge-outline{% endif %}">
{{ group.label }}
</span>
<span class="opacity-60">({{ group.rows|length }})</span>
<span class="text-muted">({{ group.rows|length }})</span>
</summary>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for row in group.rows %}
<tr>
<td>{{ row.member }}</td>
<td class="text-sm opacity-70">{{ row.note|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="overflow-x-auto border-t border-rule">
<table class="table">
<tbody>
{% for row in group.rows %}
<tr>
<td>{{ row.member }}</td>
<td class="text-sm text-muted">{{ row.note|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}

View File

@@ -4,48 +4,48 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card w-full">
<div class="card-body">
<form method="post">
{% 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.title %}
{% form_field form.kind %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.location %}
<div class="game-only">
{% form_field form.opponent %}
</div>
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "When" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "When" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
{% form_field form.start %}
{% form_field form.end %}
{% form_field form.gathering %}
@@ -53,9 +53,9 @@
</div>
<div id="game-details-section" class="game-only">
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Game" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{% form_field form.competition %}
{% form_field form.external_game_id %}
{% form_field form.max_referees %}
@@ -73,7 +73,7 @@
</div>
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="card-actions justify-start pt-4">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>

View File

@@ -9,74 +9,78 @@
{% endif %}
{% if can_create %}
<a class="btn btn-outline gap-2" href="{% url 'management:event_series_create' %}">{% lucide "repeat" size=16 %} {% trans "New series" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:event_create' %}">{% lucide "plus" size=16 %} {% trans "New event" %}</a>
<a class="btn btn-primary gap-2" href="{% url 'management:event_create' %}">{% lucide "plus" size=16 %} {% trans "New event" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row flex-wrap items-center gap-2">
<select name="season" class="select select-bordered">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="kind" class="select select-bordered">
<option value="">{% trans "All kinds" %}</option>
{% for value, label in event_kinds %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()">
<span class="label-text">{% trans "Show past events" %}</span>
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
</div>
{% block filter_strip %}
<form method="get" class="flex flex-wrap items-center gap-2 border-b border-line bg-white px-7 py-3">
<select name="season" class="select w-auto">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }}{{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="kind" class="select w-auto">
<option value="">{% trans "All kinds" %}</option>
{% for value, label in event_kinds %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate">
<input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()">
{% trans "Show past events" %}
</label>
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "filter" size=14 %} {% trans "View" %}</button>
</form>
{% endblock filter_strip %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
{% block panel %}
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Kind" %}</th>
<th>{% trans "When" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Location" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Kind" %}</th>
<th>{% trans "When" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Location" %}</th>
<th></th>
<td>
<a class="link link-hover font-semibold text-ink" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
{% if event.series_id %}
<span class="ml-1 inline-flex align-middle text-dim" title="{% trans 'Part of a series' %}" aria-label="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
{{ event.get_kind_display }}
</span>
</td>
<td class="font-mono text-xs text-ink">{{ event.start|date:"D j M Y H:i" }}</td>
<td>{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}<span class="text-dim"></span>{% endfor %}</td>
<td>{{ event.location.name|default:"—" }}</td>
<td class="text-right">
{% if event.can_manage %}
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ event.pk|dom_id:"event_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td class="flex flex-row items-center gap-2 font-semibold">
<a class="link link-hover" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
{% if event.series_id %}<span class="tooltip" data-tip="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>{% endif %}
</td>
<td data-label="{% trans 'Kind' %}">{{ event.get_kind_display }}</td>
<td data-label="{% trans 'When' %}">{{ event.start|date:"j M Y H:i" }}</td>
<td data-label="{% trans 'Teams' %}">{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td data-label="{% trans 'Location' %}">{{ event.location.name|default:"—" }}</td>
<td class="text-right">
{% if event.can_manage %}
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:event_detail' event.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ event.pk|dom_id:"event_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No events." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="6" class="py-8 text-center text-muted">{% trans "No events." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
@@ -87,21 +91,21 @@
{% if event.can_manage %}
<dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
<h3 class="text-lg font-bold text-ink">
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
</h3>
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}">
{% csrf_token %}
{% if event.series_id %}
{% blocktrans with name=event.title asvar delete_body %}Removes “{{ name }}” from the schedule. It won't be regenerated.{% endblocktrans %}
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="keep_record" class="checkbox">
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
<p class="py-2 text-sm text-muted">{{ delete_body }}</p>
<label class="flex cursor-pointer items-start gap-2 text-sm text-slate">
<input type="checkbox" name="keep_record" class="checkbox mt-0.5">
<span>{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
</label>
{% else %}
{% blocktrans with name=event.title asvar delete_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
<p class="py-2 text-sm opacity-70">{{ delete_body }}</p>
<p class="py-2 text-sm text-muted">{{ delete_body }}</p>
{% endif %}
</form>
<div class="modal-action">

View File

@@ -2,7 +2,13 @@
{% load i18n lucide ui %}
{% block heading %}{{ series.title }}{% endblock heading %}
{% block subheading %}{{ series.get_kind_display }} · {{ recurrence_summary }}{% endblock subheading %}
{% block topbar_context %}
<span class="badge {% if series.kind == "game" %}badge-error{% elif series.kind == "training" %}badge-info{% elif series.kind == "tournament" %}badge-warning{% elif series.kind == "meeting" %}badge-neutral{% elif series.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
{{ series.get_kind_display }}
</span>
<span class="font-mono text-xs text-muted">{{ recurrence_summary }}</span>
{% endblock topbar_context %}
{% block actions %}
{% if can_manage %}
@@ -17,57 +23,57 @@
{% endblock actions %}
{% block panel %}
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="grid gap-4 lg:grid-cols-2">
<div class="card">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Pattern" %}</dt>
<dd class="sm:text-right">{{ recurrence_summary }}</dd>
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Pattern" %}</dt>
<dd class="text-sm text-ink">{{ recurrence_summary }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "First occurrence" %}</dt>
<dd class="sm:text-right">{{ series.dtstart|date:"j M Y H:i" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "First occurrence" %}</dt>
<dd class="font-mono text-sm text-ink">{{ series.dtstart|date:"j M Y H:i" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Repeats until" %}</dt>
<dd class="sm:text-right">{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Repeats until" %}</dt>
<dd class="font-mono text-sm text-ink">{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Generated up to" %}</dt>
<dd class="sm:text-right">{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Generated up to" %}</dt>
<dd class="font-mono text-sm text-ink">{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
<dd class="sm:text-right">{{ series.location|default:"—" }}</dd>
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Location" %}</dt>
<dd class="text-sm text-ink">{{ series.location|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
<dd class="sm:text-right">{{ series.opponent|default:"—" }}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Opponent" %}</dt>
<dd class="text-sm text-ink">{{ series.opponent|default:"—" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd class="sm:text-right">{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Teams" %}</dt>
<dd class="text-sm text-ink">{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2>
<div class="overflow-x-auto">
<table class="table table-cards">
<table class="table">
<thead>
<tr>
<th>{% trans "When" %}</th>
@@ -77,8 +83,8 @@
<tbody>
{% for occurrence in occurrences %}
<tr>
<td class="font-semibold"><a class="link link-hover" href="{% url 'management:event_detail' occurrence.pk %}">{{ occurrence.start|date:"j M Y H:i" }}</a></td>
<td data-label="{% trans 'Status' %}">
<td class="font-mono text-sm font-semibold"><a class="link link-hover" href="{% url 'management:event_detail' occurrence.pk %}">{{ occurrence.start|date:"j M Y H:i" }}</a></td>
<td>
{% if occurrence.cancelled %}
<span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span>
{% elif occurrence.detached %}
@@ -92,7 +98,7 @@
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No occurrences generated yet." %}</td>
<td colspan="2" class="py-8 text-center text-muted">{% trans "No occurrences generated yet." %}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -4,25 +4,25 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card w-full">
<div class="card-body">
<form method="post">
{% 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.title %}
{% form_field form.kind %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Repeats" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Repeats" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{% form_field form.frequency %}
{% form_field form.interval %}
{% form_field form.dtstart %}
@@ -30,44 +30,44 @@
</div>
{% trans "Only used for a weekly pattern -- a monthly one repeats on the same day of the month as the first occurrence above." as weekdays_help %}
{% form_field form.weekdays help_text=weekdays_help %}
<details class="collapse collapse-arrow bg-base-200 mt-2">
<summary class="collapse-title text-sm font-medium">{% trans "Advanced: raw recurrence rule" %}</summary>
<div class="collapse-content">
<details class="collapse-arrow mt-3 rounded-lg border border-line bg-white">
<summary class="cursor-pointer px-3 py-2 text-sm font-medium text-ink">{% trans "Advanced: raw recurrence rule" %}</summary>
<div class="border-t border-rule p-3">
{% form_field form.advanced_rrule %}
</div>
</details>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Where" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.location %}
{% form_field form.opponent %}
</div>
<div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Timing" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="my-5 border-t border-line"></div>
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Timing" %}</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
{% form_field form.duration_hours %}
{% form_field form.duration_minutes %}
{% form_field form.gathering_minutes_before %}
{% form_field form.deadline_minutes_before %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="card-actions justify-start pt-4">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_series_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>

View File

@@ -4,28 +4,26 @@
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
{% block actions %}
{% if is_club_admin %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
{% endif %}
{% if is_club_admin %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('add_parent_modal').showModal()">{% lucide "user-plus" size=16 %} {{ add_parent_label }}</button>
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('add_child_modal').showModal()">{% lucide "baby" size=16 %} {{ add_child_label }}</button>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="card card-body">
{% include "management/_family_members_table.html" with group=group %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family.pk as add_parent_url %}
{% url 'management:family_add_child' family.pk as add_child_url %}
{% trans "Add parent" as add_parent_title %}
{% trans "Add child" as add_child_title %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
{% endif %}
{% if is_club_admin %}
{% url 'management:family_add_parent' family.pk as add_parent_url %}
{% url 'management:family_add_child' family.pk as add_child_url %}
{% trans "Add parent" as add_parent_title %}
{% trans "Add child" as add_child_title %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
{% endif %}
{% endblock panel %}

View File

@@ -4,41 +4,39 @@
{% block heading %}{% trans "Add family" %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="card card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2>
<p class="text-sm opacity-70">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
{% form_field form.parent_email %}
{% form_field form.parent_is_member %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="divider"></div>
<h2 class="card-title">{% lucide "user" size=18 %} {% trans "Parent / guardian" %}</h2>
<p class="text-sm text-muted">{% trans "Gets a login -- they set a password via the reset link if this is a new email." %}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.parent_first_name %}
{% form_field form.parent_last_name %}
{% form_field form.parent_email %}
{% form_field form.parent_is_member %}
</div>
<h2 class="card-title text-base">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
<p class="text-sm opacity-70">{% trans "No login of their own -- the parent above manages things for them." %}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
{% form_field form.child_date_of_birth %}
</div>
<div class="my-2 h-px bg-rule"></div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
<h2 class="card-title">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
<p class="text-sm text-muted">{% trans "No login of their own -- the parent above manages things for them." %}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.child_first_name %}
{% form_field form.child_last_name %}
{% form_field form.child_date_of_birth %}
</div>
<div class="mt-2 flex items-center gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
{% endblock panel %}

View File

@@ -1,72 +1,75 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Families" %}{% endblock heading %}
{% block subheading %}{% trans "Every household on file -- who their parents/guardians and children are." %}{% endblock subheading %}
{% block heading %}{% trans "Households" %}{% endblock heading %}
{% block topbar_context %}
<span class="font-mono text-[13px] text-muted">{% trans "Every household on file." %}</span>
{% endblock topbar_context %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-primary gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-2 flex items-center gap-2">
<label class="input grow sm:grow-0">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search by parent or child name ...' %}" class="input input-bordered w-full sm:max-w-xs">
</label>
{# Icon-only below `sm`: label text next to a growing input is what forced this row to wrap on a phone. #}
<button class="btn btn-outline gap-2" type="submit" aria-label="{% trans 'Search' %}">{% lucide "search" size=16 %}<span class="hidden sm:inline">{% trans "Search" %}</span></button>
{% if search %}
<a class="btn gap-2" href="{% url "management:family_list" %}" aria-label="{% trans 'Clear filter' %}">{% lucide "x" size=16 %}<span class="hidden sm:inline">{% trans "Clear filter" %}</span></a>
{% endif %}
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Family" %}</th>
<th>{% trans "Parents / guardians" %}</th>
<th>{% trans "Children" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for family in families %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:family_detail' family.pk %}">{{ family }}</a></td>
<td data-label="{% trans 'Parents / guardians' %}">
{% for guardian in family.guardians_display %}
<a class="link link-hover" href="{% url 'management:member_detail' guardian.pk %}">{{ guardian }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="opacity-40">-</span>
{% endfor %}
</td>
<td data-label="{% trans 'Children' %}">
{% for child in family.children_display %}
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="opacity-40">-</span>
{% endfor %}
</td>
<td class="text-right">
{# Same convention as Groups: "Edit" lands on the overview page, not a standalone rename form -- family_detail is where every family action (add parent/child, change role, remove) actually lives. #}
<a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' family.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">{% trans "No families yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% block filter_strip %}
<div class="flex h-[54px] shrink-0 items-center gap-2 border-b border-line bg-white px-7">
<form method="get" class="flex items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=14 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search by parent or child name ...' %}" class="input w-72 pl-9">
</div>
</div>
{# No btn-sm: .input is height:2.25rem, matching plain .btn -- btn-sm would sit 6px shorter and misalign in the row. #}
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=14 %} {% trans "Search" %}</button>
{% if search %}
<a class="btn btn-outline gap-2" href="{% url "management:family_list" %}">{% lucide "x" size=14 %} {% trans "Clear" %}</a>
{% endif %}
</form>
</div>
{% endblock filter_strip %}
{% block panel %}
<div class="card overflow-hidden">
<table class="table">
<thead>
<tr>
<th>{% trans "Family" %}</th>
<th>{% trans "Parents / guardians" %}</th>
<th>{% trans "Children" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for family in families %}
<tr>
<td><a class="link link-hover font-semibold text-ink" href="{% url 'management:family_detail' family.pk %}">{{ family }}</a></td>
<td class="text-sm">
{% for guardian in family.guardians_display %}
<a class="link link-hover" href="{% url 'management:member_detail' guardian.pk %}">{{ guardian }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="text-dim"></span>
{% endfor %}
</td>
<td class="text-sm">
{% for child in family.children_display %}
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<span class="text-dim"></span>
{% endfor %}
</td>
<td class="text-right">
{# Same convention as Groups: "Edit" lands on the overview page, not a standalone rename form -- family_detail is where every family action (add parent/child, change role, remove) actually lives. #}
<a class="btn btn-outline btn-xs" href="{% url 'management:family_detail' family.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=13 %} {% trans "Edit" %}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="py-6 text-center text-muted">{% trans "No families yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "management/_pagination.html" %}

View File

@@ -2,51 +2,49 @@
{% load i18n lucide static %}
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
{% block subheading %}{{ group.name }}{% endblock subheading %}
{% block topbar_context %}
<span class="font-mono text-[13px] text-muted">{{ group.name }}</span>
{% endblock topbar_context %}
{% block panel %}
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
<div class="card bg-base-100 shadow">
<div class="card-body">
{% for error in formset.non_form_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% comment %}
overflow-x-auto is safe here: the member picker's dropdown
(searchable-select.js) is position: fixed, computed from the input's
own screen position rather than document flow, so a scrolling
ancestor around the table no longer clips it.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for form in formset %}
{% include "management/_group_bulk_add_row.html" with form=form %}
{% endfor %}
</tbody>
</table>
<div class="card card-body">
{% for error in formset.non_form_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<template id="bulk-add-row-template">
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
</template>
{% comment %}
The member picker's dropdown (searchable-select.js) is position: fixed,
computed from the input's own screen position rather than document flow,
so no ancestor scroll wrapper is needed to keep it from being clipped.
{% endcomment %}
<table class="table w-full">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for form in formset %}
{% include "management/_group_bulk_add_row.html" with form=form %}
{% endfor %}
</tbody>
</table>
<div class="flex flex-wrap items-center gap-2 mt-2">
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
</div>
<template id="bulk-add-row-template">
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
</template>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
</div>
</div>

View File

@@ -8,37 +8,33 @@
{% endblock actions %}
{% block panel %}
<div class="card 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">{% trans "Members" %}</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:group_bulk_add' group.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
</div>
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td class="font-semibold"><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member }}</a></td>
<td class="text-right">
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_member_modal" }}').showModal()">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title">{% trans "Members" %}</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:group_bulk_add' group.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
</div>
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td class="font-semibold text-ink"><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member }}</a></td>
<td class="text-right">
<button class="btn btn-xs btn-outline btn-error gap-1" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_member_modal" }}').showModal()">{% lucide "user-minus" size=13 %} {% trans "Remove" %}</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="py-6 text-center text-muted">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% trans "Remove from group" as remove_member_title %}

View File

@@ -4,29 +4,27 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New group" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="card card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:group_detail" object.pk %}{% else %}{% url "management:group_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
<div class="mt-2 flex items-center gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:group_detail" object.pk %}{% else %}{% url "management:group_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
{% endblock panel %}

View File

@@ -2,45 +2,44 @@
{% load i18n lucide ui %}
{% block heading %}{% trans "Groups" %}{% endblock heading %}
{% block subheading %}{% trans "Named collections of members -- coaches, team managers, referee pools, committees..." %}{% endblock subheading %}
{% block topbar_context %}
<span class="font-mono text-[13px] text-muted">{% trans "Named collections of members -- coaches, team managers, referee pools, committees..." %}</span>
{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:group_create' %}">{% lucide "plus" size=16 %} {% trans "New group" %}</a>
<a class="btn btn-primary gap-2" href="{% url 'management:group_create' %}">{% lucide "plus" size=16 %} {% trans "New group" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Members" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
<td data-label="{% trans 'Members' %}">{{ group.member_count }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:group_detail' group.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ group.pk|dom_id:"group_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No groups yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card overflow-hidden">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Members" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td><a class="link link-hover font-semibold text-ink" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
<td class="font-mono text-sm">{{ group.member_count }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-xs" href="{% url 'management:group_detail' group.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=13 %} {% trans "Edit" %}</a>
<button class="btn btn-xs btn-outline btn-error" type="button" onclick="document.getElementById('{{ group.pk|dom_id:"group_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=13 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="py-6 text-center text-muted">{% trans "No groups yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "management/_pagination.html" %}

View File

@@ -1,24 +1,32 @@
{% extends "management/base.html" %}
{% load i18n static lucide %}
{% block panel_title %}Overview{% endblock panel_title %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}{% trans "Management" %}{% endblock subheading %}
{% 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 mb-6">
<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>
<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 mb-6">
<div class="alert alert-warning">
{% lucide "calendar-clock" size=20 %}
<span>
{% if billing_auto_renews %}
@@ -30,167 +38,198 @@
</div>
{% endif %}
{% comment %}
The club's own numbers that should be zero -- same attention/chart/stat-group
data controlpanel/club_detail.html shows a platform admin drilling into this
club from outside; club_attention/club_charts/club_statistics are already
club-scoped, so this is that same data for the club's own staff. Nothing
money-shaped renders below for non-admins, same line the nav already draws
around the Shop section.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 md:grid-cols-5">
<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 %} {% trans "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">{% trans "Teams nobody can pick a squad for" %}</div>
<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 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 %} {% trans "Unrostered members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">{% trans "Active members 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 "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 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 %} {% trans "Pending" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">{% trans "Memberships awaiting approval" %}</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 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 %} {% trans "New members" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.new_members }}</div>
<div class="text-xs opacity-60">{% 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 "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 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 %} {% trans "Attendance rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attention.attendance.turnout is None %}
{% trans "N/A" %}
{% else %}
{{ attention.attendance.turnout }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attention.attendance.turnout is None %}
{% trans "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>
<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="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card 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 "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
<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>
<ul class="divide-y divide-base-200">
{% for event in upcoming_events %}
<li class="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div class="min-w-0">
<div class="truncate font-semibold">{{ event.title }}</div>
<div class="truncate text-xs opacity-60">
{{ event.get_kind_display }}
{% for team in event.teams.all %}&middot; {{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
</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>
<div class="shrink-0 whitespace-nowrap text-sm opacity-70">{{ event.start|date:"D j M, H:i" }}</div>
</li>
{% empty %}
<li class="py-2 text-center text-sm opacity-60">{% trans "Nothing scheduled." %}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="card 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 "newspaper" size=18 %} {% trans "News" %}</h2>
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:news_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
</div>
<ul class="divide-y divide-base-200">
{% for news_item in published_news %}
<li class="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<a class="link link-hover truncate font-semibold" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a>
<span class="shrink-0 whitespace-nowrap text-sm opacity-70">{{ news_item.published_at|date:"D j M" }}</span>
</li>
{% empty %}
<li class="py-2 text-center text-sm opacity-60">{% trans "Nothing published yet." %}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% if is_club_admin %}
{# Charts are desktop-only: three squeezed canvases stacked on a phone add scroll without adding much readability -- the same numbers already sit in the stat cards above. #}
<div class="mb-6 hidden gap-4 lg:grid lg:grid-cols-3">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} {% trans "Signups per month" %}</h2>
<p class="text-sm opacity-70">{% trans "New members against returning ones." %}</p>
<div class="h-56">
<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 "wallet" size=18 %} {% trans "Club fee status this season" %}</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Renewal rate" %}</h2>
{% if attention.renewal_rate is None %}
<p class="text-sm opacity-70">{% trans "No previous season to compare." %}</p>
{% else %}
<div class="h-56">
<canvas id="renewal-chart"></canvas>
<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">
<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>
{% endif %}
</div>
<div class="grid gap-4 {% if is_club_admin %}md:grid-cols-4{% else %}md:grid-cols-3{% endif %}">
<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 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}&euro;{% endif %}{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
<div 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 %}
@@ -209,96 +248,61 @@
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
const renewalRate = JSON.parse(document.getElementById("renewal-rate-data").textContent);
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
// 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");
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
const charts = [];
// Both canvases are admin-only -- absent entirely for a manager/coach,
// same gate as the Shop nav section and the rest of this row.
if (signupsCanvas) {
// 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.
charts.push(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: css("--color-primary", "#4f46e5")},
{label: "{{ returning_label|escapejs }}", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
],
},
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}},
},
},
}));
}
if (feesCanvas) {
// 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.
charts.push(new Chart(feesCanvas, {
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}}},
},
}));
}
if (renewalCanvas && renewalRate !== null) {
// Same "colour carries the meaning" reasoning as the fee chart: a low
// renewal rate reads as a problem, so it's pinned to error, not neutral.
charts.push(new Chart(renewalCanvas, {
type: "pie",
data: {
labels: ["{{ renewed_label|escapejs }}", "{{ not_renewed_label|escapejs }}"],
datasets: [
{
data: [renewalRate, 100 - renewalRate],
backgroundColor: [css("--color-success", "#16a34a"), css("--color-error", "#dc2626")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: "right", labels: {color: ink}}},
},
}));
}
return charts;
};
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"]});
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 %}

View File

@@ -4,24 +4,24 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New location" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card w-full">
<div class="card-body">
<form method="post">
{% 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">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="card-actions justify-start pt-4">
<a class="btn btn-outline gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>

View File

@@ -8,44 +8,42 @@
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "City" %}</th>
<th>{% trans "Country" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for location in locations %}
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "City" %}</th>
<th>{% trans "Country" %}</th>
<th></th>
<td class="font-semibold text-ink">
{{ location.name }}
{% if location.is_home %}<span class="badge badge-sm badge-neutral ml-1">{% trans "Home" %}</span>{% endif %}
</td>
<td>{{ location.address }}</td>
<td>{{ location.city }}</td>
<td>{{ location.country }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
</thead>
<tbody>
{% for location in locations %}
<tr>
<td class="font-semibold">
{{ location.name }}
{% if location.is_home %}<span class="badge badge-sm badge-primary ml-1">{% trans "Home" %}</span>{% endif %}
</td>
<td data-label="{% trans 'Address' %}">{{ location.address }}</td>
<td data-label="{% trans 'City' %}">{{ location.city }}</td>
<td data-label="{% trans 'Country' %}">{{ location.country }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No locations yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="5" class="py-8 text-center text-muted">{% trans "No locations yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@@ -2,7 +2,19 @@
{% load lucide ui i18n static %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
{% block topbar_context %}
{% if member.contact_email %}<span class="font-mono text-[13px] text-muted">{{ member.contact_email }}</span>{% endif %}
{% if current_membership %}
<span class="badge
{% if current_membership.status == "active" %}badge-success
{% elif current_membership.status == "pending" %}badge-warning
{% elif current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ current_membership.get_status_display }}
</span>
{% endif %}
{% endblock topbar_context %}
{% block actions %}
{% if is_club_admin %}
@@ -12,63 +24,60 @@
{% block panel %}
<div class="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" size=18 %} {% trans "Personal information" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
<dd class="font-semibold sm:text-right">{{ member.date_of_birth|default:"-" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Phone" %}</dt>
<dd class="font-semibold sm:text-right">{{ member.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt>
<dd class="font-semibold sm:text-right">{{ member.emergency_phone.as_international|default:"-" }}</dd>
</div>
{% for guardian in guardians %}
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Parent phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-semibold sm:text-right">{{ guardian.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Parent emergency phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-semibold sm:text-right">{{ guardian.emergency_phone.as_international|default:"-" }}</dd>
</div>
{% endfor %}
</dl>
{# flex-col below `sm`: a button's own label never wraps, so two grow buttons side by side in a fixed row push wider than the screen once either label is long -- stacking avoids that regardless of label length. #}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center mt-2">
{% if member.phone %}
<a class="btn btn-sm btn-outline btn-primary sm:grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error sm:grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
{% endif %}
<div class="card card-body">
<h2 class="card-title">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Date of birth" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ member.date_of_birth|default:"—" }}</dd>
</div>
{% if guardians %}
<div class="divider my-1"></div>
<div class="text-xs font-semibold uppercase opacity-60 flex items-center gap-1 mb-1">
{% lucide "users" size=12 %} {% trans "Parent/guardian contact" %}
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Phone" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ member.phone.as_international|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Emergency phone" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ member.emergency_phone.as_international|default:"—" }}</dd>
</div>
{% for guardian in guardians %}
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Parent phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ guardian.phone.as_international|default:"—" }}</dd>
</div>
{% for guardian in guardians %}
{% if guardian.phone or guardian.emergency_phone %}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center mt-2">
{% if guardian.phone %}
<a class="btn btn-sm btn-outline btn-primary sm:grow" href="tel:{{ guardian.phone.as_international }}">{% lucide "phone" size=14 %} {% blocktrans with name=guardian.get_full_name %}Call {{ name }}{% endblocktrans %}</a>
{% endif %}
{% if guardian.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error sm:grow" href="tel:{{ guardian.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% blocktrans with name=guardian.get_full_name %}Emergency: {{ name }}{% endblocktrans %}</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Parent emergency phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ guardian.emergency_phone.as_international|default:"—" }}</dd>
</div>
{% endfor %}
</dl>
<div class="mt-2 flex flex-wrap items-center gap-2">
{% if member.phone %}
<a class="btn btn-outline btn-info btn-sm gap-2 flex-1" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn btn-outline btn-error btn-sm gap-2 flex-1" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
{% endif %}
</div>
{% if guardians %}
<div class="my-1 h-px bg-rule"></div>
<div class="mb-1 flex items-center gap-1.5 font-display text-xs font-bold tracking-[.1em] text-muted uppercase">
{% lucide "users" size=12 %} {% trans "Parent/guardian contact" %}
</div>
{% for guardian in guardians %}
{% if guardian.phone or guardian.emergency_phone %}
<div class="mt-2 flex flex-wrap items-center gap-2">
{% if guardian.phone %}
<a class="btn btn-outline btn-info btn-sm gap-2 flex-1" href="tel:{{ guardian.phone.as_international }}">{% lucide "phone" size=14 %} {% blocktrans with name=guardian.get_full_name %}Call {{ name }}{% endblocktrans %}</a>
{% endif %}
{% if guardian.emergency_phone %}
<a class="btn btn-outline btn-error btn-sm gap-2 flex-1" href="tel:{{ guardian.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% blocktrans with name=guardian.get_full_name %}Emergency: {{ name }}{% endblocktrans %}</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% comment %}
@@ -76,126 +85,189 @@
actually ties this person to *this* club, so it gets its own panel: the
current season's standing, plus every season they've been part of.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
{% if current_membership.is_guardian %}
{% comment %}
Stated plainly rather than left to be inferred from an empty
fee: a guardian is deliberately missing from the member list
and every member count, and someone looking at this page needs
to know that is on purpose. See club.models.ClubMembership.Kind.
{% endcomment %}
<div class="alert alert-warning py-2 my-1">
{% lucide "users" size=16 %}
<span class="text-sm">{% trans "Guardian: attached to the club as a parent of a member. Holds the login, owes no fee, and is not counted as a member." %}</span>
</div>
{% endif %}
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Joined as" %}</dt>
<dd class="font-semibold sm:text-right">{{ current_membership.get_kind_display|capfirst }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
<dd class="font-semibold sm:text-right">{{ current_membership.license|default:"-" }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
<dd class="font-semibold sm:text-right">{{ current_membership.get_status_display }}</dd>
</div>
{% if not current_membership.is_guardian %}
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold sm:text-right">{{ current_membership.get_fee_status_display }}</dd>
</div>
{% endif %}
</dl>
{% else %}
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>
{% endif %}
{% if membership_history %}
<div class="divider"></div>
<h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3>
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Season" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in membership_history %}
<tr>
<td class="font-semibold">{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
<td data-label="{% trans 'Status' %}">{{ membership.get_status_display }}</td>
<td data-label="{% trans 'Fee status' %}">{{ membership.get_fee_status_display }}</td>
<td data-label="{% trans 'License' %}">{{ membership.license|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card card-body">
<h2 class="card-title">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
{% if current_membership.is_guardian %}
{% comment %}
Stated plainly rather than left to be inferred from an empty
fee: a guardian is deliberately missing from the member list
and every member count, and someone looking at this page needs
to know that is on purpose. See club.models.ClubMembership.Kind.
{% endcomment %}
<div class="alert alert-warning">
{% lucide "users" size=16 %}
<span>{% trans "Guardian: attached to the club as a parent of a member. Holds the login, owes no fee, and is not counted as a member." %}</span>
</div>
{% endif %}
</div>
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Joined as" %}</dt>
<dd class="text-sm font-semibold text-ink">{{ current_membership.get_kind_display|capfirst }}</dd>
</div>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "License" %}</dt>
<dd class="font-mono text-sm font-semibold text-ink">{{ current_membership.license|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Status" %}</dt>
<dd>
<span class="badge badge-sm
{% if current_membership.status == "active" %}badge-success
{% elif current_membership.status == "pending" %}badge-warning
{% elif current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ current_membership.get_status_display }}
</span>
</dd>
</div>
{% if not current_membership.is_guardian %}
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Fee status" %}</dt>
<dd>
<span class="badge badge-sm
{% if current_membership.fee_status == "paid" %}badge-success
{% elif current_membership.fee_status == "partially_paid" %}badge-warning
{% elif current_membership.fee_status == "unpaid" %}badge-error
{% else %}badge-ghost{% endif %}">
{{ current_membership.get_fee_status_display }}
</span>
</dd>
</div>
{% endif %}
</dl>
{% else %}
<p class="text-sm text-muted">{% trans "Not rostered for the current season." %}</p>
{% endif %}
{% if membership_history %}
<div class="my-1 h-px bg-rule"></div>
<h3 class="font-display text-xs font-bold tracking-[.1em] text-muted uppercase">{% trans "Season history" %}</h3>
<table class="table">
<thead>
<tr>
<th>{% trans "Season" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in membership_history %}
<tr>
<td class="font-semibold">{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
<td>{{ membership.get_status_display }}</td>
<td>{{ membership.get_fee_status_display }}</td>
<td class="font-mono text-xs">{{ membership.license|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% if referee_profile or is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
{% if is_club_admin %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('referee_eligibility_modal').showModal()">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
{% endif %}
</div>
{% if referee_profile and referee_profile.level %}
<dl class="divide-y divide-base-200">
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Level" %}</dt>
<dd class="font-semibold sm:text-right">{{ referee_profile.level }}</dd>
</div>
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<dt class="text-sm opacity-70">{% trans "Valid until" %}</dt>
<dd class="font-semibold flex items-center gap-2">
{{ referee_profile.valid_until|default:"—" }}
{% if referee_profile.is_currently_valid %}
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
{% elif referee_profile.valid_until %}
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
{% comment %}
Onboarding checklist -- club.services.onboarding.checklist_for, paired with
MemberRequirementStatus if one exists. Any staff can mark an item done or
reopen it, not just admins (same visibility as the rest of this page) --
see the module docstring on club/services/onboarding.py for why this stays
separate from ClubMembership.status/fee_status.
{% endcomment %}
<div class="card card-body mt-4">
<h2 class="card-title">{% lucide "clipboard-check" size=18 %} {% trans "Documents" %}</h2>
{% if not current_membership %}
<p class="text-sm text-muted">{% trans "Not rostered for the current season -- nothing to check off yet." %}</p>
{% elif not checklist %}
<p class="text-sm text-muted">{% trans "This club has no onboarding requirements set up." %}</p>
{% else %}
<div class="flex flex-col gap-1">
{% for requirement, status in checklist %}
<div class="flex items-start justify-between gap-4 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold text-ink">{{ requirement.name }}</span>
{% if status.is_complete %}
<span class="badge badge-sm badge-success">{% trans "Complete" %}</span>
{% elif status %}
<span class="badge badge-sm badge-warning">{% trans "Incomplete" %}</span>
{% else %}
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
<span class="badge badge-sm badge-ghost">{% trans "Not started" %}</span>
{% endif %}
</dd>
</div>
{% if requirement.description %}<p class="mt-1 text-sm text-muted">{{ requirement.description }}</p>{% endif %}
{% if status.is_complete %}
<p class="mt-1 font-mono text-xs text-dim">{% blocktrans with who=status.completed_by when=status.completed_at|date:"j M Y H:i" %}Completed by {{ who }} on {{ when }}{% endblocktrans %}</p>
{% endif %}
{% if status.note %}<p class="mt-1 text-sm text-slate italic">&ldquo;{{ status.note }}&rdquo;</p>{% endif %}
{% if status.document %}
<a class="mt-1 inline-flex items-center gap-1 text-sm link link-hover" href="{% url 'management:member_requirement_document' pk=member.pk requirement_pk=requirement.pk %}">{% lucide "download" size=13 %} {% trans "Download document" %}</a>
{% endif %}
</div>
</dl>
<div class="flex shrink-0 items-center gap-1.5">
{% if status.is_complete %}
<form method="post" action="{% url 'management:member_requirement_incomplete' pk=member.pk requirement_pk=requirement.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-xs gap-1" type="submit">{% lucide "rotate-ccw" size=12 %} {% trans "Reopen" %}</button>
</form>
{% else %}
<button class="btn btn-primary btn-xs gap-1" type="button" onclick="document.getElementById('{{ requirement.pk|dom_id:"requirement_complete_modal" }}').showModal()">{% lucide "check" size=12 %} {% trans "Mark complete" %}</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if referee_profile.is_eligible %}
<div class="text-xs font-semibold uppercase opacity-60 mt-2 mb-1">{% trans "Can referee for" %}</div>
<div class="flex flex-wrap gap-2">
{% for team in referee_profile.eligible_teams %}
<span class="badge badge-outline">{{ team.name }}</span>
{% empty %}
<span class="text-sm opacity-60">{% trans "This level has no teams linked yet." %}</span>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mt-2">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %}</span>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-60">{% trans "Not eligible to referee for any team." %}</p>
{% if referee_profile or is_club_admin %}
<div class="card card-body mt-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
{% if is_club_admin %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('referee_eligibility_modal').showModal()">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
{% endif %}
</div>
{% if referee_profile and referee_profile.level %}
<dl>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Level" %}</dt>
<dd class="text-sm font-semibold text-ink">{{ referee_profile.level }}</dd>
</div>
<div class="flex items-center justify-between gap-4 py-2">
<dt class="text-sm text-muted">{% trans "Valid until" %}</dt>
<dd class="flex items-center gap-2">
<span class="font-mono text-sm font-semibold text-ink">{{ referee_profile.valid_until|default:"—" }}</span>
{% if referee_profile.is_currently_valid %}
<span class="badge badge-sm badge-success">{% trans "Valid" %}</span>
{% elif referee_profile.valid_until %}
<span class="badge badge-sm badge-error">{% trans "Expired" %}</span>
{% else %}
<span class="badge badge-sm badge-warning">{% trans "No validity set" %}</span>
{% endif %}
</dd>
</div>
</dl>
{% if referee_profile.is_eligible %}
<div class="mt-2 mb-1 font-display text-xs font-bold tracking-[.1em] text-muted uppercase">{% trans "Can referee for" %}</div>
<div class="flex flex-wrap gap-2">
{% for team in referee_profile.eligible_teams %}
<span class="badge badge-outline">{{ team.name }}</span>
{% empty %}
<span class="text-sm text-muted">{% trans "This level has no teams linked yet." %}</span>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mt-2">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %}</span>
</div>
{% endif %}
{% else %}
<p class="text-sm text-muted">{% trans "Not eligible to referee for any team." %}</p>
{% endif %}
</div>
{% if is_club_admin %}
@@ -213,23 +285,21 @@
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% for family_group in family_groups %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">
{% lucide "users" size=18 %}
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
</h2>
{% if is_club_admin %}
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
</div>
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
<div class="card card-body mt-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title">
{% lucide "users" size=18 %}
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
</h2>
{% if is_club_admin %}
<div class="flex flex-wrap gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
</div>
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
</div>
{% if is_club_admin %}
@@ -244,16 +314,14 @@
{% endfor %}
{% if is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
{% endif %}
<div class="card card-body mt-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm text-muted">{% trans "Not part of a family." %}</p>
{% endif %}
</div>
{% url 'management:member_attach_family' member.pk as attach_family_url %}
@@ -262,6 +330,36 @@
{% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
{% endif %}
{% for requirement, status in checklist %}
{% if not status.is_complete %}
<dialog id="{{ requirement.pk|dom_id:"requirement_complete_modal" }}" class="modal">
<div class="modal-box">
<h3 class="font-display text-lg font-extrabold text-ink uppercase">{% blocktrans with name=requirement.name %}Mark “{{ name }}” complete{% endblocktrans %}</h3>
<form method="post" action="{% url 'management:member_requirement_complete' pk=member.pk requirement_pk=requirement.pk %}" id="{{ requirement.pk|dom_id:"requirement_complete_form" }}" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-control my-3">
<label class="label-text" for="{{ requirement.pk|dom_id:"requirement_document" }}">{% trans "Document" %}</label>
<input class="file-input mt-1" type="file" name="document" id="{{ requirement.pk|dom_id:"requirement_document" }}">
</div>
<div class="form-control my-3">
<label class="label-text" for="{{ requirement.pk|dom_id:"requirement_note" }}">{% trans "Note" %}</label>
<textarea class="textarea mt-1" name="note" id="{{ requirement.pk|dom_id:"requirement_note" }}" rows="2"></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="{{ requirement.pk|dom_id:"requirement_complete_form" }}">{% lucide "check" size=16 %} {% trans "Mark complete" %}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% endif %}
{% endfor %}
{% endblock panel %}
{% block extra_body %}

View File

@@ -10,46 +10,44 @@
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="card card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
{% if membership_form %}
<div class="my-2 h-px bg-rule"></div>
<h2 class="card-title">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
{% for error in membership_form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for field in membership_form %}
{% form_field field %}
{% endfor %}
</div>
{% endif %}
{% if membership_form %}
<div class="divider"></div>
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
{% for error in membership_form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in membership_form %}
{% form_field field %}
{% endfor %}
</div>
{% endif %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
<div class="mt-2 flex items-center gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:member_detail" object.pk %}{% else %}{% url "management:member_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
{% if update_view %}

View File

@@ -8,35 +8,33 @@
{% endblock actions %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %}
</p>
<p class="text-sm opacity-70">
{% blocktrans %}To register a family, give matching rows the same <code>family_group</code> value (any label works, e.g. a surname) and set each row's <code>family_role</code>. A parent/guardian row with an email gets a login; a child row doesn't — grant one later from their member page if they need it.{% endblocktrans %}
</p>
<div class="card card-body">
<p class="text-sm text-muted">
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %}
</p>
<p class="text-sm text-muted">
{% blocktrans %}To register a family, give matching rows the same <code>family_group</code> value (any label works, e.g. a surname) and set each row's <code>family_role</code>. A parent/guardian row with an email gets a login; a child row doesn't — grant one later from their member page if they need it.{% endblocktrans %}
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
{% form_field field %}
{% endfor %}
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
</form>
</div>
{% endfor %}
{% for field in form %}
{% form_field field %}
{% endfor %}
<div class="mt-2 flex items-center gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:member_list' %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "upload" size=16 %} {% trans "Upload and preview" %}</button>
</div>
</form>
</div>
{% endblock panel %}

View File

@@ -11,80 +11,76 @@
</div>
{% endif %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-sm opacity-70">
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
</p>
<div class="card card-body">
<p class="text-sm text-muted">
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Row" %}</th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Joining as" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
<tbody>
{% for result in results %}
<tr>
<td>{{ result.line_number }}</td>
<td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td>
<td>{{ result.raw.email }}</td>
<td>
{% if result.family_group %}
{{ result.family_group }}
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td>
{% comment %}
A guardian holds the login for a child but is not a member:
no fee, and not counted in any member total. See
club.models.ClubMembership.Kind.
{% endcomment %}
{% if result.membership_kwargs.kind == "guardian" %}
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
{% else %}
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
{% endif %}
</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
{% else %}
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
<div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<table class="table">
<thead>
<tr>
<th>{% trans "Row" %}</th>
<th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Joining as" %}</th>
<th>{% trans "Outcome" %}</th>
</tr>
</thead>
<tbody>
{% for result in results %}
<tr>
<td class="font-mono text-xs">{{ result.line_number }}</td>
<td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td>
<td class="text-muted">{{ result.raw.email }}</td>
<td>
{% if result.family_group %}
{{ result.family_group }}
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
{% else %}
<span class="text-dim"></span>
{% endif %}
</td>
<td>
{% comment %}
A guardian holds the login for a child but is not a member:
no fee, and not counted in any member total. See
club.models.ClubMembership.Kind.
{% endcomment %}
{% if result.membership_kwargs.kind == "guardian" %}
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
{% else %}
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
{% endif %}
</td>
<td>
{% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
{% else %}
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
<div class="mt-1 text-xs text-muted">{{ result.errors|join:"; " }}</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="py-6 text-center text-muted">{% trans "No rows found in the uploaded file." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if valid_count %}
<form method="post" action="{% url 'management:member_import_confirm' %}">
{% csrf_token %}
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
</form>
{% endif %}
</div>
<div class="mt-2 flex items-center gap-2 pt-2">
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
{% if valid_count %}
<form method="post" action="{% url 'management:member_import_confirm' %}">
{% csrf_token %}
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
</form>
{% endif %}
</div>
</div>
{% endblock panel %}

View File

@@ -3,114 +3,194 @@
{% block heading %}{% trans "Members" %}{% endblock heading %}
{% block topbar_context %}
<span class="font-mono text-[13px] text-muted">{% blocktrans count counter=paginator.count %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}</span>
{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-sm btn-outline btn-info gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=14 %} {% trans "Download upload template" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_import_template' %}">{% lucide "download" size=16 %} {% trans "Download upload template" %}</a>
{% if is_club_admin %}
<a class="btn btn-sm btn-outline btn-info gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=14 %} {% trans "Mass upload" %}</a>
<a class="btn btn-sm btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=14 %} {% trans "Add family" %}</a>
<a class="btn btn-sm btn-outline gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=14 %} {% trans "Add member" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% trans "Mass upload" %}</a>
<a class="btn btn-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% trans "Add family" %}</a>
<a class="btn btn-primary gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-2 flex items-center gap-2">
<label class="input grow sm:grow-0">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full sm:max-w-xs">
</label>
{% if selected_kind != "member" %}<input type="hidden" name="kind" value="{{ selected_kind }}">{% endif %}
{# Icon-only below `sm`: label text next to a growing input is what forced this row to wrap on a phone. #}
<button class="btn btn-outline gap-2" type="submit" aria-label="{% trans 'Search' %}">{% lucide "search" size=16 %}<span class="hidden sm:inline">{% trans "Search" %}</span></button>
{% if search %}
<a class="btn gap-2" href="{% url "management:member_list" %}" aria-label="{% trans 'Clear filter' %}">{% lucide "x" size=16 %}<span class="hidden sm:inline">{% trans "Clear filter" %}</span></a>
{% endif %}
</form>
{% comment %}
Guardians -- parents attached to the club only through a child -- have no
fee and aren't counted as members, so members_visible_to() leaves them out
of the default list entirely. Without this filter that's silent: a parent
just registered via "Add family"/"Add parent" simply doesn't appear here,
with nothing on the page explaining why. ?kind= makes the exclusion an
explicit, reversible choice instead.
{% endcomment %}
<div class="mb-4 flex flex-col gap-2">
<div class="join w-full">
<a href="{% querystring kind=None page=None %}" class="btn btn-sm join-item flex-1 {% if selected_kind == "member" %}btn-primary{% endif %}">{% trans "Members" %}</a>
<a href="{% querystring kind="guardian" page=None %}" class="btn btn-sm join-item flex-1 {% if selected_kind == "guardian" %}btn-primary{% endif %}">{% trans "Guardians" %}</a>
<a href="{% querystring kind="both" page=None %}" class="btn btn-sm join-item flex-1 {% if selected_kind == "both" %}btn-primary{% endif %}">{% trans "Both" %}</a>
</div>
<span class="text-xs opacity-60">{% trans "Guardians are parents linked only through a child -- no fee, not counted as a member." %}</span>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
{# table-cards: below `md` each row reads as a card instead of forcing a horizontal scroll -- see the .table-cards rule in app.css. #}
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}, {{ member.first_name }}</a></td>
<td data-label="{% trans 'Email' %}">{{ member.contact_email|default:"-" }}</td>
<td data-label="{% trans 'Family' %}">
{% if member.family_memberships_display %}
<div class="flex flex-col gap-1">
{% for fm in member.family_memberships_display %}
<div>
<a class="btn btn-outline btn-sm" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=14 %} {{ fm.family }} {% lucide "arrow-right" size=14 %}</a>
</div>
{% endfor %}
</div>
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td data-label="{% trans 'Status' %}">
{% if member.current_membership %}
<span class="badge badge-sm
{% if member.current_membership.status == "active" %}badge-success
{% elif member.current_membership.status == "pending" %}badge-warning
{% elif member.current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ member.current_membership.get_status_display }}
</span>
{% else %}
<span class="badge badge-sm badge-neutral">{% trans "unknown" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex flex-wrap justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% block filter_strip %}
<div class="flex flex-wrap items-center gap-3 border-b border-line bg-white px-7 py-2.5">
<form method="get" id="member-filters" class="flex flex-wrap items-center gap-2">
{% if selected_kind != "member" %}<input type="hidden" name="kind" value="{{ selected_kind }}">{% endif %}
<div class="relative">
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=14 %}</span>
{# No submit button -- data-autosubmit (base.html-loaded script) submits the form itself, debounced, as soon as typing pauses. #}
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Name, email, licence no.' %}" class="input w-56 pl-9" data-autosubmit>
</div>
<select name="status" class="select w-auto" onchange="this.form.submit()">
<option value="">{% trans "Any status" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="fee_status" class="select w-auto" onchange="this.form.submit()">
<option value="">{% trans "Any fee status" %}</option>
{% for value, label in fee_status_choices %}
<option value="{{ value }}" {% if selected_fee_status == value %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="team" class="select w-auto" onchange="this.form.submit()">
<option value="">{% trans "Any team" %}</option>
{% for team in teams %}
<option value="{{ team.pk }}" {% if selected_team == team.pk|stringformat:"s" %}selected{% endif %}>{{ team.name }}</option>
{% endfor %}
</select>
{# Boolean toggles rather than another dropdown -- both are reached most often from the dashboard's own "Needs attention" links (home.html), this just makes them equally reachable/visible from here. #}
{% if selected_unrostered %}
<a href="{% querystring unrostered=None page=None %}" class="btn btn-primary gap-2">{% trans "Unrostered" %}</a>
{% else %}
<a href="{% querystring unrostered="1" page=None %}" class="btn btn-outline gap-2">{% trans "Unrostered" %}</a>
{% endif %}
{% if requirements_configured %}
{% if selected_docs %}
<a href="{% querystring docs=None page=None %}" class="btn btn-primary gap-2">{% trans "Docs open" %}</a>
{% else %}
<a href="{% querystring docs="open" page=None %}" class="btn btn-outline gap-2">{% trans "Docs open" %}</a>
{% endif %}
{% endif %}
{% if search or selected_status or selected_fee_status or selected_team or selected_unrostered or selected_docs %}
{# No btn-sm: the filter fields (.input/.select) are all height:2.25rem, matching plain .btn -- btn-sm would sit 6px shorter and misalign in the row. #}
<a class="btn btn-outline gap-2" href="{% querystring q=None status=None fee_status=None team=None unrostered=None docs=None page=None %}">{% lucide "x" size=14 %} {% trans "Clear filters" %}</a>
{% endif %}
</form>
<div class="flex-1"></div>
{% comment %}
Guardians -- parents attached to the club only through a child -- have no
fee and aren't counted as members, so members_visible_to() leaves them out
of the default list entirely. Without this filter that's silent: a parent
just registered via "Add family"/"Add parent" simply doesn't appear here,
with nothing on the page explaining why. This segmented control makes the
exclusion an explicit, reversible choice instead.
{% endcomment %}
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-1 rounded-full bg-steel p-1">
<a href="{% querystring kind=None page=None %}" class="rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase {% if selected_kind == "member" %}bg-white text-ink{% else %}text-on-dark hover:text-white{% endif %}">{% trans "Members" %}</a>
<a href="{% querystring kind="guardian" page=None %}" class="rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase {% if selected_kind == "guardian" %}bg-white text-ink{% else %}text-on-dark hover:text-white{% endif %}">{% trans "Guardians" %}</a>
<a href="{% querystring kind="both" page=None %}" class="rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase {% if selected_kind == "both" %}bg-white text-ink{% else %}text-on-dark hover:text-white{% endif %}">{% trans "Both" %}</a>
</div>
<span class="font-mono text-xs text-dim">{% trans "Guardians have no fee and aren't counted as members." %}</span>
</div>
</div>
{% endblock filter_strip %}
{% block panel %}
<div class="card overflow-hidden">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Licence" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
{% if requirements_configured %}<th>{% trans "Documents" %}</th>{% endif %}
{% if is_club_admin %}<th>{% trans "Dues" %}</th>{% endif %}
<th></th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<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">{{ member.first_name|slice:":1" }}{{ member.last_name|slice:":1" }}</div>
</span>
<div>
<div class="flex items-center gap-1.5">
<a class="link link-hover font-semibold text-ink" href="{% url 'management:member_detail' member.pk %}">{{ member.last_name }}, {{ member.first_name }}</a>
{% if selected_kind == "both" and member.current_membership.kind == "guardian" %}
<span class="badge badge-ghost badge-xs">{% trans "Guardian" %}</span>
{% endif %}
</div>
{% if member.date_of_birth %}<div class="font-mono text-xs text-muted">{{ member.date_of_birth|date:"Y" }}</div>{% endif %}
</div>
</div>
</td>
<td class="text-sm text-muted">{{ member.contact_email|default:"—" }}</td>
{# text-xs, not text-sm: IBM Plex Mono reads noticeably larger than Barlow at the same declared size. #}
<td class="font-mono text-xs text-muted">{{ member.current_membership.license|default:"—" }}</td>
<td>
{% if member.family_memberships_display %}
<div class="flex flex-wrap gap-1">
{% for fm in member.family_memberships_display %}
<a class="btn btn-outline btn-xs gap-1" href="{% url 'management:family_detail' fm.family.pk %}">{% lucide "users" size=12 %} {{ fm.family }}</a>
{% endfor %}
</div>
{% else %}
<span class="text-dim"></span>
{% endif %}
</td>
<td>
{% if member.current_membership %}
<span class="badge badge-sm
{% if member.current_membership.status == "active" %}badge-success
{% elif member.current_membership.status == "pending" %}badge-warning
{% elif member.current_membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ member.current_membership.get_status_display }}
</span>
{% else %}
<span class="badge badge-sm badge-ghost">{% trans "unknown" %}</span>
{% endif %}
</td>
{% if requirements_configured %}
<td>
{% if member.current_membership and not member.current_membership.is_guardian %}
<span class="badge badge-sm {% if member.current_membership.onboarding_open %}badge-warning{% else %}badge-success{% endif %}">
{{ member.current_membership.completed_requirements }}/{{ total_requirements }}
</span>
{% else %}
<span class="text-dim"></span>
{% endif %}
</td>
{% endif %}
{% if is_club_admin %}
<td>
{% if member.current_membership and not member.current_membership.is_guardian %}
<span class="badge badge-sm
{% if member.current_membership.fee_status == "paid" %}badge-success
{% elif member.current_membership.fee_status == "partially_paid" %}badge-warning
{% elif member.current_membership.fee_status == "unpaid" %}badge-error
{% else %}badge-ghost{% endif %}">
{{ member.current_membership.get_fee_status_display }}
</span>
{% else %}
<span class="text-dim"></span>
{% endif %}
</td>
{% endif %}
<td class="text-right">
{% if can_manage_members %}
<div class="flex flex-wrap justify-end gap-1">
<a class="btn btn-outline btn-xs" href="{% url 'management:member_detail' member.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=13 %} {% trans "Edit" %}</a>
<button class="btn btn-xs btn-outline btn-error" type="button" onclick="document.getElementById('{{ member.pk|dom_id:"member_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=13 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="py-6 text-center text-muted">{% trans "No members yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "management/_pagination.html" %}
{% if is_club_admin %}
{% if can_manage_members %}
{% trans "Delete member" as delete_member_title %}
{% trans "Delete" as delete_label %}
{% for member in members %}

View File

@@ -1,77 +1,33 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Memberships" %}{% endblock heading %}
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
{% block heading %}{% trans "Dues & billing" %}{% endblock heading %}
{% block topbar_context %}
{% if selected_season %}
<span class="font-mono text-sm text-muted">{{ selected_season.start_date|date:"Y" }}&ndash;{{ selected_season.end_date|date:"Y" }}</span>
{% endif %}
{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-outline gap-2" href="{% url 'management:membership_export_pdf' %}?{{ request.GET.urlencode }}">{% lucide "file-down" size=16 %} {% trans "Export to PDF" %}</a>
{% endblock actions %}
{% block panel %}
{% if not current_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
</div>
{% else %}
<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 "users" size=16 %} {% trans "Registered" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
<div class="text-xs opacity-60">{% trans "This season" %}</div>
</div>
{% block filter_strip %}
<div class="border-b border-line bg-white px-7 py-3">
<form method="get" class="flex flex-row flex-wrap items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=15 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input w-56 pl-9">
</div>
<div class="card bg-base-100 shadow border-l-4 border-success">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_paid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_partial %}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 "circle-dashed" size=16 %} {% trans "Partially paid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_partial }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_unpaid %}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 "circle-x" size=16 %} {% trans "Unpaid" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_unpaid }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 border-neutral">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Waived" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_waived }}</div>
</div>
</div>
<div class="card bg-base-100 shadow border-l-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-error{% elif kpi_paid_rate < 85 %}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 "percent" size=16 %} {% trans "Paid rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<form method="get" class="mb-4">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:flex lg:flex-row lg:flex-wrap lg:items-center">
<label class="input w-full lg:w-auto">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full">
</label>
<select name="season" class="select select-bordered w-full lg:w-auto">
<select name="season" class="select w-48">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<select name="fee_status" class="select select-bordered w-full lg:w-auto">
<select name="fee_status" class="select w-56">
<option value="not_paid" {% if selected_fee_status == "not_paid" %}selected{% endif %}>{% trans "Not paid (unpaid or partially)" %}</option>
{% for value, label in fee_status_choices %}
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
@@ -79,119 +35,153 @@
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
</select>
<select name="status" class="select select-bordered w-full lg:w-auto">
<select name="status" class="select w-40">
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
{% endfor %}
</select>
<select name="team" class="select select-bordered w-full lg:w-auto">
<select name="team" class="select w-44">
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
{% for team in teams %}
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
{% endfor %}
</select>
<div class="col-span-full flex gap-2 lg:col-span-1 lg:contents">
<button class="btn btn-outline grow gap-2 lg:grow-0" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn grow gap-2 lg:grow-0" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
<a class="btn btn-ghost gap-2" href="{% url 'management:membership_list' %}">{% lucide "x" size=16 %} {% trans "Reset" %}</a>
</form>
</div>
{% endblock filter_strip %}
{% block panel %}
{% if not current_season %}
<div class="alert alert-warning">
{% lucide "calendar-x" size=20 %}
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
</div>
{% else %}
<div class="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-6">
<div class="card border-hairline bg-ink p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-on-dark uppercase">{% trans "Registered" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums text-white">{{ kpi_total }}</div>
<div class="mt-1 text-[13px] text-on-dark-dim">{% trans "This season" %}</div>
</div>
<div class="card border-l-4 border-ok p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Paid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums text-ok">{{ kpi_paid }}</div>
</div>
<div class="card border-l-4 p-4 {% if kpi_partial %}border-warn{% else %}border-ok{% endif %}">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Partially paid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_partial %}text-warn{% else %}text-ink{% endif %}">{{ kpi_partial }}</div>
</div>
<div class="card border-l-4 p-4 {% if kpi_unpaid %}border-club{% else %}border-ok{% endif %}">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Unpaid" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_unpaid %}text-club{% else %}text-ink{% endif %}">{{ kpi_unpaid }}</div>
</div>
<div class="card border-l-4 border-edge p-4">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Waived" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums text-ink">{{ kpi_waived }}</div>
</div>
<div class="card border-l-4 p-4 {% if kpi_paid_rate is None %}border-info{% elif kpi_paid_rate < 50 %}border-club{% elif kpi_paid_rate < 85 %}border-warn{% else %}border-ok{% endif %}">
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Paid rate" %}</div>
<div class="mt-1 font-display text-[34px] leading-none font-extrabold tabular-nums {% if kpi_paid_rate is None %}text-info{% elif kpi_paid_rate < 50 %}text-club{% elif kpi_paid_rate < 85 %}text-warn{% else %}text-ok{% endif %}">
{% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
</div>
</div>
</div>
</form>
{% endif %}
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
<div class="card overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4.5 py-3.5">
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
</span>
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
</div>
{# table-cards: this table is dense enough (10+ columns) that horizontal scroll alone stops being usable below `md` -- see the .table-cards rule in app.css. #}
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "Owed" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "License" %}</th>
<th></th>
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
<td><a class="link link-hover font-semibold" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}, {{ membership.member.first_name }}</a></td>
<td>{{ membership.member.contact_email|default:"-" }}</td>
<td>
{% if membership.member.family_memberships_display %}
{% for fm in membership.member.family_memberships_display %}
{{ fm.family }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<span class="text-dim">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-sm
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_status_display }}
</span>
</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-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</td>
<td class="font-mono tabular-nums">&euro; {{ membership.fee_amount }}</td>
<td class="font-mono tabular-nums">
&euro; {{ membership.amount_paid }}
{% if membership.record_payment_form and membership.fee_amount %}
<div class="font-sans text-xs text-muted">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
{% endif %}
</td>
<td>{{ membership.license|default:"-" }}</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex flex-wrap justify-end gap-1">
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
</button>
</div>
{% endif %}
</td>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
<td><a class="link link-hover font-semibold" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member.last_name }}, {{ membership.member.first_name }}</a></td>
<td data-label="{% trans 'Email' %}">{{ membership.member.contact_email|default:"-" }}</td>
<td data-label="{% trans 'Family' %}">
{% if membership.member.family_memberships_display %}
{% for fm in membership.member.family_memberships_display %}
{{ fm.family }} {% if not forloop.last %},{% endif %}
{% endfor %}
{% else %}
<span class="opacity-40">-</span>
{% endif %}
</td>
<td data-label="{% trans 'Status' %}">
<span class="badge badge-sm
{% if membership.status == "active" %}badge-success
{% elif membership.status == "pending" %}badge-warning
{% elif membership.status == "cancelled" %}badge-error
{% else %}badge-neutral{% endif %}">
{{ membership.get_status_display }}
</span>
</td>
<td data-label="{% trans 'Fee status' %}">
<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-neutral{% endif %}">
{{ membership.get_fee_status_display }}
</span>
</td>
<td data-label="{% trans 'Owed' %}" class="tabular-nums">&euro; {{ membership.fee_amount }}</td>
<td data-label="{% trans 'Paid' %}" class="tabular-nums">
&euro; {{ membership.amount_paid }}
{% if membership.record_payment_form and membership.fee_amount %}
<div class="text-xs opacity-60">{% blocktrans with remaining=membership.remaining_balance_display %}{{ remaining }} left{% endblocktrans %}</div>
{% endif %}
</td>
<td data-label="{% trans 'License' %}">{{ membership.license|default:"-" }}</td>
<td class="text-right">
{% if membership.record_payment_form %}
<div class="flex flex-wrap justify-end gap-1">
<button class="btn btn-outline btn-sm" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"record_payment_modal" }}').showModal()">
{% lucide "receipt" size=12 %} {% trans "Record payment" %}
</button>
<button class="btn btn-sm btn-outline btn-success" type="submit" form="{{ membership.pk|dom_id:"mark_fully_paid_form" }}">
{% lucide "circle-check" size=12 %} {% trans "Mark fully paid" %}
</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center opacity-60">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% empty %}
<tr>
<td colspan="10" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</form>

View File

@@ -2,16 +2,17 @@
{% load i18n lucide ui %}
{% block heading %}{{ news_item.title }}{% endblock heading %}
{% block subheading %}
{% block topbar_context %}
{% if news_item.status == "draft" %}
{% trans "Draft" %}
<span class="badge">{% trans "Draft" %}</span>
{% elif news_item.is_scheduled %}
{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}
<span class="badge badge-info">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
{% blocktrans with date=news_item.published_at %}Published {{ date }}{% endblocktrans %}
<span class="badge badge-success">{% blocktrans with date=news_item.published_at %}Published {{ date }}{% endblocktrans %}</span>
{% endif %}
· {{ news_item.get_visibility_display }}
{% endblock subheading %}
<span class="font-mono text-xs text-dim">{{ news_item.get_visibility_display }}</span>
{% endblock topbar_context %}
{% block actions %}
{% if can_edit %}
@@ -28,47 +29,57 @@
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card">
<div class="card-body">
{% if news_item.teams.all %}
<div class="flex flex-wrap gap-2 mb-2">
<div class="mb-1 flex flex-wrap gap-2">
{% for team in news_item.teams.all %}
<span class="badge badge-neutral">{{ team.short_name }}</span>
{% endfor %}
</div>
{% else %}
<div class="mb-1 font-display text-xs font-bold tracking-[.14em] text-club uppercase">{% trans "Club-wide" %}</div>
{% endif %}
<div class="h-[3px] w-14 bg-club"></div>
<p class="whitespace-pre-line text-[15px] leading-relaxed text-ink">{{ news_item.body }}</p>
{% if news_item.created_by %}
<div class="mt-2 flex items-center gap-2.5 border-t border-line pt-3.5 text-sm text-muted">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-steel font-display text-xs font-extrabold text-white">{{ news_item.created_by.first_name|slice:":1" }}{{ news_item.created_by.last_name|slice:":1" }}</span>
{% blocktrans with name=news_item.created_by %}Posted by {{ name }}{% endblocktrans %}
</div>
{% endif %}
<p class="whitespace-pre-line">{{ news_item.body }}</p>
</div>
</div>
{% if news_item.title_en or news_item.body_en %}
<div class="card bg-base-100 shadow mt-4">
<div class="card mt-4">
<div class="card-body">
<h2 class="card-title text-base">{% trans "English" %}</h2>
{% if news_item.title_en %}<p class="font-semibold">{{ news_item.title_en }}</p>{% endif %}
{% if news_item.body_en %}<p class="whitespace-pre-line">{{ news_item.body_en }}</p>{% endif %}
<h2 class="card-title">{% lucide "languages" size=18 %} {% trans "English" %}</h2>
{% if news_item.title_en %}<p class="font-display text-lg font-extrabold text-ink uppercase">{{ news_item.title_en }}</p>{% endif %}
{% if news_item.body_en %}<p class="whitespace-pre-line text-[15px] leading-relaxed text-ink">{{ news_item.body_en }}</p>{% endif %}
</div>
</div>
{% endif %}
<div class="card bg-base-100 shadow mt-4">
<div class="card mt-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
<h2 class="card-title">{% lucide "image" size=18 %} {% trans "Photos" %}</h2>
{% if can_edit %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
{% endif %}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
<div class="mt-2 grid grid-cols-2 gap-4 md:grid-cols-4">
{% for photo in news_item.photos.all %}
<div class="relative">
<img class="rounded-box aspect-square object-cover w-full" src="{{ photo.image.url }}" alt="">
<img class="aspect-square w-full rounded-box border border-line object-cover" src="{{ photo.image.url }}" alt="">
{% if photo.is_main %}
<span class="badge badge-success badge-sm absolute top-1 left-1">{% trans "Main" %}</span>
<span class="badge badge-success badge-sm absolute top-1.5 left-1.5">{% trans "Main" %}</span>
{% endif %}
{% if can_edit %}
<div class="flex flex-wrap gap-1 mt-1">
<div class="mt-1 flex flex-wrap gap-1">
{% if not photo.is_main %}
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
{% csrf_token %}
@@ -85,7 +96,7 @@
{% endif %}
</div>
{% empty %}
<p class="opacity-60 col-span-full">{% trans "No photos yet." %}</p>
<p class="col-span-full text-sm text-muted">{% trans "No photos yet." %}</p>
{% endfor %}
</div>
</div>

View File

@@ -4,7 +4,7 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card w-full">
<div class="card-body">
<form method="post">
{% csrf_token %}
@@ -16,25 +16,25 @@
{% endfor %}
{% if not update_view %}
<p class="opacity-70 text-sm mb-2">{% trans "Photos can be added once the news item is created." %}</p>
<p class="mb-2 text-sm text-muted">{% trans "Photos can be added once the news item is created." %}</p>
{% endif %}
<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.title %}
{% form_field form.title_en %}
</div>
<div class="grid grid-cols-1 gap-4 mt-4">
<div class="mt-4 grid grid-cols-1 gap-4">
{% form_field form.teams %}
{% form_field form.visibility %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.body %}
{% form_field form.body_en %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="mt-4 flex items-center gap-2 border-t border-line pt-4">
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>

View File

@@ -5,65 +5,52 @@
{% block actions %}
{% if can_add_news %}
<a class="btn btn-outline gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
<a class="btn btn-primary gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-cards">
<thead>
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Visibility" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for news_item in news_items %}
<tr>
<td><a class="link link-hover font-semibold" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td>
<td data-label="{% trans 'Teams' %}">
<div class="flex flex-wrap gap-2">
{% for team in news_item.teams.all %}
<span class="badge badge-neutral badge-sm">{{ team.short_name }}</span>
{% empty %}
<span class="opacity-60">{% trans "Club-wide" %}</span>
{% endfor %}
</div>
</td>
<td data-label="{% trans 'Visibility' %}">{{ news_item.get_visibility_display }}</td>
<td data-label="{% trans 'Status' %}">
{% if news_item.status == "draft" %}
<span class="badge badge-neutral badge-sm">{% trans "Draft" %}</span>
{% elif news_item.is_scheduled %}
<span class="badge badge-warning badge-sm">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
</td>
<td class="text-right">
{% if news_item.can_edit %}
<div class="flex flex-wrap justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ news_item.pk|dom_id:"news_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</td>
</tr>
<div class="card overflow-hidden divide-y">
{% for news_item in news_items %}
<div class="flex items-center gap-3.5 px-4.5 py-3.5 {% if news_item.status == 'draft' %}border-l-[3px] border-club bg-row-sel{% endif %}">
<div class="min-w-0 flex-1">
<div class="mb-1 flex flex-wrap items-center gap-2">
{% if news_item.status == "draft" %}
<span class="badge badge-sm">{% trans "Draft" %}</span>
{% elif news_item.is_scheduled %}
<span class="badge badge-info badge-sm">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
<span class="font-mono text-xs text-dim">
{% if news_item.status == "draft" %}
{% blocktrans with time=news_item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
{% else %}
{{ news_item.published_at|date:"j M Y" }}
{% endif %}
</span>
</div>
<a class="block truncate font-display text-lg font-extrabold text-ink uppercase hover:text-club" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a>
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-[13px] text-muted">
{% if news_item.created_by %}<span>{{ news_item.created_by }}</span>&middot;{% endif %}
{% for team in news_item.teams.all %}
<span class="badge badge-outline badge-xs">{{ team.short_name }}</span>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No news items yet." %}</td>
</tr>
<span>{% trans "Club-wide" %}</span>
{% endfor %}
</tbody>
</table>
&middot; <span>{{ news_item.get_visibility_display }}</span>
</div>
</div>
{% if news_item.can_edit %}
<div class="flex shrink-0 flex-wrap justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:news_detail' news_item.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ news_item.pk|dom_id:"news_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="px-4.5 py-10 text-center text-sm text-muted">{% trans "No news items yet." %}</div>
{% endfor %}
</div>
{% include "management/_pagination.html" %}

View File

@@ -0,0 +1,57 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block panel_title %}{% if update_view %}{% trans "Edit requirement" %}{% else %}{% trans "New requirement" %}{% endif %}{% endblock panel_title %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New requirement" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error">
{% lucide "circle-x" size=18 %}
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-2 gap-4">
{% for field in form %}
{% if field.name != "blocked_event_kinds" %}
<div class="{% if field.widget_type == "textarea" or field.widget_type == "clearablefile" %}col-span-2{% endif %}">
{% form_field field %}
</div>
{% endif %}
{% endfor %}
{% comment %}
Hand-rendered: field.widget_type for CheckboxSelectMultiple
("checkboxselectmultiple") isn't one templatetags/field.html
recognises, so {% form_field %} would fall through to its
plain-input case and render an unusable <input type=
"checkboxselectmultiple">.
{% endcomment %}
<div class="form-control col-span-2">
<label class="label-text">{{ form.blocked_event_kinds.label }}</label>
<div class="flex flex-wrap gap-3">
{% for choice in form.blocked_event_kinds %}
<label class="flex items-center gap-1.5 text-sm text-ink">
{{ choice.tag }} {{ choice.choice_label }}
</label>
{% endfor %}
</div>
<span class="label-text-alt mt-1">{{ form.blocked_event_kinds.help_text }}</span>
{% for error in form.blocked_event_kinds.errors %}<div class="text-xs text-club-dark">{{ error }}</div>{% endfor %}
</div>
</div>
<div class="mt-4 flex justify-start gap-2">
<a class="btn btn-outline gap-2" href="{% url "management:onboarding_requirement_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,74 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% comment %}
What this club requires from every member after they sign up or renew --
see club/models.py's OnboardingRequirement docstring. Order matters (shown
as typed in `order`, ascending) -- it's the sequence staff see on a
member's checklist. No dedicated mockup screen for this (new feature, not
in the original design file) -- extrapolates the card/table vocabulary
used across Settings.
{% endcomment %}
{% block panel_title %}{% trans "Onboarding requirements" %}{% endblock panel_title %}
{% block heading %}{% trans "Onboarding requirements" %}{% endblock heading %}
{% block topbar_context %}
<span class="badge badge-neutral">{% blocktrans count counter=requirements|length %}{{ counter }} requirement{% plural %}{{ counter }} requirements{% endblocktrans %}</span>
{% endblock topbar_context %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:onboarding_requirement_create' %}">{% lucide "plus" size=16 %} {% trans "New requirement" %}</a>
{% endblock actions %}
{% block panel %}
<p class="text-sm text-muted">
{% trans "Shown as a checklist on every member's Documents tab. A member can be fully paid and still show as incomplete here until every active requirement is met — the two are tracked separately on purpose." %}
</p>
<div class="card overflow-hidden">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Document required" %}</th>
<th>{% trans "Active" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for requirement in requirements %}
<tr>
<td class="font-semibold">{{ requirement.name }}</td>
<td class="max-w-xs truncate text-muted">{{ requirement.description|default:"—" }}</td>
<td>
{% if requirement.requires_document %}<span class="badge badge-info">{% trans "Yes" %}</span>{% else %}<span class="text-dim">{% trans "No" %}</span>{% endif %}
</td>
<td>
{% if requirement.is_active %}<span class="badge badge-success">{% trans "Active" %}</span>{% else %}<span class="badge badge-ghost">{% trans "Inactive" %}</span>{% endif %}
</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-outline btn-sm" href="{% url 'management:onboarding_requirement_update' requirement.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ requirement.pk|dom_id:"requirement_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-dim">{% trans "No requirements set — every member's checklist is empty until you add one." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% trans "Delete requirement" as delete_title %}
{% trans "Delete" as delete_label %}
{% for requirement in requirements %}
{% url 'management:onboarding_requirement_delete' requirement.pk as requirement_delete_url %}
{% blocktrans with name=requirement.name asvar delete_body %}Delete “{{ name }}”? Existing checklists keep whatever was already recorded for it, but no member will be asked for it again.{% endblocktrans %}
{% include "controlpanel/_confirm_modal.html" with modal_id=requirement.pk|dom_id:"requirement_delete_modal" title=delete_title body=delete_body action_url=requirement_delete_url submit_label=delete_label %}
{% endfor %}
{% endblock panel %}

View File

@@ -4,24 +4,24 @@
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New opponent" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card w-full">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% 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">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<div class="card-actions justify-start pt-4">
<a class="btn btn-outline gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>

Some files were not shown because too many files have changed in this diff Show More