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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ local_settings.py
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
media
|
media
|
||||||
|
private_media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# 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.
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ ClubMembership(ClubScopedModel) # -> carries `club`
|
|||||||
status CharField (TextChoices: pending | active | lapsed | cancelled)
|
status CharField (TextChoices: pending | active | lapsed | cancelled)
|
||||||
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
|
fee_status CharField (TextChoices: unpaid | partial | paid | waived)
|
||||||
signed_up_at DateTimeField (null) # when the member registered for the season
|
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", ...]
|
Meta: unique_together (club, member, season); ordering = ["-season__start_date", ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -117,34 +117,30 @@ docker compose up -d --no-deps web
|
|||||||
|
|
||||||
## Scheduled jobs
|
## Scheduled jobs
|
||||||
|
|
||||||
Four commands need to run on a schedule. Put them on the **host**, not in a container, and on
|
Five jobs run on a schedule via **Celery Beat**, not host cron — see `rosterchief/settings.py`
|
||||||
**exactly one node** when you have several — three nodes archiving the same club is three
|
(`CELERY_BEAT_SCHEDULE`) for the exact times and `features/jobs.py` for what each one does.
|
||||||
emails to the same club.
|
`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
|
| Job | Cadence | What it does |
|
||||||
# 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
|
| `extend_event_series` | daily 03:00 | materialises recurring event occurrences so the calendar never runs dry |
|
||||||
# once per escalation level, not once per run, so a daily cron is not a daily email.
|
| `renew_subscriptions` | daily 04:00 | opens the next billing period for clubs whose current one is running out |
|
||||||
0 5 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py send_billing_reminders --commit
|
| `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.
|
Each task always acts (no `--dry-run`/`--commit` gate) — the same as the old crontab always
|
||||||
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
|
passing `--commit`. Run status (started, finished, success/failure, what it returned or
|
||||||
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
|
raised) is recorded in `features.models.JobRun` and shown on the control panel's **Jobs**
|
||||||
# not a morning of angry clubs. Since grace now runs from the period START rather than its
|
tab, which a crontab line mailing stderr on failure never gave us.
|
||||||
# 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
|
|
||||||
|
|
||||||
# Events: extend recurring series so the calendar never runs dry.
|
The `manage.py <command>` versions of these still exist unchanged, for manual/dry-run use
|
||||||
0 3 * * * cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series
|
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).
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Maintenance mode
|
## Maintenance mode
|
||||||
|
|
||||||
@@ -155,16 +151,19 @@ Control panel → **Features → Maintenance mode**. While it is on:
|
|||||||
you with no way to turn it back off;
|
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
|
- `/healthz` keeps answering on every host, or the load balancer would take the node out of
|
||||||
rotation and the control panel with it;
|
rotation and the control panel with it;
|
||||||
- the **scheduled jobs stand down** — `archive_overdue_clubs`, `extend_event_series` and
|
- the **scheduled jobs stand down** — the five Celery tasks in the table above, plus
|
||||||
`import_members_csv` refuse to run.
|
`import_members_csv` when run by hand.
|
||||||
|
|
||||||
`migrate` and `collectstatic` are deliberately **not** blocked. Maintenance is usually
|
`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
|
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.
|
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.
|
A Celery task raises loudly rather than skipping quietly while the platform is closed — that
|
||||||
That is intended: a job that silently skips itself is how a month of billing goes missing. If
|
is intended, a job that silently no-ops is how a month of billing goes missing — which
|
||||||
you genuinely mean to run one during a window, pass `--ignore-maintenance`.
|
`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:
|
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
|
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.
|
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 (~50–60 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
|
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:
|
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) |
|
| Cache / flags | `redis` container | managed Redis (or your existing one) |
|
||||||
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
|
| Uploads | local disk | **S3 bucket** (`AWS_STORAGE_BUCKET_NAME`) |
|
||||||
| Static files | WhiteNoise, in the image | unchanged — that is why WhiteNoise is there |
|
| 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 |
|
| 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
|
Drop `db` and `redis` from `compose.yaml`, point the URLs at the central services, and run
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ COPY --from=venv /app/.venv ./.venv
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=css /build/static/css/app.css ./static/css/app.css
|
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.
|
# collectstatic needs a settings module that imports: a throwaway key, never used at runtime.
|
||||||
RUN DJANGO_SECRET_KEY=build-only-not-a-secret \
|
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
|
# 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.
|
# dir) is what a named volume copies its initial ownership from on first use.
|
||||||
RUN useradd --system --uid 1000 rosterchief \
|
RUN useradd --system --uid 1000 rosterchief \
|
||||||
&& mkdir -p /app/media \
|
&& mkdir -p /app/media /app/private_media \
|
||||||
&& chown -R rosterchief /app
|
&& chown -R rosterchief /app
|
||||||
USER rosterchief
|
USER rosterchief
|
||||||
|
|
||||||
|
|||||||
1063
assets/controlpanel.css
Normal file
1063
assets/controlpanel.css
Normal file
File diff suppressed because it is too large
Load Diff
1063
assets/management.css
Normal file
1063
assets/management.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
"""Force MFA enrolment for privileged users.
|
"""Force MFA enrolment for privileged users.
|
||||||
|
|
||||||
Anyone who can change other people's data must have a second factor: Django
|
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)
|
staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN, EDITOR, or
|
||||||
in *any* club. Regular members may enrol, but aren't forced to.
|
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
|
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
|
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.
|
#: under DEBUG — without it, live reload dies on the enrolment page itself.
|
||||||
EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
|
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:
|
def mfa_required_for(user) -> bool:
|
||||||
|
|||||||
84
billing/tasks.py
Normal file
84
billing/tasks.py
Normal 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)."
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
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)
|
@admin.register(Club)
|
||||||
@@ -63,3 +63,19 @@ class ClubRoleAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ["club__name", "member__last_name", "member__first_name"]
|
search_fields = ["club__name", "member__last_name", "member__first_name"]
|
||||||
list_filter = ["club", "role"]
|
list_filter = ["club", "role"]
|
||||||
raw_id_fields = ["member"]
|
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"]
|
||||||
|
|||||||
@@ -1,23 +1,46 @@
|
|||||||
"""Tenant-aware page branding.
|
"""Tenant-aware page branding.
|
||||||
|
|
||||||
Every page inherits its chrome from ``base_template``. On a club subdomain that
|
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
|
resolves to the club-branded skin, on the base domain to the platform one — the
|
||||||
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
|
control panel's own industrial design system (assets/controlpanel.css) — so the
|
||||||
now or later) follow the tenant without a single template of their own knowing
|
auth screens (login, password reset, MFA, passkeys — anything allauth ships, now
|
||||||
that clubs exist.
|
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
|
A club subdomain serves two very different chromes, though: the public club site
|
||||||
base, so no branding bug can ever dress the platform panel up as a club.
|
(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"
|
CLUB_BASE_TEMPLATE = "_club_base.html"
|
||||||
|
MANAGEMENT_BASE_TEMPLATE = "management/_auth_base.html"
|
||||||
|
|
||||||
|
|
||||||
def branding(request):
|
def branding(request):
|
||||||
club = getattr(request, "club", None) # set by ClubTenantMiddleware
|
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 {
|
return {
|
||||||
"club": club,
|
"club": club,
|
||||||
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
|
"base_template": base_template,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@ from waffle import flag_is_active
|
|||||||
|
|
||||||
from members.models import Group
|
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):
|
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||||
@@ -25,6 +25,12 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
|||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if getattr(request, "club", None) is None:
|
if getattr(request, "club", None) is None:
|
||||||
raise Http404("The management app is not available on the base domain.")
|
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)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
@@ -32,13 +38,28 @@ class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
|||||||
|
|
||||||
|
|
||||||
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
|
||||||
"""ADMIN role only — club-wide settings that aren't scoped to a single team:
|
"""ADMIN role only (a platform superuser always passes too, see
|
||||||
seasons, positions, roles, shop configuration."""
|
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):
|
def test_func(self):
|
||||||
return is_club_admin(self.request.user, self.request.club)
|
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):
|
class FeatureRequiredMixin(ClubAdminRequiredMixin):
|
||||||
"""Gate for a whole management section (shop, forms, ...) this club doesn't
|
"""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
|
have at all unless its waffle Flag (see the ``features`` app, set per-club
|
||||||
|
|||||||
118
club/models.py
118
club/models.py
@@ -5,11 +5,13 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
|
from rosterchief.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
|
||||||
|
from rosterchief.storage import private_storage
|
||||||
|
|
||||||
|
|
||||||
class ClubManager(models.Manager):
|
class ClubManager(models.Manager):
|
||||||
@@ -256,6 +258,14 @@ class Season(ClubScopedModel):
|
|||||||
context needed) -- the season that follows the one covering ``date``."""
|
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()
|
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 ClubMembership(ClubScopedModel):
|
||||||
class Kind(models.TextChoices):
|
class Kind(models.TextChoices):
|
||||||
@@ -319,6 +329,21 @@ class ClubMembership(ClubScopedModel):
|
|||||||
"""
|
"""
|
||||||
return self.kind == self.Kind.GUARDIAN
|
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):
|
def clean(self):
|
||||||
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
||||||
# A guardian owes nothing -- they're not a member. Caught here rather than
|
# 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}"
|
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 ClubRole(ClubScopedModel):
|
||||||
class Roles(models.TextChoices):
|
class Roles(models.TextChoices):
|
||||||
ADMIN = "admin", _("admin")
|
ADMIN = "admin", _("admin")
|
||||||
MEMBER = "member", _("member")
|
MEMBER = "member", _("member")
|
||||||
EDITOR = "editor", _("editor")
|
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"))
|
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)
|
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)
|
||||||
|
|||||||
@@ -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()
|
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:
|
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:
|
def has_management_access(user: User, club: Club) -> bool:
|
||||||
"""Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
|
"""Anyone with real authority in the club: ADMIN/EDITOR/MEMBER_ADMIN, a platform
|
||||||
staff assignment (coach, team manager, physio, ...).
|
superuser, or *any* current-season staff assignment (coach, team manager,
|
||||||
|
physio, ...).
|
||||||
|
|
||||||
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
|
Deliberately excludes the plain MEMBER role -- every signed-up player (or club
|
||||||
member generally) holds that automatically the moment their ClubMembership goes
|
member generally) holds that automatically the moment their ClubMembership goes
|
||||||
active (club/signals.py), so it says nothing about whether someone is staff.
|
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()
|
return elevated or teams_staffed_by(user, club).exists()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ step here, never recomputed by re-aggregating FeePayment on every read.
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from club.models import ClubMembership, FeePayment
|
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):
|
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
|
"""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
|
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)
|
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
|
membership.amount_paid = F("amount_paid") + amount
|
||||||
@@ -54,16 +54,10 @@ def _sync_fee_status(membership, *, force_paid=False):
|
|||||||
else:
|
else:
|
||||||
new_status = ClubMembership.FeeStatus.UNPAID
|
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
|
membership.fee_status = new_status
|
||||||
update_fields = ["fee_status"]
|
membership.save(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)
|
|
||||||
|
|||||||
231
club/services/onboarding.py
Normal file
231
club/services/onboarding.py
Normal 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
34
club/tasks.py
Normal 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)."
|
||||||
472
club/tests.py
472
club/tests.py
@@ -8,6 +8,7 @@ from allauth.mfa.models import Authenticator
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.contrib import admin as django_admin
|
from django.contrib import admin as django_admin
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.db import IntegrityError
|
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.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
from teams.services import eligible_roster_members
|
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 (
|
from .services.access import (
|
||||||
COACH_MANAGER,
|
COACH_MANAGER,
|
||||||
can_edit_event,
|
can_edit_event,
|
||||||
|
can_manage_members,
|
||||||
can_manage_shop,
|
can_manage_shop,
|
||||||
has_club_role,
|
has_club_role,
|
||||||
|
has_management_access,
|
||||||
|
is_club_admin,
|
||||||
|
is_member_admin,
|
||||||
|
is_platform_superuser,
|
||||||
members_visible_to,
|
members_visible_to,
|
||||||
roles_in_club,
|
roles_in_club,
|
||||||
teams_managed_by,
|
teams_managed_by,
|
||||||
teams_staffed_by,
|
teams_staffed_by,
|
||||||
)
|
)
|
||||||
from .services.fees import mark_as_paid, record_payment, remaining_balance
|
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 .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
|
||||||
from .tenancy import (
|
from .tenancy import (
|
||||||
ClubTenantMiddleware,
|
ClubTenantMiddleware,
|
||||||
@@ -496,6 +513,26 @@ class SeasonNextAfterTests(TestCase):
|
|||||||
self.assertEqual(Season.next_after(other, datetime.date(2026, 12, 25)).club, other)
|
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):
|
class SponsorModelTests(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@@ -1035,6 +1072,61 @@ class AccessServiceTests(TestCase):
|
|||||||
self.assertTrue(can_manage_shop(admin_user, self.club))
|
self.assertTrue(can_manage_shop(admin_user, self.club))
|
||||||
self.assertFalse(can_manage_shop(editor_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):
|
class ClubRoleStatusSyncTests(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1154,16 +1246,16 @@ class BrandingTests(TestCase):
|
|||||||
def test_the_base_domain_gets_the_platform_skin(self):
|
def test_the_base_domain_gets_the_platform_skin(self):
|
||||||
response = self.login_page("rosterchief.app")
|
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.assertTemplateNotUsed(response, "_club_base.html")
|
||||||
self.assertContains(response, "Club & Team Management")
|
self.assertContains(response, "RosterChief")
|
||||||
self.assertIsNone(response.context["club"])
|
self.assertIsNone(response.context["club"])
|
||||||
|
|
||||||
def test_a_club_subdomain_gets_the_club_skin(self):
|
def test_a_club_subdomain_gets_the_club_skin(self):
|
||||||
response = self.login_page("ajax-united.rosterchief.app")
|
response = self.login_page("ajax-united.rosterchief.app")
|
||||||
|
|
||||||
self.assertTemplateUsed(response, "_club_base.html")
|
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.assertContains(response, "Ajax United")
|
||||||
self.assertEqual(response.context["club"], self.club)
|
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.
|
# The subdomain stops resolving, so there is no club to brand with.
|
||||||
self.club.archive()
|
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):
|
def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self):
|
||||||
response = self.login_page("ajax-united.rosterchief.app")
|
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")
|
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(
|
@override_settings(
|
||||||
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||||
@@ -1217,14 +1358,17 @@ class BrandingTests(TestCase):
|
|||||||
class Custom403PageTests(TestCase):
|
class Custom403PageTests(TestCase):
|
||||||
"""Django's default 403 handler picks up templates/403.html automatically --
|
"""Django's default 403 handler picks up templates/403.html automatically --
|
||||||
branded per tenant (base_template, same as maintenance.html) so a permission
|
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
|
error still looks like the app, not a bare Django error page. A club subdomain
|
||||||
(sign out, theme toggle, home link) stays reachable."""
|
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
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
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")
|
member = get_user_model().objects.create_user(email="member-403@example.com", password="pw-secret-123")
|
||||||
self.client.force_login(member)
|
self.client.force_login(member)
|
||||||
|
|
||||||
@@ -1233,7 +1377,7 @@ class Custom403PageTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
self.assertContains(response, "Access denied", status_code=403)
|
self.assertContains(response, "Access denied", status_code=403)
|
||||||
self.assertContains(response, "Ajax United", 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):
|
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"))
|
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.assertEqual(response.status_code, 403)
|
||||||
self.assertContains(response, "Access denied", status_code=403)
|
self.assertContains(response, "Access denied", status_code=403)
|
||||||
self.assertContains(response, "Club & Team Management", status_code=403)
|
self.assertTemplateUsed(response, "controlpanel/_auth_base.html")
|
||||||
|
self.assertContains(response, "RosterChief", status_code=403)
|
||||||
|
|
||||||
|
|
||||||
class ClubBrandingModelTests(TestCase):
|
class ClubBrandingModelTests(TestCase):
|
||||||
@@ -1352,17 +1497,21 @@ class FeeServiceTests(TestCase):
|
|||||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
|
||||||
self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
|
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("100.00"))
|
||||||
record_payment(self.membership, amount=Decimal("50.00"))
|
record_payment(self.membership, amount=Decimal("50.00"))
|
||||||
|
|
||||||
self.membership.refresh_from_db()
|
self.membership.refresh_from_db()
|
||||||
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
|
||||||
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
|
self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
|
||||||
self.assertEqual(self.membership.activated_at, timezone.localdate())
|
self.assertIsNone(self.membership.activated_at)
|
||||||
self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
|
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)
|
earlier = datetime.date(2026, 1, 1)
|
||||||
self.membership.activated_at = earlier
|
self.membership.activated_at = earlier
|
||||||
self.membership.save()
|
self.membership.save()
|
||||||
@@ -1400,7 +1549,7 @@ class FeeServiceTests(TestCase):
|
|||||||
|
|
||||||
unpriced.refresh_from_db()
|
unpriced.refresh_from_db()
|
||||||
self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
|
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())
|
self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
|
||||||
|
|
||||||
def test_recorded_by_is_stored_on_the_payment(self):
|
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())
|
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)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ services:
|
|||||||
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
||||||
# running fine in between.
|
# running fine in between.
|
||||||
- media_data:/app/media
|
- 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -36,6 +39,32 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
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:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -56,8 +85,10 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
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"]
|
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
media_data:
|
media_data:
|
||||||
|
private_media_data:
|
||||||
|
|||||||
49
compose.yaml
49
compose.yaml
@@ -40,6 +40,10 @@ services:
|
|||||||
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
# this, a rebuild or recreate wipes MEDIA_ROOT even though the container itself keeps
|
||||||
# running fine in between.
|
# running fine in between.
|
||||||
- media_data:/app/media
|
- 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -52,6 +56,38 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
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:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -75,10 +111,14 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Cache only, so nothing here needs to survive a restart. It is not optional though: it
|
# Doubles as the Celery broker/result backend for `worker`/`beat` (see rosterchief/settings.py)
|
||||||
# is what keeps every gunicorn worker agreeing about which feature flags are on. maxmemory
|
# as well as the cache. maxmemory-policy allkeys-lru is right for a cache — evict rather than
|
||||||
# is a ceiling, not a saving — this is already the smallest process in the stack — but on a
|
# grow unbounded — but it means a queued task message COULD be evicted under memory pressure
|
||||||
# memory-limited box it should evict cache entries under pressure, not grow unbounded.
|
# 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"]
|
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "32mb", "--maxmemory-policy", "allkeys-lru"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -86,3 +126,4 @@ volumes:
|
|||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
media_data:
|
media_data:
|
||||||
|
private_media_data:
|
||||||
|
|||||||
16
controlpanel/context_processors.py
Normal file
16
controlpanel/context_processors.py
Normal 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()}
|
||||||
51
controlpanel/services/jobs.py
Normal file
51
controlpanel/services/jobs.py
Normal 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]
|
||||||
@@ -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():
|
def platform_totals():
|
||||||
return {
|
return {
|
||||||
"clubs": Club.objects.active().count(),
|
"clubs": Club.objects.active().count(),
|
||||||
|
|||||||
112
controlpanel/templates/controlpanel/_auth_base.html
Normal file
112
controlpanel/templates/controlpanel/_auth_base.html
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
{% load static lucide ui %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Standalone shell for every sitewide allauth screen -- login, password change/reset,
|
||||||
|
MFA, passkeys, recovery codes -- plus 403.html/maintenance.html, rendered whenever
|
||||||
|
there is no club tenant (see club/context_processors.py: this is
|
||||||
|
PLATFORM_BASE_TEMPLATE). Same industrial design language as controlpanel/base.html --
|
||||||
|
dark ink chrome, Barlow/Barlow Condensed/IBM Plex Mono, assets/controlpanel.css --
|
||||||
|
but deliberately simpler: one centred card on a dark page, not a full command-bar
|
||||||
|
app shell, since these are public entrance screens for the whole platform (every
|
||||||
|
club admin and base-domain account), not the control panel itself.
|
||||||
|
|
||||||
|
Block names match what templates/_base.html used to provide (head_title, extra_head,
|
||||||
|
main, extra_body) rather than inventing new ones: templates/allauth/layouts/base.html
|
||||||
|
and templates/403.html/maintenance.html target those names directly, and both are
|
||||||
|
shared with the club-branded skin (_club_base.html, still on assets/app.css and real
|
||||||
|
daisyUI, untouched) -- give them a different block name here and they would have
|
||||||
|
nothing to override on this side of the fork.
|
||||||
|
{% endcomment %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<title>
|
||||||
|
{% block head_title %}{% endblock head_title %} · RosterChief
|
||||||
|
</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
|
||||||
|
{% block extra_head %}{% endblock extra_head %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="flex min-h-screen flex-col items-center gap-10 bg-ink px-4 py-14 font-sans text-slate">
|
||||||
|
{# Explicit bg-ink here too, not just on <body>: the white-on-dark brand mark must stay legible on its own. #}
|
||||||
|
<div class="flex w-full max-w-md items-center justify-between gap-4 bg-ink py-1">
|
||||||
|
<a class="flex min-w-0 shrink-0 items-center gap-2.5" href="/">
|
||||||
|
{# The real mark, not the .crest clip-path fallback (that's for clubs with no logo of their own) -- white-on-dark variant for this page. #}
|
||||||
|
<img class="h-8 w-8 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="32" height="32">
|
||||||
|
<span class="font-display text-xl font-extrabold tracking-[.1em] text-white uppercase">RosterChief</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex shrink-0 items-center gap-1.5 font-mono text-xs text-on-dark-dim hover:text-white" href="/">
|
||||||
|
{% lucide "arrow-left" size=14 %} Back to site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="flex w-full max-w-md flex-1 flex-col justify-center gap-4">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% with alert=message|as_alert %}
|
||||||
|
<div class="alert {{ alert.css }}" role="alert">
|
||||||
|
{% lucide alert.icon size=18 %}
|
||||||
|
<div>
|
||||||
|
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
|
||||||
|
<div class="text-sm">{{ alert.body }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block main %}{% endblock main %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<p class="font-mono text-[11px] text-on-dark-faint">© {% now "Y" %} RosterChief</p>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
|
||||||
|
the same field (templates/allauth/elements/fields.html). The boxed .otp layout
|
||||||
|
only fits six, so past that this falls back to a plain .input-lg rather than
|
||||||
|
letting the text spill out of the boxes.
|
||||||
|
|
||||||
|
The real <input>'s own text is invisible (assets/controlpanel.css: `.otp input`
|
||||||
|
is `color: transparent`, only its caret shows) -- this writes each typed
|
||||||
|
character into its matching <span> directly instead, which is exact by
|
||||||
|
construction. A pure-CSS letter-spacing overlay (spacing the real glyphs to
|
||||||
|
match the box pitch) was tried first and drifted more with every character
|
||||||
|
typed, in a way font-metric tuning couldn't reliably fix.
|
||||||
|
{% endcomment %}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll("[data-otp]").forEach((otp) => {
|
||||||
|
const input = otp.querySelector("input");
|
||||||
|
const boxes = otp.querySelectorAll("span");
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const boxed = input.value.length <= boxes.length;
|
||||||
|
otp.classList.toggle("otp", boxed);
|
||||||
|
otp.classList.toggle("otp-lg", boxed);
|
||||||
|
boxes.forEach((box, index) => {
|
||||||
|
box.classList.toggle("hidden", !boxed);
|
||||||
|
box.textContent = boxed ? input.value[index] || "" : "";
|
||||||
|
});
|
||||||
|
input.classList.toggle("input", !boxed);
|
||||||
|
input.classList.toggle("input-lg", !boxed);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", fit);
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
allauth puts page-level scripts and out-of-form markup here -- notably the
|
||||||
|
hidden `mfa_login` form the passkey button submits on the login page. Without
|
||||||
|
this block that form is never rendered and "Sign in with a passkey" is dead.
|
||||||
|
{% endcomment %}
|
||||||
|
{% block extra_body %}{% endblock extra_body %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,38 +4,36 @@
|
|||||||
Club-scoped admins, and the modals to add one / confirm removing one. Included with
|
Club-scoped admins, and the modals to add one / confirm removing one. Included with
|
||||||
`club`, `admins`, `admin_form` already in context.
|
`club`, `admins`, `admin_form` already in context.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card flex flex-col">
|
||||||
<div class="card-body">
|
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Club admins</span>
|
||||||
<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=14 %} Add admin</button>
|
||||||
<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>
|
<div class="overflow-x-auto">
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for role in admins %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<td class="font-semibold text-ink">{{ role.member }}</td>
|
||||||
<th>Email</th>
|
<td class="font-mono text-xs text-muted">{{ role.member.user.email|default:"—" }}</td>
|
||||||
<th></th>
|
<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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for role in admins %}
|
<td colspan="3" class="text-center text-muted">No admins yet.</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td>{{ role.member }}</td>
|
{% endfor %}
|
||||||
<td>{{ role.member.user.email|default:"—" }}</td>
|
</tbody>
|
||||||
<td class="text-right">
|
</table>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
|
`dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already
|
||||||
in context.
|
in context.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="card mb-6 bg-base-100 shadow">
|
<div class="card flex flex-col">
|
||||||
<div class="card-body">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-3">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Plan & billing</span>
|
||||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-2">
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||||
<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 %}
|
||||||
{% 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>
|
</button>
|
||||||
{% if not subscription %}
|
{% endif %}
|
||||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
|
{% if subscription %}
|
||||||
{% lucide "hourglass" size=14 %} Start trial
|
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('open_period_modal').showModal()">
|
||||||
</button>
|
{% lucide "calendar-plus" size=14 %} {% if club.is_archived %}Reactivate{% else %}Open period{% endif %}
|
||||||
{% endif %}
|
</button>
|
||||||
{% if subscription %}
|
{% endif %}
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 px-4 py-3">
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Paying up does not un-archive a club on its own -- restoring is a deliberate act,
|
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
|
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.
|
instead of something you have to remember to go and check.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% if club.is_archived and dues_settled %}
|
{% 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 %}
|
{% lucide "circle-check" size=16 %}
|
||||||
<span>This club is archived but owes nothing. Reactivating will restore access and open its next period.</span>
|
<span>This club is archived but owes nothing. Reactivating will restore access and open its next period.</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not subscription %}
|
{% 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 %}
|
{% else %}
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm text-slate">
|
||||||
On plan <strong>{{ subscription.plan.name }}</strong>.
|
On plan <strong class="text-ink">{{ subscription.plan.name }}</strong>.
|
||||||
{% if subscription.trial_ends_at %}
|
{% if subscription.trial_ends_at %}
|
||||||
<span class="badge badge-info badge-sm gap-1">{% lucide "hourglass" size=12 %} Trial</span>
|
<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 %}
|
{% endif %}
|
||||||
{{ subscription.plan.duration_months }}-month periods, archived {{ subscription.plan.grace_days }} days after a period starts if unpaid.
|
{{ subscription.plan.duration_months }}-month periods, archived {{ subscription.plan.grace_days }} days after a period starts if unpaid.
|
||||||
{% if subscription.auto_renew %}
|
{% if subscription.auto_renew %}
|
||||||
@@ -76,28 +76,32 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for due in dues %}
|
{% for due in dues %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="min-w-[220px] whitespace-nowrap">
|
||||||
{{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }}
|
{{ 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>
|
||||||
<td class="text-right tabular-nums">€{{ due.amount|floatformat:2 }}</td>
|
<td class="text-right font-mono 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_paid|floatformat:2 }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if due.status == "paid" %}
|
{% if due.status == "paid" %}
|
||||||
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
<span class="badge badge-success gap-1">{% lucide "check" size=12 %} Paid</span>
|
||||||
{% elif due.status == "waived" %}
|
{% 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 %}
|
{% elif due.grace_until < today %}
|
||||||
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
<span class="badge badge-error gap-1">{% lucide "triangle-alert" size=12 %} Overdue</span>
|
||||||
{% elif due.period_end < today %}
|
{% elif due.period_end < today %}
|
||||||
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
<span class="badge badge-warning gap-1">{% lucide "hourglass" size=12 %} In grace</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-outline">{{ due.get_status_display }}</span>
|
<span class="badge badge-ghost">{{ due.get_status_display }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 %}
|
{% 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 %}
|
{% if not due.payments.all %}
|
||||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -105,11 +109,12 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for payment in due.payments.all %}
|
{% 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">
|
<td colspan="2" class="pl-8">
|
||||||
{% lucide "corner-down-right" size=12 %}
|
{% lucide "corner-down-right" size=12 %}
|
||||||
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
{{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %}
|
||||||
@@ -120,7 +125,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -4,42 +4,34 @@
|
|||||||
Which feature flags apply to this club. Included with `club`, `flags` (from
|
Which feature flags apply to this club. Included with `club`, `flags` (from
|
||||||
`flags_for_club`) already in context.
|
`flags_for_club`) already in context.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="card mb-6 bg-base-100 shadow">
|
<div class="card flex flex-col">
|
||||||
<div class="card-body">
|
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Feature flags</span>
|
||||||
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
<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>
|
||||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="overflow-x-auto">
|
{% for entry in flags %}
|
||||||
<table class="table">
|
<div class="flex items-center gap-3 border-b border-rule px-4 py-2.5 last:border-b-0">
|
||||||
<tbody>
|
<div class="min-w-0 flex-1">
|
||||||
{% for entry in flags %}
|
<div class="font-mono text-xs text-ink">{{ entry.flag.name }}</div>
|
||||||
<tr>
|
<div class="text-xs text-muted">{{ entry.flag.note|default:"—" }}</div>
|
||||||
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
</div>
|
||||||
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
{% if entry.overridden %}
|
||||||
<td class="text-right">
|
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||||
{% if entry.overridden %}
|
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %} shrink-0">
|
||||||
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||||
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
</span>
|
||||||
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
{% else %}
|
||||||
</span>
|
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||||
{% else %}
|
{% csrf_token %}
|
||||||
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
<button class="btn btn-xs shrink-0 gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||||
{% csrf_token %}
|
{% if entry.enabled %}{% lucide "toggle-right" size=14 %} On{% else %}{% lucide "toggle-left" size=14 %} Off{% endif %}
|
||||||
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
</button>
|
||||||
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
</form>
|
||||||
</button>
|
{% endif %}
|
||||||
</form>
|
</div>
|
||||||
{% endif %}
|
{% empty %}
|
||||||
</td>
|
<div class="px-4 py-6 text-center text-sm text-muted">No features defined yet.</div>
|
||||||
</tr>
|
{% endfor %}
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-center opacity-60">No features defined yet.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,64 +28,56 @@
|
|||||||
{% for club in clubs %}
|
{% for club in clubs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-row items-center gap-4">
|
<div class="flex flex-row items-center gap-3">
|
||||||
<div>
|
{% if club.logo %}
|
||||||
{% if club.logo %}
|
<img class="h-10 w-10 shrink-0 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
<img class="h-12 w-12 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
{% else %}
|
||||||
{% else %}
|
<div class="crest flex h-10 w-10 shrink-0 items-center justify-center bg-ink">
|
||||||
<div class="avatar avatar-placeholder">
|
<span class="font-display text-xs font-extrabold text-white">{{ club.initials }}</span>
|
||||||
<div class="w-12 rounded-full bg-neutral text-neutral-content">
|
</div>
|
||||||
<span>{{ club.initials }}</span>
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col">
|
||||||
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
<a class="link link-hover font-semibold text-ink" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||||
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}</div>
|
<div class="font-mono text-[11px] text-muted">{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-1.5">
|
||||||
{% if club.is_archived %}
|
{% 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 %}
|
{% else %}
|
||||||
{% if not club.has_season %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not club.upcoming_events %}
|
{% 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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-right tabular-nums">{{ club.active_members }}</td>
|
<td class="text-right font-mono tabular-nums">{{ club.active_members }}</td>
|
||||||
<td class="text-right tabular-nums">
|
<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.admin_count %}
|
{% 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 %}
|
{% 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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right tabular-nums">{{ club.team_count }}</td>
|
<td class="text-right font-mono 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.upcoming_events }}</td>
|
||||||
|
|
||||||
<td class="text-right">
|
<td class="text-right font-mono text-xs text-muted">
|
||||||
{% if club.plan_name %}
|
{{ club.plan_name|lower|default:"—" }}
|
||||||
<span class="badge badge-accent">{{ club.plan_name|lower }}</span>
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-right">
|
<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 not club.dues_owed %}
|
||||||
{% if club.plan_name %}
|
{% if club.plan_name %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
@@ -94,40 +86,36 @@
|
|||||||
for a paid period AND a waived one (both cover the club, they just differ in
|
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.
|
how). No covered period at all (only cancelled dues, say) shows a dash.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="flex flex-col items-end gap-1">
|
{% if club.covered_status == "waived" %}
|
||||||
{% if club.covered_status == "waived" %}
|
<span class="badge badge-ghost badge-sm">Waived</span>
|
||||||
<span class="badge badge-ghost badge-outline">waived</span>
|
{% elif club.covered_until %}
|
||||||
{% elif club.covered_until %}
|
<span class="badge badge-success badge-sm">Paid</span>
|
||||||
<span class="badge badge-success">paid</span>
|
{% else %}
|
||||||
{% else %}
|
<span class="text-muted">—</span>
|
||||||
-
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
-
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% 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 %}
|
{% elif club.dues_period_end < today %}
|
||||||
<span class="badge badge-warning">grace</span>
|
<span class="badge badge-warning badge-sm">Grace</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-right">
|
<td class="text-right font-mono text-xs whitespace-nowrap text-muted">{{ club.covered_until|date:"j M Y"|default:"—" }}</td>
|
||||||
<span class="whitespace-nowrap">{{ club.covered_until|date:"j M Y"|default:"-" }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,31 +6,31 @@
|
|||||||
Locations page shows, no separate sync step involved. Included with `club`,
|
Locations page shows, no separate sync step involved. Included with `club`,
|
||||||
`home_location`, `home_location_form` already in context.
|
`home_location`, `home_location_form` already in context.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="card mb-6 bg-base-100 shadow">
|
<div class="card flex flex-col">
|
||||||
<div class="card-body">
|
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Home location</span>
|
||||||
<h2 class="card-title text-base">{% lucide "map-pin" size=18 %} Home location</h2>
|
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()">
|
||||||
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()">
|
{% if home_location %}
|
||||||
{% if home_location %}
|
{% lucide "pencil" size=14 %} Edit
|
||||||
{% lucide "pencil" size=16 %} Edit
|
{% else %}
|
||||||
{% else %}
|
{% lucide "plus" size=14 %} Set home location
|
||||||
{% lucide "plus" size=16 %} Set home location
|
{% endif %}
|
||||||
{% endif %}
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<div class="px-4 py-3">
|
||||||
{% if home_location %}
|
{% if home_location %}
|
||||||
<dl class="divide-y divide-base-200">
|
<dl>
|
||||||
<div class="flex items-center justify-between py-2">
|
<div class="flex items-center justify-between py-2">
|
||||||
<dt class="text-sm opacity-70">Name</dt>
|
<dt class="text-sm text-muted">Name</dt>
|
||||||
<dd class="font-semibold">{{ home_location.name }}</dd>
|
<dd class="font-semibold text-ink">{{ home_location.name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between py-2">
|
<div class="flex items-center justify-between py-2">
|
||||||
<dt class="text-sm opacity-70">Address</dt>
|
<dt class="text-sm text-muted">Address</dt>
|
||||||
<dd>{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd>
|
<dd class="text-right text-sm text-slate">{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,17 @@
|
|||||||
{% load lucide %}
|
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
|
The command bar's tabs. One place, included by base.html, so a new section is a new
|
||||||
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
|
<li>-equivalent here and nowhere else -- see design_handoff_rosterchief_platform/README.md
|
||||||
ends up reachable on a desktop and invisible on a phone.
|
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 %}
|
{% endcomment %}
|
||||||
<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="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
|
<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>
|
||||||
{% lucide "layout-dashboard" size=16 %} Dashboard
|
<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>
|
<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>
|
||||||
</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>
|
|
||||||
{% if user.is_superuser %}
|
{% if user.is_superuser %}
|
||||||
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
|
{# Superusers only, exactly as the view is gated: a tab staff cannot follow is a lie. #}
|
||||||
<li>
|
<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>
|
||||||
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
|
|
||||||
{% lucide "user-cog" size=16 %} Platform admins
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a class="flex h-full items-center px-3.5 {% if nav == 'jobs' %}bg-steel text-white border-b-2 border-ice{% else %}text-on-dark-dim hover:text-white{% endif %}" href="{% url 'controlpanel:jobs' %}">jobs</a>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load lucide ui %}
|
{% load lucide ui %}
|
||||||
|
|
||||||
{% block heading %}Platform admins{% endblock heading %}
|
{% block panel_title %}Admins{% endblock panel_title %}
|
||||||
|
|
||||||
{% block subheading %}
|
{% block breadcrumb %}
|
||||||
<p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p>
|
<span class="text-ink">admins</span>
|
||||||
{% endblock subheading %}
|
<span class="text-edge">|</span>
|
||||||
|
<span>{{ admins|length }} platform admin{{ admins|length|pluralize }}</span>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
{% block actions %}
|
{% 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>
|
<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 %}
|
{% 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." %}
|
{% 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">
|
||||||
<div class="card-body">
|
<div class="border-b border-line px-4 py-3">
|
||||||
<div class="overflow-x-auto">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Platform admins</span>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -1,90 +1,131 @@
|
|||||||
{% extends "_platform_base.html" %}
|
{% load static lucide ui %}
|
||||||
{% load lucide %}
|
|
||||||
|
|
||||||
{% block title %}
|
{% comment %}
|
||||||
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
|
Standalone shell for the control panel -- does NOT extend templates/_base.html or
|
||||||
{% endblock title %}
|
_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 %}
|
<title>
|
||||||
<button class="btn btn-ghost btn-square lg:hidden" type="button" onclick="document.getElementById('mobile_nav_modal').showModal()" aria-label="Menu">
|
{% block title %}{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief{% endblock title %}
|
||||||
{% lucide "menu" size=20 %}
|
</title>
|
||||||
</button>
|
|
||||||
{% endblock nav_toggle %}
|
|
||||||
|
|
||||||
{% 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 %}
|
<link rel="stylesheet" href="{% static 'css/controlpanel.css' %}">
|
||||||
{% block nav_icons_class %}hidden items-center lg:flex{% endblock nav_icons_class %}
|
{% block extra_head %}{% endblock extra_head %}
|
||||||
|
</head>
|
||||||
|
|
||||||
{% block menu %}
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
|
App-shell layout: the command bar and breadcrumb strip are pinned, <main> is the only
|
||||||
the day the menu itself grows taller than the screen.
|
scrolling region -- same reasoning templates/_base.html gives for the club-facing shell.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate">
|
||||||
<ul class="menu w-full gap-1 p-3 mt-4">
|
<header class="flex h-[52px] shrink-0 items-center gap-5 bg-ink px-5">
|
||||||
{% include "controlpanel/_nav_items.html" %}
|
<a class="flex shrink-0 items-center gap-2.5" href="{% url 'controlpanel:dashboard' %}">
|
||||||
</ul>
|
{# 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. #}
|
||||||
</aside>
|
<img class="h-7 w-7 shrink-0" src="{% static 'images/rosterchief-white.svg' %}" alt="" width="28" height="28">
|
||||||
{% endblock menu %}
|
<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 %}
|
<nav class="flex h-full font-mono text-xs">
|
||||||
{# 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>
|
|
||||||
{% include "controlpanel/_nav_items.html" %}
|
{% include "controlpanel/_nav_items.html" %}
|
||||||
</ul>
|
</nav>
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button>close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
{% if maintenance_on %}
|
<div class="flex-1"></div>
|
||||||
<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 flex-col gap-2 grow">
|
<div class="flex items-center gap-2.5">
|
||||||
<h1 class="text-3xl font-bold">
|
{% block actions %}{% endblock actions %}
|
||||||
{% block heading %}Control panel{% endblock heading %}
|
|
||||||
</h1>
|
|
||||||
<span class="text-sm text-base-content/50">{% block subheading %}{% endblock subheading %}</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
<main class="flex-1 overflow-y-auto">
|
||||||
{% block actions %}{% endblock actions %}
|
<div class="mx-auto flex w-full max-w-[1440px] flex-col gap-4 px-7 py-6">
|
||||||
</div>
|
{% if maintenance_on %}
|
||||||
</div>
|
<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 %}
|
{% if messages %}
|
||||||
{% endblock main %}
|
<div class="flex flex-col gap-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% with alert=message|as_alert %}
|
||||||
|
<div class="alert {{ alert.css }}" role="alert">
|
||||||
|
{% lucide alert.icon size=18 %}
|
||||||
|
<div>
|
||||||
|
<div class="font-display text-sm font-bold tracking-wide uppercase">{{ alert.title }}</div>
|
||||||
|
<div class="text-sm">{{ alert.body }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block panel %}{% endblock panel %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block extra_body %}{% endblock extra_body %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,147 +1,182 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% 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 %}
|
{% 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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
{% url 'controlpanel:plan_create' as plan_create_url %}
|
{% 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">
|
{# --- Plans ----------------------------------------------------------- #}
|
||||||
<div class="card-body">
|
<div class="card flex flex-col">
|
||||||
<h2 class="card-title text-base">{% lucide "layers" size=18 %} Plans</h2>
|
<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 %}
|
{% comment %}
|
||||||
Prices are dated, not edited. A rate change is a new row with a future
|
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,
|
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 %}
|
{% 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>
|
<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 class="overflow-x-auto">
|
</div>
|
||||||
<table class="table">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<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>
|
<tr>
|
||||||
<th>Plan</th>
|
<td>
|
||||||
<th>Clocks</th>
|
<div class="flex items-center gap-1.5">
|
||||||
<th class="text-right">Clubs</th>
|
<span class="font-semibold text-ink">{{ plan.name }}</span>
|
||||||
<th>Prices</th>
|
{% if plan.is_trial %}<span class="badge badge-info badge-xs">{% trans "Trial" %}</span>{% endif %}
|
||||||
<th></th>
|
{% 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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for plan in plans %}
|
<td colspan="5" class="text-center text-muted">{% trans "No plans yet." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td>
|
{% endfor %}
|
||||||
<div class="font-medium">{{ plan.name }}</div>
|
</tbody>
|
||||||
{% if plan.is_trial %}<span class="badge badge-info badge-xs">Trial</span>{% endif %}
|
</table>
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
{% for plan in plans %}
|
{% for plan in plans %}
|
||||||
{% url 'controlpanel:plan_price_create' plan.pk as plan_price_url %}
|
{% 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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
{# --- Owed -------------------------------------------------------------- #}
|
||||||
<div class="card-body">
|
<div class="card flex flex-col">
|
||||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Owed</h2>
|
<div class="flex items-center gap-3 border-b border-line px-4 py-3">
|
||||||
<div class="overflow-x-auto">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% trans "Owed" %}</span>
|
||||||
<table class="table">
|
<span class="flex-1"></span>
|
||||||
<thead>
|
<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>
|
<tr>
|
||||||
<th>Club</th>
|
<td>
|
||||||
<th>Period</th>
|
<a class="link link-hover font-semibold text-ink" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
|
||||||
<th class="text-right">Owed</th>
|
<div class="text-xs text-muted">{{ due.plan.name }}</div>
|
||||||
<th class="text-right">Status</th>
|
</td>
|
||||||
<th></th>
|
<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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for due in owing %}
|
<td colspan="5" class="text-center text-muted">{% trans "Nothing outstanding." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td>
|
{% endfor %}
|
||||||
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' due.club.pk %}">{{ due.club.name }}</a>
|
</tbody>
|
||||||
<div class="text-xs opacity-60">{{ due.plan.name }}</div>
|
</table>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
{% for due in owing %}
|
{% for due in owing %}
|
||||||
{% url 'controlpanel:due_pay' due.pk as due_pay_url %}
|
{% 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 %}
|
{% endfor %}
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -1,67 +1,77 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load static lucide %}
|
{% load static lucide %}
|
||||||
|
|
||||||
{% block logo %}
|
{% block panel_title %}{{ club.name }}{% endblock panel_title %}
|
||||||
{% 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 heading %}{{ club.name }}{% endblock heading %}
|
{% block breadcrumb %}
|
||||||
|
<a class="hover:text-ink" href="{% url 'controlpanel:club_list' %}">clubs</a>
|
||||||
{% block subheading %}
|
<span>/</span>
|
||||||
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %}
|
<span class="text-ink">{{ club.slug }}</span>
|
||||||
{% if club.is_archived %}
|
<span>/</span>
|
||||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
<span>settings</span>
|
||||||
{% endif %}
|
<div class="flex-1"></div>
|
||||||
{% endblock subheading %}
|
<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 %}
|
{% 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-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">{% lucide "external-link" size=16 %} Open</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 %}
|
{% if club.is_archived %}
|
||||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||||
{% csrf_token %}
|
{% 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>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
|
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
|
||||||
{% csrf_token %}
|
{% 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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
{# --- club header --------------------------------------------------- #}
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{% comment %}
|
||||||
|
The ring is the club's own primary colour, same as on the club's own subdomain --
|
||||||
|
but the control panel never injects a page-wide --color-primary override (it must
|
||||||
|
not dress itself up as the club), so it's set here as a locally-scoped custom
|
||||||
|
property instead: it only reaches this element, not the rest of the panel's
|
||||||
|
buttons and badges.
|
||||||
|
{% endcomment %}
|
||||||
|
{% if club.logo %}
|
||||||
|
<div class="crest ring-primary flex h-[68px] w-[68px] shrink-0 items-center justify-center overflow-hidden bg-ink" {% if club.primary_color %}style="--color-primary: {{ club.primary_color }};"{% endif %}>
|
||||||
|
<img class="h-full w-full object-cover" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="crest flex h-[68px] w-[68px] shrink-0 items-center justify-center bg-ink">
|
||||||
|
<span class="font-display text-xl font-extrabold text-white">{{ club.initials }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-display text-2xl leading-none font-extrabold text-ink uppercase">{{ club.name }}</div>
|
||||||
|
<div class="mt-1.5 font-mono text-xs text-muted">
|
||||||
|
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ club.legal_name }}{% endif %}
|
||||||
|
{% if club.is_archived %}
|
||||||
|
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if club.is_archived %}
|
{% if club.is_archived %}
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning">
|
||||||
{% lucide "alert-triangle" size=20 %}
|
{% 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>
|
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if attention.no_season %}
|
{% if attention.no_season %}
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning">
|
||||||
{% lucide "calendar-x" size=20 %}
|
{% lucide "calendar-x" size=18 %}
|
||||||
<span>
|
<span>No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.</span>
|
||||||
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -70,132 +80,110 @@
|
|||||||
the club's setup, not a statistic: with nobody in a management position the access
|
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.
|
service grants no authority over that team, so nobody can pick the squad.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
{% comment %}<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
{% comment %}<div class="card p-3.5 border-club">
|
||||||
<div class="card-body p-4">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Outstanding</div>
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} 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="text-3xl font-bold 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 class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
|
||||||
</div>
|
|
||||||
</div>{% endcomment %}
|
</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 p-3.5 {% if attention.teams_without_manager %}border-club{% endif %}">
|
||||||
<div class="card-body p-4">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Teams without coach</div>
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-x" size=16 %} 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="text-4xl font-bold tabular-nums font-mono">{{ 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 class="text-xs opacity-60">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>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
|
<div class="card p-3.5">
|
||||||
<div class="card-body p-4">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">Attendance rate</div>
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} Unrostered members</div>
|
<div class="mt-0.5 font-display text-3xl leading-none font-extrabold text-ink tabular-nums">
|
||||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.unrostered }}</div>
|
{% if attention.attendance.turnout is None %}N/A{% else %}{{ attention.attendance.turnout }}%{% endif %}
|
||||||
<div class="text-xs opacity-60">Active members on no team</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mt-1.5 font-mono text-[11px] text-muted">
|
||||||
|
{% if attention.attendance.turnout is None %}
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
|
No events this season
|
||||||
<div class="card-body p-4">
|
{% else %}
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} Pending</div>
|
<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>
|
||||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ attention.pending_approvals }}</div>
|
{% endif %}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</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="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="card bg-base-100 shadow">
|
{% for group in groups %}
|
||||||
<div class="card-body">
|
<div class="card flex flex-col">
|
||||||
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
<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>
|
||||||
<p class="text-sm opacity-70">New members against returning ones.</p>
|
<dl class="px-4">
|
||||||
<div class="h-56">
|
{% for label, value in group.stats %}
|
||||||
<canvas id="signups-chart"></canvas>
|
<div class="flex items-center justify-between py-2">
|
||||||
</div>
|
<dt class="text-sm text-muted">{{ label }}</dt>
|
||||||
|
<dd class="font-mono font-semibold text-ink tabular-nums">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "controlpanel/_club_home_location_card.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Club fee status this season</h2>
|
{% include "controlpanel/_club_features_card.html" %}
|
||||||
<div class="h-56">
|
{% include "controlpanel/_club_admins_card.html" %}
|
||||||
<canvas id="fees-chart"></canvas>
|
{% include "controlpanel/_club_billing_card.html" %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 md:grid-cols-4">
|
|
||||||
{% for group in groups %}
|
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
|
|
||||||
<dl class="divide-y divide-base-200">
|
|
||||||
{% for label, value in group.stats %}
|
|
||||||
<div class="flex items-center justify-between py-2">
|
|
||||||
<dt class="text-sm opacity-70">{{ label }}</dt>
|
|
||||||
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% include "controlpanel/_club_features_card.html" %}
|
|
||||||
{% include "controlpanel/_club_billing_card.html" %}
|
|
||||||
{% include "controlpanel/_club_home_location_card.html" %}
|
|
||||||
{% include "controlpanel/_club_admins_card.html" %}
|
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
{% block extra_body %}
|
{% block extra_body %}
|
||||||
@@ -205,64 +193,50 @@
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
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 = () => {
|
// Stacked: the bar height stays "signups this month" while the split shows where
|
||||||
const ink = css("--color-base-content", "#333");
|
// they came from. Side-by-side bars would answer a different question.
|
||||||
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
const signups = new Chart(document.getElementById("signups-chart"), {
|
||||||
|
type: "bar",
|
||||||
// Stacked: the bar height stays "signups this month" while the split shows where
|
data: {
|
||||||
// they came from. Side-by-side bars would answer a different question.
|
labels: data.signups.map((point) => point.month),
|
||||||
const signups = new Chart(document.getElementById("signups-chart"), {
|
datasets: [
|
||||||
type: "bar",
|
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: "#0B1220"},
|
||||||
data: {
|
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: "#14B8E8"},
|
||||||
labels: data.signups.map((point) => point.month),
|
],
|
||||||
datasets: [
|
},
|
||||||
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5")},
|
options: {
|
||||||
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9")},
|
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}}},
|
// Colour carries the meaning here — unpaid must read as a problem, waived must
|
||||||
scales: {
|
// not — so the slices are pinned to the semantic theme colours, in order.
|
||||||
x: {stacked: true, ticks: {color: ink}, grid: {color: grid}},
|
const fees = new Chart(document.getElementById("fees-chart"), {
|
||||||
y: {stacked: true, beginAtZero: true, ticks: {color: ink, precision: 0}, grid: {color: grid}},
|
type: "pie",
|
||||||
|
data: {
|
||||||
|
labels: data.fees.map((slice) => slice.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data.fees.map((slice) => slice.value),
|
||||||
|
backgroundColor: ["#14A05A", "#F0A22E", "#E4002B", "#8B95A4"],
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
});
|
},
|
||||||
|
options: {
|
||||||
// Colour carries the meaning here — unpaid must read as a problem, waived must
|
responsive: true,
|
||||||
// not — so the slices are pinned to the semantic theme colours, in order.
|
maintainAspectRatio: false,
|
||||||
const fees = new Chart(document.getElementById("fees-chart"), {
|
plugins: {legend: {position: "right", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
|
||||||
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"]});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock extra_body %}
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load lucide ui %}
|
{% 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 %}
|
{% 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">
|
<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 %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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.name %}
|
||||||
{% form_field form.legal_name %}
|
{% form_field form.legal_name %}
|
||||||
{% form_field form.contact_email %}
|
{% form_field form.contact_email %}
|
||||||
@@ -23,22 +31,22 @@
|
|||||||
{% form_field form.sport_type %}
|
{% form_field form.sport_type %}
|
||||||
</div>
|
</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.logo %}
|
||||||
{% form_field form.primary_color %}
|
{% form_field form.primary_color %}
|
||||||
{% form_field form.secondary_color %}
|
{% form_field form.secondary_color %}
|
||||||
</div>
|
</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_start %}
|
||||||
{% form_field form.season_duration_months %}
|
{% form_field form.season_duration_months %}
|
||||||
</div>
|
</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>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load lucide %}
|
{% 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 %}
|
{% block actions %}
|
||||||
{% if show_archived %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<form method="get" class="mb-4 flex gap-2">
|
{% if show_archived %}
|
||||||
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
|
<div class="flex items-center gap-2">
|
||||||
<label class="input">
|
<span class="font-display text-xl font-extrabold tracking-[.04em] text-ink uppercase">Archived clubs</span>
|
||||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
<span class="badge badge-warning">{% lucide "archive" size=12 %} {{ clubs|length }} archived</span>
|
||||||
<input type="search"
|
</div>
|
||||||
name="q"
|
{% endif %}
|
||||||
value="{{ search }}"
|
|
||||||
placeholder="Search clubs…"
|
|
||||||
class="input input-bordered w-full max-w-xs">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
<div class="card">
|
||||||
{% if show_archived %}
|
{% if show_archived %}
|
||||||
{% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
|
{% include "controlpanel/_club_health_table.html" with empty_message="No archived clubs." %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "controlpanel/_club_health_table.html" %}
|
{% include "controlpanel/_club_health_table.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -1,111 +1,213 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load static lucide %}
|
{% load static lucide %}
|
||||||
|
|
||||||
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
|
{% block panel_title %}Platform health{% endblock panel_title %}
|
||||||
{% block subheading %}Welcome back {{ user.member.first_name }} · {% now "d b Y" %}{% endblock subheading %}
|
|
||||||
|
{% 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 %}
|
{% 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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
{# --- KPI row ------------------------------------------------------- #}
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<div class="card-body p-4">
|
<div class="card p-3.5">
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "building-2" size=16 %} Clubs</div>
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">clubs live</div>
|
||||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ totals.clubs }}</div>
|
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.clubs }}</div>
|
||||||
<div class="text-xs opacity-60">Managing {{ totals.members }} member{{ totals.members|pluralize }}</div>
|
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.archived_clubs }} archived</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card p-3.5">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">members</div>
|
||||||
<div class="card-body p-4">
|
<div class="mt-0.5 font-display text-4xl leading-none font-extrabold text-ink tabular-nums">{{ totals.members }}</div>
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "archive" size=16 %} Archived clubs</div>
|
<div class="mt-1.5 font-mono text-[11px] text-muted">{{ totals.admins }} club admin{{ totals.admins|pluralize }}</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>
|
</div>
|
||||||
|
<div class="card p-3.5">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.clubs_without_season %}border-warning{% endif %}">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dues owed</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar-x" size=16 %} No current season</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 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>
|
</div>
|
||||||
|
<div class="card p-3.5 {% if attention.clubs_without_season %}border-warn{% endif %}">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dormant_clubs %}border-warning{% endif %}">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">no season</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "moon-star" size=16 %} Dormant clubs</div>
|
<div class="mt-1.5 font-mono text-[11px] text-muted">can't take signups</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>
|
</div>
|
||||||
|
<div class="card p-3.5 {% if attention.dormant_clubs %}border-warn{% endif %}">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.admins_pending_mfa %}border-warning{% endif %}">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">dormant clubs</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "shield-alert" size=16 %} MFA pending</div>
|
<div class="mt-1.5 font-mono text-[11px] text-muted">no events 30d</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>
|
</div>
|
||||||
|
<div class="card p-3.5 {% if failed_jobs %}border-club{% endif %}">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-success {% if attention.dues_owed %}border-warning{% endif %}">
|
<div class="font-mono text-[10px] tracking-[.08em] text-muted uppercase">failed jobs</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "receipt-euro" size=16 %} Payment pending</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 class="text-4xl font-bold tabular-nums font-mono">€{{ attention.dues_owed|floatformat:2 }}</div>
|
|
||||||
<div class="text-xs opacity-60">
|
|
||||||
{{ attention.dues_in_grace }} in grace ·
|
|
||||||
<span class="{% if attention.dues_overdue %}font-semibold text-error{% endif %}">{{ attention.dues_overdue }} overdue</span>
|
|
||||||
{% comment %}
|
|
||||||
Renewals pending should sit at ~0: the cron job renews clubs 30 days out and
|
|
||||||
then they fall past the horizon. A number that lingers here means the job has
|
|
||||||
stopped and a club is about to use the platform for free — which no other
|
|
||||||
figure on this page reveals, because nothing has been billed yet.
|
|
||||||
{% endcomment %}
|
|
||||||
{% if attention.renewals_pending %}
|
|
||||||
· <span class="font-semibold text-warning">{{ attention.renewals_pending }} awaiting renewal</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[1.6fr_1fr]">
|
||||||
<div class="card bg-base-100 shadow">
|
{# --- left column ------------------------------------------------ #}
|
||||||
<div class="card-body">
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
<div class="card p-4">
|
||||||
<p class="text-sm opacity-70">New members against returning ones, across every club.</p>
|
<div class="mb-3 flex items-center">
|
||||||
<div class="h-56">
|
{# 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>
|
<canvas id="signups-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card flex flex-col">
|
||||||
<div class="card-body">
|
<div class="flex items-center border-b border-line px-4 py-3">
|
||||||
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Club health</span>
|
||||||
<p class="text-sm opacity-70">Tracking club onboarding to ensure a smooth start</p>
|
<span class="flex-1"></span>
|
||||||
<div class="mt-2 space-y-3">
|
<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 %}
|
{% for step in funnel %}
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-center justify-between text-sm">
|
<div class="mb-1 flex items-center gap-1.5 font-mono text-[11px] text-muted">{% lucide step.icon size=12 %} {{ step.label }}</div>
|
||||||
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
|
<div class="font-display text-2xl leading-none font-extrabold text-ink tabular-nums">{{ step.count }}</div>
|
||||||
<span class="font-semibold tabular-nums font-mono">{{ step.count }}</span>
|
<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>
|
|
||||||
<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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
{# --- right column ------------------------------------------------ #}
|
||||||
<div class="card-body">
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="card-title">{% lucide "building-2" size=18 %} Clubs</h2>
|
<div class="rounded bg-ink p-4 text-white">
|
||||||
{% include "controlpanel/_club_health_table.html" %}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
@@ -116,84 +218,28 @@
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
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
|
new Chart(document.getElementById("signups-chart"), {
|
||||||
// rest of the page does — they are CSS variables. Read the computed values, and
|
type: "bar",
|
||||||
// rebuild when the theme attribute changes, or the charts keep the light palette
|
data: {
|
||||||
// after a switch to dark.
|
labels: data.signups.map((point) => point.month),
|
||||||
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
datasets: [
|
||||||
|
{label: "New", data: data.signups.map((point) => point.new), backgroundColor: "#0B1220"},
|
||||||
const render = () => {
|
{label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: "#14B8E8"},
|
||||||
const ink = css("--color-base-content", "#333");
|
],
|
||||||
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
},
|
||||||
|
options: {
|
||||||
// Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of
|
responsive: true,
|
||||||
// them: the axis is rounded to keep the labels short, but the tooltip keeps the
|
maintainAspectRatio: false,
|
||||||
// cents — rounding a euro amount someone is reading off a chart is a lie.
|
plugins: {legend: {position: "bottom", labels: {color: ink, font: {family: "IBM Plex Mono", size: 11}}}},
|
||||||
const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0});
|
scales: {
|
||||||
const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2});
|
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}},
|
||||||
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")},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
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>
|
</script>
|
||||||
{% endblock extra_body %}
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load lucide ui %}
|
{% 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 %}
|
{% 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>
|
<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 %}
|
{% comment %}
|
||||||
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
|
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
|
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 %}
|
{% endcomment %}
|
||||||
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
|
<div class="card overflow-hidden {% if maintenance.is_active %}border-2 border-club{% endif %}">
|
||||||
<div class="card-body">
|
{% if maintenance.is_active %}
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-center gap-2.5 border-b border-club bg-club px-4 py-3">
|
||||||
<div class="w-full">
|
{% lucide "lock" size=18 class="shrink-0 text-white" %}
|
||||||
<h2 class="card-title text-base mb-2">{% lucide "wrench" size=18 %} Maintenance mode</h2>
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-white uppercase">Maintenance mode — platform closed</span>
|
||||||
{% if maintenance.is_active %}
|
<span class="flex-1"></span>
|
||||||
<div class="flex flex-row gap-2 text-sm">
|
<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>
|
||||||
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
|
|
||||||
<div>·</div>
|
|
||||||
<div>since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if maintenance.message %}
|
|
||||||
<div class="my-4">
|
|
||||||
<div class="font-semibold mb-1">Message</div>
|
|
||||||
<div class="p-4 bg-base-300 border-l-4 border-info w-full font-mono">{{ maintenance.message }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm opacity-70">
|
|
||||||
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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' %}">
|
<div class="card-body">
|
||||||
{% csrf_token %}
|
{% if maintenance.is_active %}
|
||||||
{% if not maintenance.is_active %}
|
{% if maintenance.message %}
|
||||||
<div class="form-control my-2 w-full pb-2">
|
<div>
|
||||||
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
|
<div class="mb-1.5 font-mono text-[10px] tracking-[.08em] text-muted uppercase">Message shown to clubs</div>
|
||||||
<span class="label-text">{{ maintenance_form.message.label }}</span>
|
<div class="border border-line bg-subhead px-3 py-2.5 font-mono text-sm text-ink">{{ maintenance.message }}</div>
|
||||||
</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>
|
</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 %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-6 bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
|
{% lucide "flag" size=16 class="shrink-0 text-muted" %}
|
||||||
<p class="text-sm opacity-70">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Flags</span>
|
||||||
Flags are turned on per club. Setting <em>Everyone</em> to Yes or No overrides club targeting entirely.
|
<span class="flex-1"></span>
|
||||||
</p>
|
<span class="font-mono text-[11px] text-muted">{{ flags|length }} flag{{ flags|length|pluralize }}</span>
|
||||||
<div class="overflow-x-auto">
|
</div>
|
||||||
<table class="table">
|
<p class="px-4 pt-3 pb-3 text-sm text-muted">
|
||||||
<thead>
|
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>
|
<tr>
|
||||||
<th>Name</th>
|
<td class="font-mono">{{ flag.name }}</td>
|
||||||
<th>Everyone</th>
|
<td>
|
||||||
<th>Clubs</th>
|
{% if flag.everyone is True %}
|
||||||
<th>Note</th>
|
<span class="badge badge-success">On for all</span>
|
||||||
<th></th>
|
{% 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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for flag in flags %}
|
<td colspan="5" class="text-center text-muted">No features yet.</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="font-mono font-medium">{{ flag.name }}</td>
|
{% endfor %}
|
||||||
<td>
|
</tbody>
|
||||||
{% if flag.everyone is True %}
|
</table>
|
||||||
<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 %}
|
|
||||||
</div>
|
</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>
|
||||||
<div class="card bg-base-100 shadow">
|
|
||||||
<div class="card-body">
|
<div class="card">
|
||||||
<h2 class="card-title text-base">{% lucide "power" size=18 %} Switches</h2>
|
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||||
<p class="text-sm opacity-70">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p>
|
{% lucide "power" size=16 class="shrink-0 text-muted" %}
|
||||||
<div class="overflow-x-auto">
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Switches</span>
|
||||||
<table class="table">
|
<span class="flex-1"></span>
|
||||||
<tbody>
|
<span class="font-mono text-[11px] text-muted">{{ switches|length }} switch{{ switches|length|pluralize }}</span>
|
||||||
{% for switch in switches %}
|
</div>
|
||||||
<tr>
|
<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>
|
||||||
<td class="font-mono font-medium">{{ switch.name }}</td>
|
<div class="overflow-x-auto">
|
||||||
<td class="opacity-70">{{ switch.note|default:"-" }}</td>
|
<table class="table">
|
||||||
<td class="text-right">
|
<thead>
|
||||||
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
|
<tr>
|
||||||
{% csrf_token %}
|
<th>Name</th>
|
||||||
<button class="btn btn-sm gap-1 {% if switch.active %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
<th>Note</th>
|
||||||
{% if switch.active %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
<th></th>
|
||||||
</button>
|
</tr>
|
||||||
</form>
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
{% for switch in switches %}
|
||||||
{% empty %}
|
<tr>
|
||||||
<tr>
|
<td class="font-mono">{{ switch.name }}</td>
|
||||||
<td class="text-center opacity-60">No switches yet — add one in the Django admin.</td>
|
<td class="text-muted">{{ switch.note|default:"—" }}</td>
|
||||||
</tr>
|
<td class="text-right">
|
||||||
{% endfor %}
|
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
|
||||||
</tbody>
|
{% csrf_token %}
|
||||||
</table>
|
<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 }}">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
77
controlpanel/templates/controlpanel/jobs.html
Normal file
77
controlpanel/templates/controlpanel/jobs.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "controlpanel/base.html" %}
|
||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% block panel_title %}Jobs{% endblock panel_title %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<span class="text-ink">jobs</span>
|
||||||
|
<span class="text-edge">|</span>
|
||||||
|
<span>{{ jobs|length }} scheduled</span>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="rounded border border-info-border bg-info-bg px-4 py-3 text-sm text-info-text">
|
||||||
|
{% lucide "info" size=16 class="inline -mt-0.5 mr-1" %}
|
||||||
|
These run on Celery Beat's own schedule (see <span class="font-mono">rosterchief/settings.py</span>) — this page is monitoring only, there is no "run now" here. A run that never appears at its scheduled time is the signal something's wrong with <span class="font-mono">worker</span>/<span class="font-mono">beat</span>, not with this page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for job in jobs %}
|
||||||
|
<div class="card flex flex-col">
|
||||||
|
<div class="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-display text-sm font-extrabold tracking-[.08em] text-ink uppercase">{{ job.label }}</div>
|
||||||
|
<div class="mt-0.5 font-mono text-[11px] text-muted">{{ job.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% if job.latest %}
|
||||||
|
{% if job.latest.status == "success" %}
|
||||||
|
<span class="badge badge-success shrink-0">ok</span>
|
||||||
|
{% elif job.latest.status == "failure" %}
|
||||||
|
<span class="badge badge-error shrink-0">error</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-info shrink-0">running</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge shrink-0">never run</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-2 px-4 py-3 text-sm">
|
||||||
|
{# flex-1 here so the paragraph absorbs whatever space a shorter description leaves, keeping the schedule pill pinned to the bottom of the content block across every card in the row instead of drifting with description length. #}
|
||||||
|
<p class="flex-1 text-slate">{{ job.description }}</p>
|
||||||
|
<div class="badge flex items-center gap-1.5 font-mono text-[11px] text-muted">
|
||||||
|
{% lucide "clock" size=12 %} {{ job.schedule }}
|
||||||
|
</div>
|
||||||
|
{% if job.latest %}
|
||||||
|
<div class="mt-1 font-mono text-[11px] text-muted">
|
||||||
|
last run {{ job.latest.started_at|date:"j M Y, H:i" }}
|
||||||
|
{% if job.latest.duration %}· {{ job.latest.duration.total_seconds|floatformat:1 }}s{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if job.latest.status == "success" and job.latest.detail %}
|
||||||
|
<div class="rounded bg-subhead px-2.5 py-2 font-mono text-xs text-slate">{{ job.latest.detail }}</div>
|
||||||
|
{% elif job.latest.status == "failure" and job.latest.error %}
|
||||||
|
<div class="rounded border border-danger-border bg-danger-bg px-2.5 py-2 font-mono text-xs text-club-dark">{{ job.latest.error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if job.runs|length > 1 %}
|
||||||
|
<div class="mt-auto flex flex-col gap-1.5 border-t border-rule px-4 py-3">
|
||||||
|
<div class="font-mono text-[10px] tracking-[.06em] text-dim uppercase">Recent runs</div>
|
||||||
|
{% for run in job.runs|slice:":5" %}
|
||||||
|
<div class="flex items-center gap-2 font-mono text-[11px]">
|
||||||
|
<span class="text-dim">{{ run.started_at|date:"d/m H:i" }}</span>
|
||||||
|
{% if run.status == "success" %}
|
||||||
|
<span class="text-ok-text">ok</span>
|
||||||
|
{% elif run.status == "failure" %}
|
||||||
|
<span class="text-club-dark">error</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-info-text">running</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
@@ -7,76 +7,96 @@
|
|||||||
that list can be long. See billing.services.plans for what "delete" actually does.
|
that list can be long. See billing.services.plans for what "delete" actually does.
|
||||||
{% endcomment %}
|
{% 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 %}
|
{% block actions %}
|
||||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "arrow-left" size=16 %} {% trans "Back to billing" %}</a>
|
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:billing' %}">{% lucide "arrow-left" size=16 %} {% trans "Back to billing" %}</a>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card mb-6 bg-base-100 shadow">
|
<div class="max-w-3xl">
|
||||||
<div class="card-body">
|
{# --- the plan being removed, and which of the two outcomes applies -- #}
|
||||||
<h2 class="card-title text-base">{% lucide "triangle-alert" size=18 %} {% trans "This can't be undone" %}</h2>
|
<div class="card flex items-start gap-3 border-l-4 border-l-club-dark p-4">
|
||||||
{% if impact.will_hard_delete %}
|
{% lucide "triangle-alert" size=20 class="mt-0.5 shrink-0 text-club-dark" %}
|
||||||
<p class="text-sm opacity-70">{% blocktrans with plan=plan.name %}“{{ plan }}” has never billed anyone, so it will be removed completely.{% endblocktrans %}</p>
|
<div>
|
||||||
{% else %}
|
<div class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">{% trans "This can't be undone" %}</div>
|
||||||
<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>
|
{% if impact.will_hard_delete %}
|
||||||
{% endif %}
|
<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>
|
||||||
</div>
|
{% else %}
|
||||||
</div>
|
<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 %}
|
||||||
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if impact.broken_trial_clubs %}
|
{# --- clubs currently subscribed to this plan -------------------------- #}
|
||||||
<div class="card mb-6 bg-base-100 shadow border-l-4 border-warning">
|
{% if impact.unsubscribed_clubs %}
|
||||||
<div class="card-body">
|
<div class="card mt-4 flex flex-col border-l-4 border-l-club-dark">
|
||||||
<h2 class="card-title text-base">
|
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||||
{% lucide "hourglass" size=18 %}
|
{% lucide "building-2" size=16 class="text-club-dark" %}
|
||||||
{% 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 class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">
|
||||||
</h2>
|
{% blocktrans count counter=impact.unsubscribed_clubs|length %}{{ counter }} club is currently on this plan{% plural %}{{ counter }} clubs are currently on this plan{% endblocktrans %}
|
||||||
<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>
|
</span>
|
||||||
<ul class="mt-2 divide-y divide-base-200">
|
</div>
|
||||||
{% for club in impact.broken_trial_clubs %}
|
<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>
|
||||||
<li class="py-2 flex items-center justify-between">
|
<table class="table mt-1">
|
||||||
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
|
<tbody>
|
||||||
<span class="badge badge-warning badge-outline gap-1">{% lucide "octagon-alert" size=12 %} {% trans "Trial needs a new plan" %}</span>
|
{% for club in impact.unsubscribed_clubs %}
|
||||||
</li>
|
<tr>
|
||||||
{% endfor %}
|
<td><a class="link link-hover font-semibold text-ink" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a></td>
|
||||||
</ul>
|
<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>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not impact.has_impact %}
|
{# --- clubs mid-trial, scheduled to land on this plan ------------------- #}
|
||||||
<div class="alert alert-info mb-6">
|
{% if impact.broken_trial_clubs %}
|
||||||
{% lucide "info" size=20 %}
|
<div class="card mt-4 flex flex-col border-l-4 border-l-warn">
|
||||||
<span>{% trans "No club is currently on this plan, or has a trial scheduled to switch to it." %}</span>
|
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||||
</div>
|
{% lucide "hourglass" size=16 class="text-warn-text" %}
|
||||||
{% endif %}
|
<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 %}">
|
{% if not impact.has_impact %}
|
||||||
{% csrf_token %}
|
<div class="alert alert-info mt-4">
|
||||||
<div class="flex flex-row justify-end gap-2">
|
{% 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>
|
<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>
|
<button class="btn btn-error gap-2" type="submit">{% lucide "trash-2" size=16 %} {% trans "Delete plan" %}</button>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -37,13 +37,13 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{% elif field_type == "checkbox" %}
|
{% elif field_type == "checkbox" %}
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
{% if show_as_toggle %}
|
{% 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 %}
|
{% else %}
|
||||||
class="checkbox {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}"
|
class="checkbox mt-0.5 {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
name="{{ field.html_name }}"
|
name="{{ field.html_name }}"
|
||||||
{% if field.value %}checked="checked"{% endif %}
|
{% if field.value %}checked="checked"{% endif %}
|
||||||
|
|||||||
@@ -126,10 +126,13 @@ class ClubManagementTests(ControlPanelTestBase):
|
|||||||
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
|
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
|
||||||
|
|
||||||
def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self):
|
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.sport_type = Club.SportType.ICE_HOCKEY
|
||||||
self.club.save()
|
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 · Ice hockey", html=False)
|
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
|
||||||
|
|
||||||
@@ -665,8 +668,8 @@ class MessageRenderingTests(ControlPanelTestBase):
|
|||||||
# so the generic per-level one ("Careful") must not show.
|
# so the generic per-level one ("Careful") must not show.
|
||||||
response = self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]), follow=True)
|
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, "alert alert-warning border-warning")
|
||||||
self.assertContains(response, '<div class="font-bold">Club archived</div>', html=False)
|
self.assertContains(response, "Club archived")
|
||||||
self.assertContains(response, "<svg") # the lucide icon
|
self.assertContains(response, "<svg") # the lucide icon
|
||||||
|
|
||||||
|
|
||||||
@@ -968,9 +971,9 @@ class DashboardMetricsTests(ControlPanelTestBase):
|
|||||||
def test_the_dashboard_renders_its_metrics_and_charts(self):
|
def test_the_dashboard_renders_its_metrics_and_charts(self):
|
||||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||||
|
|
||||||
self.assertContains(response, "No current season")
|
# The six KPI tiles -- see controlpanel/templates/controlpanel/dashboard.html.
|
||||||
self.assertContains(response, "MFA pending")
|
for label in ("clubs live", "members", "dues owed", "no season", "dormant clubs", "failed jobs"):
|
||||||
self.assertContains(response, "Payment pending")
|
self.assertContains(response, label)
|
||||||
self.assertContains(response, 'id="signups-chart"')
|
self.assertContains(response, 'id="signups-chart"')
|
||||||
self.assertContains(response, "js/chart.js")
|
self.assertContains(response, "js/chart.js")
|
||||||
self.assertIn("signups", response.context["charts"])
|
self.assertIn("signups", response.context["charts"])
|
||||||
@@ -1249,8 +1252,10 @@ class ClubHealthTableTests(TestCase):
|
|||||||
|
|
||||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||||
|
|
||||||
# Health, not vanity: Plan and Dues each name something to act on, next to the counts.
|
# Health, not vanity: a leaner set than the full clubs list -- one row per club is
|
||||||
for column in ("Members", "Admins", "Teams", "Events", "Plan", "Dues"):
|
# 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>")
|
self.assertContains(response, f">{column}</th>")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,6 @@ urlpatterns = [
|
|||||||
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
|
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>/update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"),
|
||||||
path("admins/<uuid:pk>/revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocati
|
|||||||
from .messages import notify
|
from .messages import notify
|
||||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
||||||
from .services.admins import grant_club_admin, revoke_club_admin
|
from .services.admins import grant_club_admin, revoke_club_admin
|
||||||
|
from .services.jobs import job_overview, recent_job_runs
|
||||||
from .services.platform_admins import (
|
from .services.platform_admins import (
|
||||||
PlatformAdminError,
|
PlatformAdminError,
|
||||||
grant_platform_access,
|
grant_platform_access,
|
||||||
@@ -32,7 +33,7 @@ from .services.platform_admins import (
|
|||||||
revoke_platform_access,
|
revoke_platform_access,
|
||||||
set_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()
|
Flag = get_waffle_flag_model()
|
||||||
Switch = get_waffle_switch_model()
|
Switch = get_waffle_switch_model()
|
||||||
@@ -63,12 +64,28 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
|||||||
funnel=onboarding_funnel(),
|
funnel=onboarding_funnel(),
|
||||||
flags=flag_adoption(),
|
flags=flag_adoption(),
|
||||||
charts=platform_charts(),
|
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(),
|
today=timezone.localdate(),
|
||||||
**kwargs,
|
**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):
|
class ClubListView(PlatformStaffRequiredMixin, ListView):
|
||||||
template_name = "controlpanel/club_list.html"
|
template_name = "controlpanel/club_list.html"
|
||||||
context_object_name = "clubs"
|
context_object_name = "clubs"
|
||||||
|
|||||||
278
design_handoff_rosterchief_platform/README.md
Normal file
278
design_handoff_rosterchief_platform/README.md
Normal 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 (3–4 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 26–30px 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, 22–26px, 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, 28–40px, `leading-[.96]`, uppercase | |
|
||||||
|
| Card title | Barlow Condensed 800, 20–24px, uppercase | |
|
||||||
|
| Eyebrow / label | Barlow Condensed 700–800, 11–12px, `tracking-[.14em]`, uppercase, `text-muted` | `font-display font-extrabold text-xs tracking-[.14em] uppercase text-muted` |
|
||||||
|
| Nav / button label | Barlow Condensed 800, 13–17px, `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, 12–13px, `text-muted` | |
|
||||||
|
| **Scoreboard numeral** | Barlow Condensed 800, 26–76px, `leading-none`, `tabular-nums` | `font-display font-extrabold tabular-nums leading-none` |
|
||||||
|
| **Jersey number** | Barlow Condensed 800, 18–26px, `tabular-nums` | |
|
||||||
|
| Data / mono | IBM Plex Mono 400, 10–13px — 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 44–46px tall; primary mobile CTAs 46–52px.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
**Buttons** — height 46px mobile / 34–36px 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, 11–13px, `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), 46–52px 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 28–30px 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 48–54px 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:00–23: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`, 1–30 `warn`, 31–60 `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.
|
||||||
1783
design_handoff_rosterchief_platform/RosterChief Platform.dc.html
Normal file
1783
design_handoff_rosterchief_platform/RosterChief Platform.dc.html
Normal file
File diff suppressed because it is too large
Load Diff
27
design_handoff_rosterchief_platform/github.md
Normal file
27
design_handoff_rosterchief_platform/github.md
Normal 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) |
|
||||||
|
| M1–M6 Member app: home, event RSVP for several, calendar, news article, me & my people, edit personal info | news/models.py, events/models.py (Attendance), members/models.py (Family), club/models.py (ClubMembership) |
|
||||||
|
| C1–C6 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 |
|
||||||
|
| D1–D6 Management: club home, members list & bulk actions, sign-up intake & approval, team & staff assignment, season calendar planning, dues & billing | management/urls.py, members/models.py, teams/models.py, events/models.py, billing/models.py |
|
||||||
|
| D7–D9 Management: member detail drawer, news list & editor, club identity & 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 |
|
||||||
|
| P1–P2 Control panel: platform health, club provisioning & feature flags | controlpanel/services/statistics.py, controlpanel/views.py, features/models.py, billing/models.py |
|
||||||
1225
design_handoff_rosterchief_platform/image-slot.js
Normal file
1225
design_handoff_rosterchief_platform/image-slot.js
Normal file
File diff suppressed because it is too large
Load Diff
352
design_handoff_rosterchief_platform/ios-frame.jsx
Normal file
352
design_handoff_rosterchief_platform/ios-frame.jsx
Normal 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,
|
||||||
|
});
|
||||||
6
design_handoff_rosterchief_platform/rosterchief-dark.svg
Normal file
6
design_handoff_rosterchief_platform/rosterchief-dark.svg
Normal 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 |
1911
design_handoff_rosterchief_platform/support.js
Normal file
1911
design_handoff_rosterchief_platform/support.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
event's season instead of teams/groups (the two are mutually exclusive, see
|
||||||
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
|
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
|
||||||
but only for events that are still in the future — history is never rewritten.
|
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.db.models import Count, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from club.models import ClubMembership, Season
|
from club.models import ClubMembership, Season
|
||||||
|
from club.services.onboarding import blocked_member_ids_for_event
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from teams.models import TeamMembership
|
from teams.models import TeamMembership
|
||||||
@@ -44,7 +52,12 @@ def effective_members(event):
|
|||||||
if group_ids:
|
if group_ids:
|
||||||
member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True))
|
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))
|
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))
|
||||||
|
|
||||||
return Member.objects.filter(id__in=member_ids)
|
return Member.objects.filter(id__in=member_ids)
|
||||||
|
|||||||
220
events/services/calendar.py
Normal file
220
events/services/calendar.py
Normal 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
28
events/tasks.py
Normal 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."
|
||||||
183
events/tests.py
183
events/tests.py
@@ -1,7 +1,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@@ -9,7 +10,8 @@ from django.test import TestCase
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from waffle import get_waffle_flag_model
|
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 members.models import Group, GroupMembership, Member
|
||||||
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ from .services import (
|
|||||||
team_attendance_rate,
|
team_attendance_rate,
|
||||||
team_no_shows,
|
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.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
|
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})
|
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):
|
class AttendanceSyncTests(EventsTestBase):
|
||||||
def test_setting_teams_creates_attendance_for_roster(self):
|
def test_setting_teams_creates_attendance_for_roster(self):
|
||||||
@@ -1289,3 +1352,119 @@ class RefereeServiceTests(EventsTestBase):
|
|||||||
assignment.refresh_from_db()
|
assignment.refresh_from_db()
|
||||||
self.assertEqual(assignment.km_total, Decimal("0"))
|
self.assertEqual(assignment.km_total, Decimal("0"))
|
||||||
self.assertEqual(assignment.total_payable, Decimal("25.00"))
|
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
38
features/jobs.py
Normal 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"),
|
||||||
|
},
|
||||||
|
}
|
||||||
34
features/migrations/0003_jobrun.py
Normal file
34
features/migrations/0003_jobrun.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -151,3 +151,38 @@ class Maintenance(UUIDModel):
|
|||||||
maintenance.save()
|
maintenance.save()
|
||||||
|
|
||||||
return maintenance
|
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
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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.db.models.signals import m2m_changed
|
||||||
from django.dispatch import receiver
|
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"}
|
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.
|
# Reverse edit (club.flags.add(flag)): flush each flag touched.
|
||||||
for flag in Flag.objects.filter(pk__in=pk_set or []):
|
for flag in Flag.objects.filter(pk__in=pk_set or []):
|
||||||
flag.flush()
|
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])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
|
from celery import shared_task
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.management import call_command
|
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 club.models import Club
|
||||||
|
|
||||||
from .models import Maintenance
|
from .jobs import JOB_REGISTRY
|
||||||
|
from .models import JobRun, Maintenance
|
||||||
|
|
||||||
Flag = get_waffle_flag_model()
|
Flag = get_waffle_flag_model()
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -259,3 +262,63 @@ class MaintenanceCommandTests(TestCase):
|
|||||||
Maintenance.start()
|
Maintenance.start()
|
||||||
|
|
||||||
self.run_command("migrate", "--check") # raises SystemExit only if migrations are pending
|
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())
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news
|
|||||||
from waffle import flag_is_active
|
from waffle import flag_is_active
|
||||||
|
|
||||||
from billing.services.notices import club_billing_notice
|
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
|
from members.models import ParentClaim
|
||||||
|
|
||||||
#: Every management URL name, mapped to the nav item it should light up --
|
#: Every management URL name, mapped to the nav item it should light up --
|
||||||
@@ -126,6 +126,57 @@ _NAV_SECTIONS = {
|
|||||||
"invoice_list": "invoice_list",
|
"invoice_list": "invoice_list",
|
||||||
"form_list": "form_list",
|
"form_list": "form_list",
|
||||||
"submission_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."""
|
some other app can never leak into this."""
|
||||||
match = request.resolver_match
|
match = request.resolver_match
|
||||||
if match is None or match.namespace != "management":
|
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):
|
def is_admin(request):
|
||||||
club = getattr(request, "club", None)
|
club = getattr(request, "club", None)
|
||||||
if club is None or not request.user.is_authenticated:
|
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):
|
def billing_notice(request):
|
||||||
@@ -221,13 +279,14 @@ def sidebar_counters(request):
|
|||||||
the same shape RefereeManagementDashboardView's own kpi_no_referee uses for
|
the same shape RefereeManagementDashboardView's own kpi_no_referee uses for
|
||||||
its default "next 10" range).
|
its default "next 10" range).
|
||||||
|
|
||||||
Admin-only, matching how _nav_items.html itself gates both links (`{% if
|
Gated on can_manage_members (real ADMIN or MEMBER_ADMIN), matching how
|
||||||
is_club_admin %}`) -- a coach never sees either link, so there's no reason
|
_nav_items.html itself gates both links -- a coach never sees either link, so
|
||||||
to run either query for them. Always an int when shown, never hidden at 0:
|
there's no reason to run either query for them. Always an int when shown,
|
||||||
"the queue is empty" and "nobody checked" have to read differently.
|
never hidden at 0: "the queue is empty" and "nobody checked" have to read
|
||||||
|
differently.
|
||||||
"""
|
"""
|
||||||
club = getattr(request, "club", None)
|
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}
|
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
|
# Imported here rather than at module level to keep this module's own import
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 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.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||||
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
||||||
@@ -137,6 +137,11 @@ class TeamMembershipForm(forms.ModelForm):
|
|||||||
members = members.exclude(pk__in=taken)
|
members = members.exclude(pk__in=taken)
|
||||||
self.fields["member"].queryset = members
|
self.fields["member"].queryset = members
|
||||||
self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False)
|
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):
|
def clean(self):
|
||||||
cleaned = super().clean()
|
cleaned = super().clean()
|
||||||
@@ -865,3 +870,91 @@ class RecordFeePaymentForm(forms.Form):
|
|||||||
method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
|
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."))
|
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}))
|
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}))
|
||||||
|
|||||||
111
management/templates/management/_auth_base.html
Normal file
111
management/templates/management/_auth_base.html
Normal 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 %} · {{ 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">© {% 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>
|
||||||
@@ -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
|
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.
|
leaves it unset, since staying on the family page is already the right place there.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table table-cards">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>{% trans "Name" %}</th>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Email" %}</th>
|
||||||
<th>{% trans "Email" %}</th>
|
<th>{% trans "Type" %}</th>
|
||||||
<th>{% trans "Type" %}</th>
|
<th></th>
|
||||||
<th></th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for person in group.all %}
|
||||||
{% for person in group.all %}
|
<tr>
|
||||||
<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><a class="link link-hover font-semibold" 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 data-label="{% trans 'Email' %}">{{ person.contact_email|default:"-" }}</td>
|
<td>
|
||||||
<td data-label="{% trans 'Type' %}">
|
{% if is_club_admin %}
|
||||||
{% if is_club_admin %}
|
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
|
||||||
<form method="post" action="{% url 'management:family_membership_role_update' group.family.pk person.pk %}">
|
{% csrf_token %}
|
||||||
{% csrf_token %}
|
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||||
{% 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' %}">
|
||||||
<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 %}
|
||||||
{% for value, label in family_role_choices %}
|
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
|
||||||
<option value="{{ value }}" {% if value == person.role_in_family %}selected{% endif %}>{{ label|capfirst }}</option>
|
{% endfor %}
|
||||||
{% endfor %}
|
</select>
|
||||||
</select>
|
</form>
|
||||||
</form>
|
{% else %}
|
||||||
{% else %}
|
<span class="badge badge-sm badge-ghost">{{ person.role_in_family_display|capfirst }}</span>
|
||||||
<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 %}
|
{% endif %}
|
||||||
</td>
|
<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>
|
||||||
<td class="text-right">
|
<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' %}">
|
||||||
{% if is_club_admin %}
|
{% lucide "user-x" size=13 %} {% trans "Remove" %}
|
||||||
<div class="flex flex-wrap justify-end gap-1">
|
</button>
|
||||||
{% if person.grant_login_form %}
|
<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' %}">
|
||||||
<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 "trash-2" size=13 %} {% trans "Delete" %}
|
||||||
{% lucide "key-round" size=14 %} {% trans "Grant login" %}
|
</button>
|
||||||
</button>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</td>
|
||||||
<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' %}">
|
</tr>
|
||||||
{% lucide "user-x" size=14 %} {% trans "Remove" %}
|
{% endfor %}
|
||||||
</button>
|
</tbody>
|
||||||
<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' %}">
|
</table>
|
||||||
{% lucide "trash-2" size=14 %} {% trans "Delete" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
{% trans "Delete member" as delete_member_title %}
|
{% trans "Delete member" as delete_member_title %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n lucide %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Shared placeholder for every entity that doesn't have its own list template yet
|
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 heading %}{{ page_title }}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="alert alert-info">
|
||||||
<div class="card-body">
|
{% lucide "construction" size=18 %}
|
||||||
<div class="overflow-x-auto">
|
<span>{% trans "This section is still being built. For now it only lists what's on file." %}</span>
|
||||||
<table class="table">
|
</div>
|
||||||
<tbody>
|
|
||||||
{% for object in object_list %}
|
<div class="card overflow-hidden">
|
||||||
<tr>
|
<div class="overflow-x-auto">
|
||||||
<td>{{ object }}</td>
|
<table class="table">
|
||||||
</tr>
|
<tbody>
|
||||||
{% empty %}
|
{% for object in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center opacity-60">{% trans "Nothing here yet." %}</td>
|
<td>{{ object }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td class="text-center text-muted">{% trans "Nothing here yet." %}</td>
|
||||||
</div>
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<tr class="bulk-add-row">
|
<tr class="bulk-add-row">
|
||||||
<td>
|
<td>
|
||||||
{{ form.member }}
|
{{ form.member }}
|
||||||
{% for error in form.member.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="text-xs text-error mt-1">{{ 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="w-12">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -1,74 +1,109 @@
|
|||||||
{% load i18n lucide %}
|
{% load i18n lucide %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
The management nav, in one place: the sidebar renders it on a wide screen and the
|
The two-level sidebar (design_handoff_rosterchief_platform/README.md, D1/D2): seven
|
||||||
collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
|
top-level sections -- Overview/Members/Teams/Calendar/News/Finance/Settings -- each
|
||||||
plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
|
except News expanding into sub-items while it (or a sub-item) is active. `nav`/
|
||||||
just so the nav never shows a link they can't follow.
|
`nav_section` come from management.context_processors.active_nav_section.
|
||||||
|
|
||||||
`nav` (management.context_processors.active_nav_section) is the current page's
|
Admin-only sub-items are hidden here for plain staff -- the views are gated
|
||||||
section, derived from the resolved URL name -- `menu-active` is daisyUI's active
|
regardless (ClubAdminRequiredMixin), this is just so the nav never shows a link
|
||||||
state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
|
they can't follow. Same reasoning as the old flat nav this replaces.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<li>
|
<a class="nav-item {% if nav_section == 'overview' %}active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=17 %} {% trans "Overview" %}</a>
|
||||||
<a class="{% if nav == 'home' %}menu-active{% endif %}" href="{% url 'management:home' %}">{% lucide "layout-dashboard" size=16 %} {% trans "Dashboard" %}</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="menu-title">{% trans "People" %}</li>
|
<div>
|
||||||
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
|
<a class="nav-item {% if nav_section == 'members' %}active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=17 %} {% trans "Members" %}</a>
|
||||||
<li><a class="{% if nav == 'family_list' %}menu-active{% endif %}" href="{% url 'management:family_list' %}">{% lucide "home" size=16 %} {% trans "Families" %}</a></li>
|
{% if nav_section == 'members' %}
|
||||||
{% if is_club_admin %}
|
<div class="flex flex-col">
|
||||||
<li>
|
<a class="nav-subitem {% if nav == 'member_list' %}active{% endif %}" href="{% url 'management:member_list' %}">{% trans "All members" %}</a>
|
||||||
<a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">
|
{% if is_club_admin %}
|
||||||
{% lucide "inbox" size=16 %} {% trans "Parent claims" %}
|
{# Sign-up is admin only (finance-adjacent -- fee status feeds it) even though parent claims/households/groups below open up to MEMBER_ADMIN too. #}
|
||||||
<span class="badge badge-sm ml-auto {% if pending_parent_claims_count %}badge-error{% else %}badge-neutral{% endif %}">{{ pending_parent_claims_count }}</span>
|
<a class="nav-subitem {% if nav == 'signup_list' %}active{% endif %}" href="{% url 'management:signup_list' %}">{% trans "Sign-up" %}</a>
|
||||||
</a>
|
{% endif %}
|
||||||
</li>
|
{% if can_manage_members %}
|
||||||
{% endif %}
|
{# Membership dues/fee tracking (management:membership_list) lives under Finance, not here -- see that section below. #}
|
||||||
{% 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>
|
<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" %}
|
||||||
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
|
{% if pending_parent_claims_count %}<span class="badge badge-error badge-xs">{{ pending_parent_claims_count }}</span>{% endif %}
|
||||||
<li><a class="{% if nav == 'group_list' %}menu-active{% endif %}" href="{% url 'management:group_list' %}">{% lucide "users-round" size=16 %} {% trans "Groups" %}</a></li>
|
</a>
|
||||||
{% endif %}
|
-->
|
||||||
|
{% 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>
|
<div>
|
||||||
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
|
<a class="nav-item {% if nav_section == 'teams' %}active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=17 %} {% trans "Teams" %}</a>
|
||||||
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
|
{% if nav_section == 'teams' %}
|
||||||
{% if is_club_admin %}
|
<div class="flex flex-col">
|
||||||
<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>
|
<a class="nav-subitem {% if nav == 'team_list' %}active{% endif %}" href="{% url 'management:team_list' %}">{% trans "All teams" %}</a>
|
||||||
<li>
|
<a class="nav-subitem {% if nav == 'referee_list' %}active{% endif %}" href="{% url 'management:referee_list' %}">{% trans "Referees" %}</a>
|
||||||
<a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">
|
</div>
|
||||||
{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
|
{% endif %}
|
||||||
<span class="badge badge-sm ml-auto {% if games_missing_referees_count %}badge-error{% else %}badge-neutral{% endif %}">{{ games_missing_referees_count }}</span>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<li class="menu-title">{% trans "News" %}</li>
|
<div>
|
||||||
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
|
<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>
|
<a class="nav-item {% if nav_section == 'news' %}active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=17 %} {% trans "News" %}</a>
|
||||||
<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 %}
|
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<li class="menu-title">{% trans "Sponsors" %}</li>
|
<div>
|
||||||
<li><a class="{% if nav == 'sponsor_list' %}menu-active{% endif %}" href="{% url 'management:sponsor_list' %}">{% lucide "handshake" size=16 %} {% trans "Sponsors" %}</a></li>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_club_admin and shop_enabled %}
|
{% if is_club_admin or can_manage_members %}
|
||||||
<li class="menu-title">{% trans "Shop" %}</li>
|
<div>
|
||||||
<li><a class="{% if nav == 'product_list' %}menu-active{% endif %}" href="{% url 'management:product_list' %}">{% lucide "package" size=16 %} {% trans "Products" %}</a></li>
|
{# MEMBER_ADMIN has no Club identity access, so their landing click on "Settings" itself goes to the first sub-item they can actually reach. #}
|
||||||
<li><a class="{% if nav == 'order_list' %}menu-active{% endif %}" href="{% url 'management:order_list' %}">{% lucide "shopping-cart" size=16 %} {% trans "Orders" %}</a></li>
|
<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>
|
||||||
<li><a class="{% if nav == 'discount_list' %}menu-active{% endif %}" href="{% url 'management:discount_list' %}">{% lucide "percent" size=16 %} {% trans "Discounts" %}</a></li>
|
{% if nav_section == 'settings' %}
|
||||||
<li><a class="{% if nav == 'invoice_list' %}menu-active{% endif %}" href="{% url 'management:invoice_list' %}">{% lucide "receipt" size=16 %} {% trans "Invoices" %}</a></li>
|
<div class="flex flex-col">
|
||||||
{% endif %}
|
{% if is_club_admin %}
|
||||||
|
<a class="nav-subitem {% if nav == 'club_settings' %}active{% endif %}" href="{% url 'management:club_settings' %}">{% trans "Club identity" %}</a>
|
||||||
{% if is_club_admin and forms_enabled %}
|
{% endif %}
|
||||||
<li class="menu-title">{% trans "Forms" %}</li>
|
<a class="nav-subitem {% if nav == 'onboarding_requirement_list' %}active{% endif %}" href="{% url 'management:onboarding_requirement_list' %}">{% trans "Onboarding requirements" %}</a>
|
||||||
<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 %}
|
||||||
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
{% load i18n lucide ui %}
|
{% load i18n lucide ui %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Shared by the event detail page and the referee management dashboard --
|
Shared by the event detail page and the referee management dashboard --
|
||||||
context: event, referees (EventReferee rows, each with a .fee_form when
|
context: event, referees (EventReferee rows -- fee/km/km_rate read straight
|
||||||
can_manage_referees), referee_candidates, referees_full,
|
off the model instance, no separate form object needed), referee_candidates,
|
||||||
can_manage_referees, and an optional next_url to return to after a POST
|
referees_full, can_manage_referees, and an optional next_url to return to
|
||||||
(defaults to the event detail page when blank).
|
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 %}
|
{% endcomment %}
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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" %}
|
{% lucide "flag" size=18 %} {% trans "Referees" %}
|
||||||
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
|
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
|
||||||
{{ referees|length }} / {{ event.max_referees }}
|
{{ 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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-base-200">
|
<ul class="divide-y">
|
||||||
{% for referee in referees %}
|
{% 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">
|
<li class="flex flex-wrap items-center justify-between gap-3 py-2.5">
|
||||||
<span>
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
{{ referee.display_name }}
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
|
<span class="text-sm font-semibold text-ink">{{ referee.display_name }}</span>
|
||||||
{% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
|
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</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 %}
|
{% if can_manage_referees and not referee.fee %}<span class="badge badge-warning badge-xs">{% trans "Fee not set" %}</span>{% endif %}
|
||||||
</span>
|
</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 %}
|
{% if can_manage_referees %}
|
||||||
<div class="flex gap-1 shrink-0">
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
<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>
|
{% 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">€</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 %}">
|
<form method="post" action="{% url 'management:event_referee_remove' event.pk referee.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
{% 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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif referee.total_payable %}
|
||||||
|
<span class="shrink-0 font-mono text-xs text-muted">{% blocktrans with total=referee.total_payable|floatformat:2 %}€{{ total }} due{% endblocktrans %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% empty %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if can_manage_referees %}
|
{% if can_manage_referees and not referees_full %}
|
||||||
{% if referees_full %}
|
<div class="mt-3 flex flex-col gap-2 border-t border-rule pt-3">
|
||||||
<p class="text-sm opacity-60 mt-2">{% trans "This game already has its maximum number of referees." %}</p>
|
<span class="font-mono text-[11px] tracking-[.08em] text-dim uppercase">{% trans "Add a referee" %}</span>
|
||||||
{% else %}
|
|
||||||
{% if referee_candidates %}
|
{% 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 %}
|
{% csrf_token %}
|
||||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
{% 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 %}
|
{% 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 %}>
|
<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 %}
|
{{ candidate }}{% if candidate.has_conflict %} ⚠{% endif %}
|
||||||
@@ -59,32 +92,22 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
|
||||||
</form>
|
</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 %}
|
{% else %}
|
||||||
<p class="text-sm opacity-60 mt-2">
|
<p class="text-sm text-muted">
|
||||||
{% trans "No eligible referees for this team yet." %}
|
{% 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>
|
</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% csrf_token %}
|
||||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
{% 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>
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Add external" %}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if can_manage_referees %}
|
<p class="text-xs text-muted">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
|
||||||
{% trans "Referee fee" as fee_title %}
|
</div>
|
||||||
{% trans "Save" as save_label %}
|
{% elif can_manage_referees and referees_full %}
|
||||||
{% with encoded_next=next_url|urlencode %}
|
<p class="mt-2 text-sm text-muted">{% trans "This game already has its maximum number of referees." %}</p>
|
||||||
{% 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 %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,28 +5,28 @@
|
|||||||
row" button clones from -- so the two can never drift apart.
|
row" button clones from -- so the two can never drift apart.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<tr class="bulk-add-row">
|
<tr class="bulk-add-row">
|
||||||
<td>
|
<td class="py-2">
|
||||||
{{ form.member }}
|
{{ form.member }}
|
||||||
{% for error in form.member.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="text-xs text-error mt-1">{{ 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>
|
<td class="py-2">
|
||||||
{{ form.position }}
|
{{ 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>
|
||||||
<td class="w-24">
|
<td class="w-24 py-2">
|
||||||
{{ form.jersey_number }}
|
{{ 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>
|
||||||
<td class="w-16 text-center">
|
<td class="w-16 py-2 text-center">
|
||||||
{{ form.is_captain }}
|
{{ 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>
|
||||||
<td class="w-16 text-center">
|
<td class="w-16 py-2 text-center">
|
||||||
{{ form.is_alternate_captain }}
|
{{ 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>
|
||||||
<td class="w-12">
|
<td class="w-12 py-2">
|
||||||
<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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,90 +1,169 @@
|
|||||||
{% extends "_club_base.html" %}
|
{% load static lucide ui i18n %}
|
||||||
{% load i18n lucide %}
|
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
The club-staff shell: team managers, coaches and admins only (never parents or
|
Standalone shell for club management -- does NOT extend templates/_base.html or
|
||||||
players -- see club.mixins.ClubStaffRequiredMixin). Mirrors controlpanel/base.html's
|
_club_base.html, and does not load assets/app.css or daisyUI. Same reasoning as
|
||||||
block structure, extending the club's own skin instead of the platform's.
|
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 %}
|
{% 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 %}
|
<title>
|
||||||
{% block section_title %}{% trans "Management" %}{% endblock section_title %}
|
{% block title %}{% block panel_title %}Management{% endblock panel_title %} · {{ club.name }}{% endblock title %}
|
||||||
{% endblock head_title %}
|
</title>
|
||||||
|
|
||||||
{% block nav_toggle %}
|
<link rel="stylesheet" href="{% static 'css/management.css' %}">
|
||||||
<button class="btn btn-ghost btn-square lg:hidden" type="button" onclick="document.getElementById('mobile_nav_modal').showModal()" aria-label="{% trans 'Menu' %}">
|
{% if club.secondary_color %}
|
||||||
{% lucide "menu" size=20 %}
|
<style>
|
||||||
</button>
|
:root {
|
||||||
{% endblock nav_toggle %}
|
--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 %}
|
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate">
|
||||||
{% block nav_icons_class %}hidden items-center lg:flex{% endblock nav_icons_class %}
|
<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 · management</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
{% block menu %}
|
<nav class="flex flex-col gap-0.5 px-2.5">
|
||||||
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
|
{% include "management/_nav_items.html" %}
|
||||||
<ul class="menu w-full gap-1 p-3 mt-4">
|
</nav>
|
||||||
{% include "management/_nav_items.html" %}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
{% endblock menu %}
|
|
||||||
|
|
||||||
{% block main %}
|
<div class="flex-1"></div>
|
||||||
{# 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="px-[18px]">
|
||||||
<div class="modal-box h-full max-h-none w-72 max-w-[85vw] rounded-none p-0">
|
{% comment %}
|
||||||
<div class="flex items-center justify-between border-b border-base-300 p-3">
|
<details>, not a click-driven dropdown -- no JS needed to open it, only
|
||||||
<span class="font-roboto text-lg font-bold tracking-wider">{% trans "Menu" %}</span>
|
the outside-click/Escape listener below to close it (a <details> stays
|
||||||
<form method="dialog">
|
open until its own <summary> is clicked again otherwise, which would be
|
||||||
<button class="btn btn-ghost btn-square btn-sm" aria-label="{% trans 'Close' %}">{% lucide "x" size=18 %}</button>
|
the only sidebar element on the whole page that doesn't dismiss itself).
|
||||||
</form>
|
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>
|
</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>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
<script>
|
||||||
{% block actions %}{% endblock actions %}
|
(() => {
|
||||||
</div>
|
const menu = document.getElementById("user-menu");
|
||||||
</div>
|
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 %}
|
// Search-as-you-type: any [data-autosubmit] input submits its own form a
|
||||||
A final billing notice follows the admin onto every management page -- but only at
|
// beat after typing stops, instead of needing a separate Search button.
|
||||||
error level (inside 7 days of archiving, or already past it). Shown from the moment
|
// Debounced (not one submit per keystroke) so a full-page GET reload isn't
|
||||||
anything is owed it would sit on every screen for weeks and train people to ignore
|
// fired on every character -- 400ms is short enough to feel immediate,
|
||||||
the one week it matters. home.html includes the same partial at every level, hence
|
// long enough that normal typing speed never triggers it mid-word.
|
||||||
the guard here rather than inside the partial.
|
document.querySelectorAll("[data-autosubmit]").forEach((input) => {
|
||||||
{% endcomment %}
|
let timeout;
|
||||||
{% if billing_notice.is_urgent and nav != "home" %}
|
input.addEventListener("input", () => {
|
||||||
{% include "management/_billing_notice.html" %}
|
clearTimeout(timeout);
|
||||||
{% endif %}
|
timeout = setTimeout(() => input.form.requestSubmit(), 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block panel %}{% endblock panel %}
|
{% block extra_body %}{% endblock extra_body %}
|
||||||
{% endblock main %}
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
76
management/templates/management/club_settings.html
Normal file
76
management/templates/management/club_settings.html
Normal 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 %}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
{% load i18n lucide %}
|
{% load i18n lucide %}
|
||||||
|
|
||||||
{% block heading %}{{ event.title }}{% endblock heading %}
|
{% 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 %}
|
{% block actions %}
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
@@ -22,7 +28,7 @@
|
|||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
{% if event.series_id %}
|
{% if event.series_id %}
|
||||||
<div class="alert alert-info mb-6">
|
<div class="alert alert-info">
|
||||||
{% lucide "repeat" size=20 %}
|
{% lucide "repeat" size=20 %}
|
||||||
<span>
|
<span>
|
||||||
{% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %}
|
{% blocktrans with series=event.series %}Part of the recurring series “{{ series }}”.{% endblocktrans %}
|
||||||
@@ -32,54 +38,54 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2>
|
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Details" %}</h2>
|
||||||
<dl class="divide-y divide-base-200">
|
<dl>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Start" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Start" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.start|date:"j M Y H:i" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ event.start|date:"j M Y H:i" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "End" %}</dt>
|
<dt class="text-sm text-muted">{% trans "End" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ event.end|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Gathering" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Gathering" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ event.gathering|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Registration deadline" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Registration deadline" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ event.deadline|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Location" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.location|default:"—" }}</dd>
|
<dd class="text-sm text-ink">{{ event.location|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Opponent" %}</dt>
|
||||||
<dd class="sm:text-right">{{ event.opponent|default:"—" }}</dd>
|
<dd class="text-sm text-ink">{{ event.opponent|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Teams" %}</dt>
|
||||||
<dd class="sm:text-right">{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
<dd class="text-sm text-ink">{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Groups" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Groups" %}</dt>
|
||||||
<dd class="sm:text-right">{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
<dd class="text-sm text-ink">{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if event.club_wide %}
|
{% 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">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Audience" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Audience" %}</dt>
|
||||||
<dd class="sm:text-right"><span class="badge badge-primary">{% trans "Whole club" %}</span></dd>
|
<dd><span class="badge badge-neutral">{% trans "Whole club" %}</span></dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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>
|
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} {% trans "RSVPs" %}</h2>
|
||||||
@@ -89,29 +95,26 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<dl class="divide-y divide-base-200">
|
<dl>
|
||||||
{% for group in attendance_groups %}
|
{% 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">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{{ group.label }}</dt>
|
<dt class="text-sm text-muted">{{ group.label }}</dt>
|
||||||
<dd class="font-semibold tabular-nums font-mono sm:text-right">{{ group.rows|length }}</dd>
|
<dd class="font-mono text-sm font-semibold text-ink tabular-nums">{{ group.rows|length }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% if not has_attendance_rows %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if event.kind == "game" %}
|
{% if event.kind == "game" %}
|
||||||
<div class="mb-6 card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 class="card-title text-base">
|
<h2 class="card-title text-base">{% lucide "trophy" size=18 %} {% trans "Game" %}</h2>
|
||||||
{% 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>
|
|
||||||
{% if can_manage and event.competition %}
|
{% if can_manage and event.competition %}
|
||||||
<form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}">
|
<form method="post" action="{% url 'management:event_fetch_game_info' event.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -119,20 +122,20 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm opacity-70">{% trans "Score" %}</div>
|
<div class="text-sm text-muted">{% trans "Score" %}</div>
|
||||||
<div class="text-sm font-semibold tabular-nums font-mono">
|
<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 %}
|
{% 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>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm opacity-70">{% trans "Competition" %}</div>
|
<div class="text-sm text-muted">{% trans "Competition" %}</div>
|
||||||
<div class="text-sm">{{ event.competition|default:"—" }}</div>
|
<div class="text-sm text-ink">{{ event.competition|default:"—" }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm opacity-70">{% trans "External game ID" %}</div>
|
<div class="text-sm text-muted">{% trans "External game ID" %}</div>
|
||||||
<div class="font-mono text-sm">{{ event.external_game_id|default:"—" }}</div>
|
<div class="font-mono text-sm text-ink">{{ event.external_game_id|default:"—" }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,14 +143,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if event.is_home_game and not referee_management_needed %}
|
{% 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 %}
|
{% lucide "info" size=20 %}
|
||||||
<span>{% trans "Referees for this game are managed by the federation, not the club." %}</span>
|
<span>{% trans "Referees for this game are managed by the federation, not the club." %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if referee_management_needed %}
|
{% if referee_management_needed %}
|
||||||
<div class="mb-6 card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% include "management/_referee_assignment_panel.html" %}
|
{% include "management/_referee_assignment_panel.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,19 +162,19 @@
|
|||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
<dialog id="event_delete_modal" class="modal">
|
<dialog id="event_delete_modal" class="modal">
|
||||||
<div class="modal-box">
|
<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 %}
|
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form">
|
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="event_delete_form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if event.series_id %}
|
{% 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>
|
<p class="py-2 text-sm text-muted">{% trans "Removes this occurrence from the schedule. It won't be regenerated." %}</p>
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
<label class="flex cursor-pointer items-start gap-2 text-sm text-slate">
|
||||||
<input type="checkbox" name="keep_record" class="checkbox">
|
<input type="checkbox" name="keep_record" class="checkbox mt-0.5">
|
||||||
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
<span>{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
@@ -190,37 +193,35 @@
|
|||||||
{% if has_attendance_rows %}
|
{% if has_attendance_rows %}
|
||||||
<dialog id="rsvp_modal" class="modal">
|
<dialog id="rsvp_modal" class="modal">
|
||||||
<div class="modal-box max-w-2xl">
|
<div class="modal-box max-w-2xl">
|
||||||
<h3 class="text-lg font-bold">{% trans "Who responded" %}</h3>
|
<h3 class="text-lg font-bold text-ink">{% trans "Who responded" %}</h3>
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 flex flex-col gap-2">
|
||||||
{% for group in attendance_groups %}
|
{% for group in attendance_groups %}
|
||||||
{% if group.rows %}
|
{% if group.rows %}
|
||||||
<details class="collapse collapse-arrow border border-base-300 bg-base-100">
|
<details class="collapse-arrow rounded-lg border border-line bg-white">
|
||||||
<summary class="collapse-title text-sm font-medium">
|
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-medium text-ink">
|
||||||
<span class="badge badge-sm
|
<span class="badge badge-sm
|
||||||
{% if group.value == "present" %}badge-success
|
{% if group.value == "present" %}badge-success
|
||||||
{% elif group.value == "absent" %}badge-error
|
{% elif group.value == "absent" %}badge-error
|
||||||
{% elif group.value == "excused" %}badge-warning
|
{% elif group.value == "excused" %}badge-warning
|
||||||
{% elif group.value == "selected" %}badge-info
|
{% 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
|
{% elif group.value == "not_selected" %}badge-neutral
|
||||||
{% else %}badge-outline{% endif %}">
|
{% else %}badge-outline{% endif %}">
|
||||||
{{ group.label }}
|
{{ group.label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="opacity-60">({{ group.rows|length }})</span>
|
<span class="text-muted">({{ group.rows|length }})</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="collapse-content">
|
<div class="overflow-x-auto border-t border-rule">
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table table-sm">
|
<tbody>
|
||||||
<tbody>
|
{% for row in group.rows %}
|
||||||
{% for row in group.rows %}
|
<tr>
|
||||||
<tr>
|
<td>{{ row.member }}</td>
|
||||||
<td>{{ row.member }}</td>
|
<td class="text-sm text-muted">{{ row.note|default:"—" }}</td>
|
||||||
<td class="text-sm opacity-70">{{ row.note|default:"—" }}</td>
|
</tr>
|
||||||
</tr>
|
{% endfor %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,48 +4,48 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New event" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card w-full">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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.title %}
|
||||||
{% form_field form.kind %}
|
{% form_field form.kind %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Audience" %}</h3>
|
||||||
<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.teams %}
|
{% form_field form.teams %}
|
||||||
{% form_field form.groups %}
|
{% form_field form.groups %}
|
||||||
{% if "club_wide" in form.fields %}
|
{% if "club_wide" in form.fields %}
|
||||||
{% form_field form.club_wide %}
|
{% form_field form.club_wide %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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.invited_members %}
|
||||||
{% form_field form.excluded_members %}
|
{% form_field form.excluded_members %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Where" %}</h3>
|
||||||
<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.location %}
|
{% form_field form.location %}
|
||||||
<div class="game-only">
|
<div class="game-only">
|
||||||
{% form_field form.opponent %}
|
{% form_field form.opponent %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "When" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "When" %}</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
{% form_field form.start %}
|
{% form_field form.start %}
|
||||||
{% form_field form.end %}
|
{% form_field form.end %}
|
||||||
{% form_field form.gathering %}
|
{% form_field form.gathering %}
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="game-details-section" class="game-only">
|
<div id="game-details-section" class="game-only">
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Game" %}</h3>
|
||||||
<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.competition %}
|
{% form_field form.competition %}
|
||||||
{% form_field form.external_game_id %}
|
{% form_field form.external_game_id %}
|
||||||
{% form_field form.max_referees %}
|
{% form_field form.max_referees %}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,74 +9,78 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_create %}
|
{% 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_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 %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block filter_strip %}
|
||||||
<form method="get" class="mb-4">
|
<form method="get" class="flex flex-wrap items-center gap-2 border-b border-line bg-white px-7 py-3">
|
||||||
<div class="flex flex-row flex-wrap items-center gap-2">
|
<select name="season" class="select w-auto">
|
||||||
<select name="season" class="select select-bordered">
|
{% for season in seasons %}
|
||||||
{% 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>
|
||||||
<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 %}
|
||||||
{% endfor %}
|
</select>
|
||||||
</select>
|
<select name="kind" class="select w-auto">
|
||||||
<select name="kind" class="select select-bordered">
|
<option value="">{% trans "All kinds" %}</option>
|
||||||
<option value="">{% trans "All kinds" %}</option>
|
{% for value, label in event_kinds %}
|
||||||
{% for value, label in event_kinds %}
|
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
{% endfor %}
|
||||||
{% endfor %}
|
</select>
|
||||||
</select>
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate">
|
||||||
<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()">
|
||||||
<input type="checkbox" name="show_past" value="1" class="checkbox" {% if show_past %}checked{% endif %} onchange="this.form.submit()">
|
{% trans "Show past events" %}
|
||||||
<span class="label-text">{% trans "Show past events" %}</span>
|
</label>
|
||||||
</label>
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "filter" size=14 %} {% trans "View" %}</button>
|
||||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
{% endblock filter_strip %}
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
{% block panel %}
|
||||||
<div class="card-body">
|
<div class="card overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-cards">
|
<table class="table">
|
||||||
<thead>
|
<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>
|
<tr>
|
||||||
<th>{% trans "Title" %}</th>
|
<td>
|
||||||
<th>{% trans "Kind" %}</th>
|
<a class="link link-hover font-semibold text-ink" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
|
||||||
<th>{% trans "When" %}</th>
|
{% if event.series_id %}
|
||||||
<th>{% trans "Teams" %}</th>
|
<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>
|
||||||
<th>{% trans "Location" %}</th>
|
{% endif %}
|
||||||
<th></th>
|
</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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for event in events %}
|
<td colspan="6" class="py-8 text-center text-muted">{% trans "No events." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="flex flex-row items-center gap-2 font-semibold">
|
{% endfor %}
|
||||||
<a class="link link-hover" href="{% url 'management:event_detail' event.pk %}">{{ event.title }}</a>
|
</tbody>
|
||||||
{% if event.series_id %}<span class="tooltip" data-tip="{% trans 'Part of a series' %}">{% lucide "repeat" size=12 %}</span>{% endif %}
|
</table>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,21 +91,21 @@
|
|||||||
{% if event.can_manage %}
|
{% if event.can_manage %}
|
||||||
<dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal">
|
<dialog id="{{ event.pk|dom_id:"event_delete_modal" }}" class="modal">
|
||||||
<div class="modal-box">
|
<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 %}
|
{% if event.series_id %}{% trans "Cancel occurrence" %}{% else %}{% trans "Delete event" %}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}">
|
<form method="post" action="{% url 'management:event_delete' event.pk %}" id="{{ event.pk|dom_id:"event_delete_form" }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if event.series_id %}
|
{% if event.series_id %}
|
||||||
{% blocktrans with name=event.title asvar delete_body %}Removes “{{ name }}” from the schedule. It won't be regenerated.{% endblocktrans %}
|
{% 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>
|
<p class="py-2 text-sm text-muted">{{ delete_body }}</p>
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
<label class="flex cursor-pointer items-start gap-2 text-sm text-slate">
|
||||||
<input type="checkbox" name="keep_record" class="checkbox">
|
<input type="checkbox" name="keep_record" class="checkbox mt-0.5">
|
||||||
<span class="label-text">{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
<span>{% trans "Keep a record of it (marks it cancelled instead of deleting it)" %}</span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% blocktrans with name=event.title asvar delete_body %}Delete “{{ name }}”? This cannot be undone.{% endblocktrans %}
|
{% 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 %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
{% load i18n lucide ui %}
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
{% block heading %}{{ series.title }}{% endblock heading %}
|
{% 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 %}
|
{% block actions %}
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
@@ -17,57 +23,57 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2>
|
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} {% trans "Recurrence" %}</h2>
|
||||||
<dl class="divide-y divide-base-200">
|
<dl>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Pattern" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Pattern" %}</dt>
|
||||||
<dd class="sm:text-right">{{ recurrence_summary }}</dd>
|
<dd class="text-sm text-ink">{{ recurrence_summary }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "First occurrence" %}</dt>
|
<dt class="text-sm text-muted">{% trans "First occurrence" %}</dt>
|
||||||
<dd class="sm:text-right">{{ series.dtstart|date:"j M Y H:i" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ series.dtstart|date:"j M Y H:i" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Repeats until" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Repeats until" %}</dt>
|
||||||
<dd class="sm:text-right">{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ series.until|date:"j M Y H:i"|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Generated up to" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Generated up to" %}</dt>
|
||||||
<dd class="sm:text-right">{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
|
<dd class="font-mono text-sm text-ink">{{ series.generated_until|date:"j M Y"|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2>
|
<h2 class="card-title text-base">{% lucide "info" size=18 %} {% trans "Template" %}</h2>
|
||||||
<dl class="divide-y divide-base-200">
|
<dl>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Location" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Location" %}</dt>
|
||||||
<dd class="sm:text-right">{{ series.location|default:"—" }}</dd>
|
<dd class="text-sm text-ink">{{ series.location|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Opponent" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Opponent" %}</dt>
|
||||||
<dd class="sm:text-right">{{ series.opponent|default:"—" }}</dd>
|
<dd class="text-sm text-ink">{{ series.opponent|default:"—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
|
<dt class="text-sm text-muted">{% trans "Teams" %}</dt>
|
||||||
<dd class="sm:text-right">{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
<dd class="text-sm text-ink">{% for team in series.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2>
|
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Occurrences" %}</h2>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-cards">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "When" %}</th>
|
<th>{% trans "When" %}</th>
|
||||||
@@ -77,8 +83,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for occurrence in occurrences %}
|
{% for occurrence in occurrences %}
|
||||||
<tr>
|
<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 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 data-label="{% trans 'Status' %}">
|
<td>
|
||||||
{% if occurrence.cancelled %}
|
{% if occurrence.cancelled %}
|
||||||
<span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span>
|
<span class="badge badge-outline gap-1">{% lucide "ban" size=12 %} {% trans "Cancelled" %}</span>
|
||||||
{% elif occurrence.detached %}
|
{% elif occurrence.detached %}
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -4,25 +4,25 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New series" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card w-full">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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.title %}
|
||||||
{% form_field form.kind %}
|
{% form_field form.kind %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Repeats" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Repeats" %}</h3>
|
||||||
<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.frequency %}
|
{% form_field form.frequency %}
|
||||||
{% form_field form.interval %}
|
{% form_field form.interval %}
|
||||||
{% form_field form.dtstart %}
|
{% form_field form.dtstart %}
|
||||||
@@ -30,44 +30,44 @@
|
|||||||
</div>
|
</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 %}
|
{% 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 %}
|
{% form_field form.weekdays help_text=weekdays_help %}
|
||||||
<details class="collapse collapse-arrow bg-base-200 mt-2">
|
<details class="collapse-arrow mt-3 rounded-lg border border-line bg-white">
|
||||||
<summary class="collapse-title text-sm font-medium">{% trans "Advanced: raw recurrence rule" %}</summary>
|
<summary class="cursor-pointer px-3 py-2 text-sm font-medium text-ink">{% trans "Advanced: raw recurrence rule" %}</summary>
|
||||||
<div class="collapse-content">
|
<div class="border-t border-rule p-3">
|
||||||
{% form_field form.advanced_rrule %}
|
{% form_field form.advanced_rrule %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Audience" %}</h3>
|
||||||
<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.teams %}
|
{% form_field form.teams %}
|
||||||
{% form_field form.groups %}
|
{% form_field form.groups %}
|
||||||
{% if "club_wide" in form.fields %}
|
{% if "club_wide" in form.fields %}
|
||||||
{% form_field form.club_wide %}
|
{% form_field form.club_wide %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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.invited_members %}
|
||||||
{% form_field form.excluded_members %}
|
{% form_field form.excluded_members %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Where" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Where" %}</h3>
|
||||||
<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.location %}
|
{% form_field form.location %}
|
||||||
{% form_field form.opponent %}
|
{% form_field form.opponent %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="my-5 border-t border-line"></div>
|
||||||
<h3 class="text-lg font-semibold mb-3">{% trans "Timing" %}</h3>
|
<h3 class="mb-3 font-display text-xs font-extrabold tracking-[.12em] text-muted uppercase">{% trans "Timing" %}</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
{% form_field form.duration_hours %}
|
{% form_field form.duration_hours %}
|
||||||
{% form_field form.duration_minutes %}
|
{% form_field form.duration_minutes %}
|
||||||
{% form_field form.gathering_minutes_before %}
|
{% form_field form.gathering_minutes_before %}
|
||||||
{% form_field form.deadline_minutes_before %}
|
{% form_field form.deadline_minutes_before %}
|
||||||
</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_series_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,28 +4,26 @@
|
|||||||
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
|
{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
{% trans "Add parent" as add_parent_label %}
|
{% trans "Add parent" as add_parent_label %}
|
||||||
{% trans "Add child" as add_child_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_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>
|
<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 %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
|
||||||
{% include "management/_family_members_table.html" with group=group %}
|
{% include "management/_family_members_table.html" with group=group %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
{% url 'management:family_add_parent' family.pk as add_parent_url %}
|
{% url 'management:family_add_parent' family.pk as add_parent_url %}
|
||||||
{% url 'management:family_add_child' family.pk as add_child_url %}
|
{% url 'management:family_add_child' family.pk as add_child_url %}
|
||||||
{% trans "Add parent" as add_parent_title %}
|
{% trans "Add parent" as add_parent_title %}
|
||||||
{% trans "Add child" as add_child_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 %}
|
{% 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_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" %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -4,41 +4,39 @@
|
|||||||
{% block heading %}{% trans "Add family" %}{% endblock heading %}
|
{% block heading %}{% trans "Add family" %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<form method="post">
|
||||||
<form method="post">
|
{% csrf_token %}
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error my-2">
|
||||||
<span>{{ error }}</span>
|
<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 %}
|
|
||||||
</div>
|
</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>
|
<div class="my-2 h-px bg-rule"></div>
|
||||||
<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="card-actions justify-start pt-2 mt-2">
|
<h2 class="card-title">{% lucide "baby" size=18 %} {% trans "Child" %}</h2>
|
||||||
<a class="btn btn-outline gap-2" href="{% url "management:member_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<p class="text-sm text-muted">{% trans "No login of their own -- the parent above manages things for them." %}</p>
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
</div>
|
{% form_field form.child_first_name %}
|
||||||
</form>
|
{% form_field form.child_last_name %}
|
||||||
</div>
|
{% 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>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -1,72 +1,75 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n lucide %}
|
{% load i18n lucide %}
|
||||||
|
|
||||||
{% block heading %}{% trans "Families" %}{% endblock heading %}
|
{% block heading %}{% trans "Households" %}{% endblock heading %}
|
||||||
{% block subheading %}{% trans "Every household on file -- who their parents/guardians and children are." %}{% endblock subheading %}
|
|
||||||
|
{% block topbar_context %}
|
||||||
|
<span class="font-mono text-[13px] text-muted">{% trans "Every household on file." %}</span>
|
||||||
|
{% endblock topbar_context %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block filter_strip %}
|
||||||
<form method="get" class="mb-2 flex items-center gap-2">
|
<div class="flex h-[54px] shrink-0 items-center gap-2 border-b border-line bg-white px-7">
|
||||||
<label class="input grow sm:grow-0">
|
<form method="get" class="flex items-center gap-2">
|
||||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
<div class="relative">
|
||||||
<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">
|
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=14 %}</span>
|
||||||
</label>
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search by parent or child name ...' %}" class="input w-72 pl-9">
|
||||||
{# 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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% include "management/_pagination.html" %}
|
{% include "management/_pagination.html" %}
|
||||||
|
|||||||
@@ -2,51 +2,49 @@
|
|||||||
{% load i18n lucide static %}
|
{% load i18n lucide static %}
|
||||||
|
|
||||||
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
|
{% 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 %}
|
{% block panel %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ formset.management_form }}
|
{{ formset.management_form }}
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
{% for error in formset.non_form_errors %}
|
||||||
{% for error in formset.non_form_errors %}
|
<div class="alert alert-error my-2">
|
||||||
<div class="alert alert-error my-2">
|
<span>{{ error }}</span>
|
||||||
<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>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<template id="bulk-add-row-template">
|
{% comment %}
|
||||||
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
|
The member picker's dropdown (searchable-select.js) is position: fixed,
|
||||||
</template>
|
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">
|
<template id="bulk-add-row-template">
|
||||||
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
|
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
|
||||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
|
</template>
|
||||||
</div>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,37 +8,33 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<h2 class="card-title">{% trans "Members" %}</h2>
|
||||||
<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>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% trans "Remove from group" as remove_member_title %}
|
{% trans "Remove from group" as remove_member_title %}
|
||||||
|
|||||||
@@ -4,29 +4,27 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New group" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New group" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<form method="post">
|
||||||
<form method="post">
|
{% csrf_token %}
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error my-2">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="mt-2 flex items-center gap-2 pt-2">
|
||||||
{% for field in form %}
|
<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>
|
||||||
{% form_field field %}
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
{% endfor %}
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,45 +2,44 @@
|
|||||||
{% load i18n lucide ui %}
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
{% block heading %}{% trans "Groups" %}{% endblock heading %}
|
{% 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 %}
|
{% 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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card overflow-hidden">
|
||||||
<div class="card-body">
|
<table class="table">
|
||||||
<div class="overflow-x-auto">
|
<thead>
|
||||||
<table class="table table-cards">
|
<tr>
|
||||||
<thead>
|
<th>{% trans "Name" %}</th>
|
||||||
<tr>
|
<th>{% trans "Members" %}</th>
|
||||||
<th>{% trans "Name" %}</th>
|
<th></th>
|
||||||
<th>{% trans "Members" %}</th>
|
</tr>
|
||||||
<th></th>
|
</thead>
|
||||||
</tr>
|
<tbody>
|
||||||
</thead>
|
{% for group in groups %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for group in groups %}
|
<td><a class="link link-hover font-semibold text-ink" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
|
||||||
<tr>
|
<td class="font-mono text-sm">{{ group.member_count }}</td>
|
||||||
<td><a class="link link-hover font-semibold" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
|
<td class="text-right">
|
||||||
<td data-label="{% trans 'Members' %}">{{ group.member_count }}</td>
|
<div class="flex justify-end gap-1">
|
||||||
<td class="text-right">
|
<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>
|
||||||
<div class="flex justify-end gap-1">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</td>
|
{% empty %}
|
||||||
</tr>
|
<tr>
|
||||||
{% empty %}
|
<td colspan="3" class="py-6 text-center text-muted">{% trans "No groups yet." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td colspan="3" class="text-center opacity-60">{% trans "No groups yet." %}</td>
|
{% endfor %}
|
||||||
</tr>
|
</tbody>
|
||||||
{% endfor %}
|
</table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "management/_pagination.html" %}
|
{% include "management/_pagination.html" %}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n static lucide %}
|
{% load i18n static lucide %}
|
||||||
|
|
||||||
|
{% block panel_title %}Overview{% endblock panel_title %}
|
||||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
{% 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 %}
|
{% block panel %}
|
||||||
{# Every level here; base.html repeats it on other pages only once it turns urgent. #}
|
{# Every level here; base.html repeats it on other pages only once it turns urgent. #}
|
||||||
{% include "management/_billing_notice.html" %}
|
{% include "management/_billing_notice.html" %}
|
||||||
|
|
||||||
{% if attention.no_season %}
|
{% if attention.no_season %}
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning">
|
||||||
{% lucide "calendar-x" size=20 %}
|
{% lucide "calendar-x" size=20 %}
|
||||||
<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>
|
||||||
{% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_club_admin and billing_ends_at %}
|
{% 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 %}
|
{% lucide "calendar-clock" size=20 %}
|
||||||
<span>
|
<span>
|
||||||
{% if billing_auto_renews %}
|
{% if billing_auto_renews %}
|
||||||
@@ -30,167 +38,198 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% comment %}
|
<div class="flex gap-3.5">
|
||||||
The club's own numbers that should be zero -- same attention/chart/stat-group
|
<div class="card min-w-0 flex-1 p-4">
|
||||||
data controlpanel/club_detail.html shows a platform admin drilling into this
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Total members" %}</div>
|
||||||
club from outside; club_attention/club_charts/club_statistics are already
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{{ member_count }}</div>
|
||||||
club-scoped, so this is that same data for the club's own staff. Nothing
|
<div class="mt-1 text-[13px] {% if member_count_change > 0 %}text-ok{% elif member_count_change < 0 %}text-club{% else %}text-muted{% endif %}">
|
||||||
money-shaped renders below for non-admins, same line the nav already draws
|
{% if member_count_change is None %}
|
||||||
around the Shop section.
|
{% trans "No previous season to compare" %}
|
||||||
{% endcomment %}
|
{% else %}
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 md:grid-cols-5">
|
{% if member_count_change > 0 %}+{% endif %}{{ member_count_change }} {% trans "vs last season" %}
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.teams_without_manager %}border-error{% else %}border-success{% endif %}">
|
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card min-w-0 flex-1 p-4">
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.unrostered %}border-warning{% else %}border-success{% endif %}">
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Awaiting approval" %}</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}</div>
|
<div class="mt-1 text-[13px] text-muted">{% trans "Memberships to review" %}</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>
|
</div>
|
||||||
|
<div class="card min-w-0 flex-1 p-4">
|
||||||
<div class="card bg-base-100 shadow border-l-4 {% if attention.pending_approvals %}border-warning{% else %}border-success{% endif %}">
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Teams without coach" %}</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "clock" size=16 %} {% trans "Pending" %}</div>
|
<div class="mt-1 text-[13px] text-muted">{% trans "Nobody can pick a squad" %}</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>
|
</div>
|
||||||
|
<div class="card min-w-0 flex-1 p-4">
|
||||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
<div class="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "Unrostered members" %}</div>
|
||||||
<div class="card-body p-4">
|
<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="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "sparkles" size=16 %} {% trans "New members" %}</div>
|
<div class="mt-1 text-[13px] text-muted">{% trans "Active, on no team" %}</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>
|
</div>
|
||||||
|
<div class="card min-w-0 flex-1 p-4">
|
||||||
<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="font-display text-xs font-bold tracking-[.12em] text-muted uppercase">{% trans "New members" %}</div>
|
||||||
<div class="card-body p-4">
|
<div class="mt-1 font-display text-[34px] leading-none font-extrabold text-ink tabular-nums">{{ attention.new_members }}</div>
|
||||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
|
<div class="mt-1 text-[13px] text-muted">{% trans "First season at this club" %}</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>
|
</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>
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.35fr_1fr]">
|
||||||
<div class="card bg-base-100 shadow">
|
{# --- left: needs attention + signups ----------------------------- #}
|
||||||
<div class="card-body">
|
<div class="flex flex-col gap-5">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="card flex flex-col">
|
||||||
<h2 class="card-title text-base">{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}</h2>
|
<div class="flex items-center border-b border-line px-4.5 py-3.5">
|
||||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:event_list' %}">{% lucide "arrow-right" size=14 %} {% trans "View all" %}</a>
|
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "Needs attention" %}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-base-200">
|
<div class="flex flex-col">
|
||||||
{% for event in upcoming_events %}
|
{% if attention.pending_approvals %}
|
||||||
<li class="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-center gap-3.5 border-b border-rule px-4.5 py-3.5">
|
||||||
<div class="min-w-0">
|
<span class="h-8.5 w-1.5 shrink-0 rounded-sm bg-club"></span>
|
||||||
<div class="truncate font-semibold">{{ event.title }}</div>
|
<div class="flex-1">
|
||||||
<div class="truncate text-xs opacity-60">
|
<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>
|
||||||
{{ event.get_kind_display }}
|
|
||||||
{% for team in event.teams.all %}· {{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 whitespace-nowrap text-sm opacity-70">{{ event.start|date:"D j M, H:i" }}</div>
|
<a class="btn btn-outline btn-sm" href="{% url 'management:member_list' %}?status=pending">{% trans "Review" %}</a>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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" %} →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex-1 p-4.5">
|
||||||
|
<div class="mb-3.5 flex items-center justify-between">
|
||||||
|
<span class="font-display text-base font-extrabold tracking-[.08em] text-ink uppercase">{% trans "News" %}</span>
|
||||||
|
<a class="btn btn-outline btn-sm" href="{% url 'management:news_list' %}">{% trans "View all" %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y">
|
||||||
|
{% for news_item in published_news %}
|
||||||
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<a class="truncate text-sm font-semibold text-ink hover:underline" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a>
|
||||||
|
<span class="shrink-0 font-mono text-xs text-muted">{{ news_item.published_at|date:"j M" }}</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="py-4 text-center text-sm text-muted">{% trans "Nothing published yet." %}</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% for group in groups %}
|
||||||
{% if group.title != "Shop" or is_club_admin %}
|
{% if group.title != "Shop" or is_club_admin %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card p-4">
|
||||||
<div class="card-body">
|
<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>
|
||||||
<h2 class="card-title text-base">{% lucide group.icon size=18 %} {{ group.title }}</h2>
|
<dl>
|
||||||
<dl class="divide-y divide-base-200">
|
{% for label, value in group.stats %}
|
||||||
{% for label, value in group.stats %}
|
<div class="flex items-center justify-between py-1.5">
|
||||||
<div class="flex items-center justify-between py-2">
|
<dt class="text-[13px] text-muted">{{ label }}</dt>
|
||||||
<dt class="text-sm opacity-70">{{ label }}</dt>
|
<dd class="font-mono text-sm font-semibold text-ink">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
||||||
<dd class="font-semibold tabular-nums font-mono">{% if group.title == "Shop" and label == "Outstanding" or label == "Revenue" %}€{% endif %}{{ value }}</dd>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endfor %}
|
</dl>
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -209,96 +248,61 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||||
const renewalRate = JSON.parse(document.getElementById("renewal-rate-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");
|
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");
|
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 renewalCanvas = document.getElementById("renewal-chart");
|
||||||
|
if (renewalCanvas && renewalRate !== null) {
|
||||||
const render = () => {
|
new Chart(renewalCanvas, {
|
||||||
const ink = css("--color-base-content", "#333");
|
type: "pie",
|
||||||
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
data: {
|
||||||
const charts = [];
|
labels: ["{{ renewed_label|escapejs }}", "{{ not_renewed_label|escapejs }}"],
|
||||||
|
datasets: [{data: [renewalRate, 100 - renewalRate], backgroundColor: ["#14A05A", "#E4002B"]}],
|
||||||
// Both canvases are admin-only -- absent entirely for a manager/coach,
|
},
|
||||||
// same gate as the Shop nav section and the rest of this row.
|
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {position: "bottom", labels: {color: ink}}}},
|
||||||
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"]});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock extra_body %}
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -4,24 +4,24 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New location" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New location" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card w-full">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% for field in form %}
|
||||||
{% form_field field %}
|
{% form_field field %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</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="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,44 +8,42 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card overflow-hidden">
|
||||||
<div class="card-body">
|
<div class="overflow-x-auto">
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table table-cards">
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<td class="font-semibold text-ink">
|
||||||
<th>{% trans "Address" %}</th>
|
{{ location.name }}
|
||||||
<th>{% trans "City" %}</th>
|
{% if location.is_home %}<span class="badge badge-sm badge-neutral ml-1">{% trans "Home" %}</span>{% endif %}
|
||||||
<th>{% trans "Country" %}</th>
|
</td>
|
||||||
<th></th>
|
<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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for location in locations %}
|
<td colspan="5" class="py-8 text-center text-muted">{% trans "No locations yet." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="font-semibold">
|
{% endfor %}
|
||||||
{{ location.name }}
|
</tbody>
|
||||||
{% if location.is_home %}<span class="badge badge-sm badge-primary ml-1">{% trans "Home" %}</span>{% endif %}
|
</table>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
{% load lucide ui i18n static %}
|
{% load lucide ui i18n static %}
|
||||||
|
|
||||||
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
|
{% 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 %}
|
{% block actions %}
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
@@ -12,63 +24,60 @@
|
|||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<h2 class="card-title">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
|
||||||
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
|
<dl>
|
||||||
<dl class="divide-y divide-base-200">
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
<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 text-muted">{% trans "Date of birth" %}</dt>
|
||||||
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
|
<dd class="font-mono text-sm font-semibold text-ink">{{ member.date_of_birth|default:"—" }}</dd>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
{% if guardians %}
|
<dt class="text-sm text-muted">{% trans "Phone" %}</dt>
|
||||||
<div class="divider my-1"></div>
|
<dd class="font-mono text-sm font-semibold text-ink">{{ member.phone.as_international|default:"—" }}</dd>
|
||||||
<div class="text-xs font-semibold uppercase opacity-60 flex items-center gap-1 mb-1">
|
</div>
|
||||||
{% 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 "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>
|
</div>
|
||||||
{% for guardian in guardians %}
|
<div class="flex items-center justify-between gap-4 py-2">
|
||||||
{% if guardian.phone or guardian.emergency_phone %}
|
<dt class="text-sm text-muted">{% trans "Parent emergency phone" %} — {{ guardian.get_full_name }}</dt>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center mt-2">
|
<dd class="font-mono text-sm font-semibold text-ink">{{ guardian.emergency_phone.as_international|default:"—" }}</dd>
|
||||||
{% if guardian.phone %}
|
</div>
|
||||||
<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>
|
{% endfor %}
|
||||||
{% endif %}
|
</dl>
|
||||||
{% 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>
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{% endif %}
|
{% if member.phone %}
|
||||||
</div>
|
<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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
@@ -76,126 +85,189 @@
|
|||||||
actually ties this person to *this* club, so it gets its own panel: the
|
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.
|
current season's standing, plus every season they've been part of.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<h2 class="card-title">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
|
||||||
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
|
{% if current_membership %}
|
||||||
{% if current_membership %}
|
{% if current_membership.is_guardian %}
|
||||||
{% if current_membership.is_guardian %}
|
{% comment %}
|
||||||
{% comment %}
|
Stated plainly rather than left to be inferred from an empty
|
||||||
Stated plainly rather than left to be inferred from an empty
|
fee: a guardian is deliberately missing from the member list
|
||||||
fee: a guardian is deliberately missing from the member list
|
and every member count, and someone looking at this page needs
|
||||||
and every member count, and someone looking at this page needs
|
to know that is on purpose. See club.models.ClubMembership.Kind.
|
||||||
to know that is on purpose. See club.models.ClubMembership.Kind.
|
{% endcomment %}
|
||||||
{% endcomment %}
|
<div class="alert alert-warning">
|
||||||
<div class="alert alert-warning py-2 my-1">
|
{% lucide "users" size=16 %}
|
||||||
{% 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>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if referee_profile or is_club_admin %}
|
{% comment %}
|
||||||
<div class="card bg-base-100 shadow mt-4">
|
Onboarding checklist -- club.services.onboarding.checklist_for, paired with
|
||||||
<div class="card-body">
|
MemberRequirementStatus if one exists. Any staff can mark an item done or
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
reopen it, not just admins (same visibility as the rest of this page) --
|
||||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
|
see the module docstring on club/services/onboarding.py for why this stays
|
||||||
{% if is_club_admin %}
|
separate from ClubMembership.status/fee_status.
|
||||||
<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>
|
{% endcomment %}
|
||||||
{% endif %}
|
<div class="card card-body mt-4">
|
||||||
</div>
|
<h2 class="card-title">{% lucide "clipboard-check" size=18 %} {% trans "Documents" %}</h2>
|
||||||
|
{% if not current_membership %}
|
||||||
{% if referee_profile and referee_profile.level %}
|
<p class="text-sm text-muted">{% trans "Not rostered for the current season -- nothing to check off yet." %}</p>
|
||||||
<dl class="divide-y divide-base-200">
|
{% elif not checklist %}
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<p class="text-sm text-muted">{% trans "This club has no onboarding requirements set up." %}</p>
|
||||||
<dt class="text-sm opacity-70">{% trans "Level" %}</dt>
|
{% else %}
|
||||||
<dd class="font-semibold sm:text-right">{{ referee_profile.level }}</dd>
|
<div class="flex flex-col gap-1">
|
||||||
</div>
|
{% for requirement, status in checklist %}
|
||||||
<div class="flex flex-col gap-0.5 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
<div class="flex items-start justify-between gap-4 py-3">
|
||||||
<dt class="text-sm opacity-70">{% trans "Valid until" %}</dt>
|
<div class="min-w-0 flex-1">
|
||||||
<dd class="font-semibold flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{{ referee_profile.valid_until|default:"—" }}
|
<span class="font-semibold text-ink">{{ requirement.name }}</span>
|
||||||
{% if referee_profile.is_currently_valid %}
|
{% if status.is_complete %}
|
||||||
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
|
<span class="badge badge-sm badge-success">{% trans "Complete" %}</span>
|
||||||
{% elif referee_profile.valid_until %}
|
{% elif status %}
|
||||||
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
|
<span class="badge badge-sm badge-warning">{% trans "Incomplete" %}</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% 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">“{{ status.note }}”</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>
|
</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 %}
|
{% if referee_profile or is_club_admin %}
|
||||||
<div class="text-xs font-semibold uppercase opacity-60 mt-2 mb-1">{% trans "Can referee for" %}</div>
|
<div class="card card-body mt-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
{% for team in referee_profile.eligible_teams %}
|
<h2 class="card-title">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
|
||||||
<span class="badge badge-outline">{{ team.name }}</span>
|
{% if is_club_admin %}
|
||||||
{% empty %}
|
<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>
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
@@ -213,23 +285,21 @@
|
|||||||
{% trans "Remove from family" as remove_from_family_title %}
|
{% trans "Remove from family" as remove_from_family_title %}
|
||||||
{% trans "Remove" as remove_label %}
|
{% trans "Remove" as remove_label %}
|
||||||
{% for family_group in family_groups %}
|
{% for family_group in family_groups %}
|
||||||
<div class="card bg-base-100 shadow mt-4">
|
<div class="card card-body mt-4">
|
||||||
<div class="card-body">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<h2 class="card-title">
|
||||||
<h2 class="card-title text-base">
|
{% lucide "users" size=18 %}
|
||||||
{% lucide "users" size=18 %}
|
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
|
||||||
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
|
</h2>
|
||||||
</h2>
|
{% if is_club_admin %}
|
||||||
{% if is_club_admin %}
|
<div class="flex flex-wrap gap-2">
|
||||||
<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_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-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>
|
||||||
<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>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
@@ -244,16 +314,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="card bg-base-100 shadow mt-4">
|
<div class="card card-body mt-4">
|
||||||
<div class="card-body">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<h2 class="card-title">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
{% if not family_groups %}
|
||||||
|
<p class="text-sm text-muted">{% trans "Not part of a family." %}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% url 'management:member_attach_family' member.pk as attach_family_url %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% endblock panel %}
|
||||||
|
|
||||||
{% block extra_body %}
|
{% block extra_body %}
|
||||||
|
|||||||
@@ -10,46 +10,44 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<form method="post">
|
||||||
<form method="post">
|
{% csrf_token %}
|
||||||
{% 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">
|
<div class="alert alert-error my-2">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% for field in membership_form %}
|
||||||
{% form_field field %}
|
{% form_field field %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if membership_form %}
|
<div class="mt-2 flex items-center gap-2 pt-2">
|
||||||
<div class="divider"></div>
|
<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>
|
||||||
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "This season" %}</h2>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
{% for error in membership_form.non_field_errors %}
|
</form>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{% if update_view %}
|
{% if update_view %}
|
||||||
|
|||||||
@@ -8,35 +8,33 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<p class="text-sm text-muted">
|
||||||
<p class="text-sm opacity-70">
|
{% blocktrans %}Fill in the downloaded template — one row per member — then upload it
|
||||||
{% 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
|
||||||
here. Nothing is created yet: you'll see exactly what will be added
|
before anything is saved.{% endblocktrans %}
|
||||||
before anything is saved.{% endblocktrans %}
|
</p>
|
||||||
</p>
|
<p class="text-sm text-muted">
|
||||||
<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 %}
|
||||||
{% 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>
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error my-2">
|
||||||
<span>{{ error }}</span>
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endfor %}
|
||||||
</div>
|
|
||||||
|
{% 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>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -11,80 +11,76 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card card-body">
|
||||||
<div class="card-body">
|
<p class="text-sm text-muted">
|
||||||
<p class="text-sm opacity-70">
|
{% blocktrans count counter=valid_count %}{{ counter }} member will be created.{% plural %}{{ counter }} members will be created.{% endblocktrans %}
|
||||||
{% 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 %}
|
||||||
{% if skipped_count %}{% blocktrans count counter=skipped_count %}{{ counter }} row will be skipped.{% plural %}{{ counter }} rows will be skipped.{% endblocktrans %}{% endif %}
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>{% trans "Row" %}</th>
|
||||||
<th>{% trans "Row" %}</th>
|
<th>{% trans "Last name" %}</th>
|
||||||
<th>{% trans "Last name" %}</th>
|
<th>{% trans "First name" %}</th>
|
||||||
<th>{% trans "First name" %}</th>
|
<th>{% trans "Email" %}</th>
|
||||||
<th>{% trans "Email" %}</th>
|
<th>{% trans "Family" %}</th>
|
||||||
<th>{% trans "Family" %}</th>
|
<th>{% trans "Joining as" %}</th>
|
||||||
<th>{% trans "Joining as" %}</th>
|
<th>{% trans "Outcome" %}</th>
|
||||||
<th>{% trans "Outcome" %}</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for result in results %}
|
||||||
{% for result in results %}
|
<tr>
|
||||||
<tr>
|
<td class="font-mono text-xs">{{ result.line_number }}</td>
|
||||||
<td>{{ result.line_number }}</td>
|
<td>{{ result.raw.last_name }}</td>
|
||||||
<td>{{ result.raw.last_name }}</td>
|
<td>{{ result.raw.first_name }}</td>
|
||||||
<td>{{ result.raw.first_name }}</td>
|
<td class="text-muted">{{ result.raw.email }}</td>
|
||||||
<td>{{ result.raw.email }}</td>
|
<td>
|
||||||
<td>
|
{% if result.family_group %}
|
||||||
{% if result.family_group %}
|
{{ result.family_group }}
|
||||||
{{ result.family_group }}
|
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
|
||||||
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
|
{% else %}
|
||||||
{% else %}
|
<span class="text-dim">—</span>
|
||||||
<span class="opacity-40">-</span>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
{% comment %}
|
||||||
{% comment %}
|
A guardian holds the login for a child but is not a member:
|
||||||
A guardian holds the login for a child but is not a member:
|
no fee, and not counted in any member total. See
|
||||||
no fee, and not counted in any member total. See
|
club.models.ClubMembership.Kind.
|
||||||
club.models.ClubMembership.Kind.
|
{% endcomment %}
|
||||||
{% endcomment %}
|
{% if result.membership_kwargs.kind == "guardian" %}
|
||||||
{% if result.membership_kwargs.kind == "guardian" %}
|
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
|
||||||
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
|
{% else %}
|
||||||
{% else %}
|
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
|
||||||
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
{% if result.member %}
|
||||||
{% if result.member %}
|
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
|
||||||
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
|
{% else %}
|
||||||
{% else %}
|
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
|
||||||
<span class="badge badge-error badge-sm">{% trans "Skipped" %}</span>
|
<div class="mt-1 text-xs text-muted">{{ result.errors|join:"; " }}</div>
|
||||||
<div class="text-xs opacity-70 mt-1">{{ result.errors|join:"; " }}</div>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
{% empty %}
|
||||||
{% empty %}
|
<tr>
|
||||||
<tr>
|
<td colspan="7" class="py-6 text-center text-muted">{% trans "No rows found in the uploaded file." %}</td>
|
||||||
<td colspan="7" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
</tr>
|
||||||
</tr>
|
{% endfor %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-start pt-2 mt-2">
|
<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>
|
<a class="btn btn-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
{% if valid_count %}
|
{% if valid_count %}
|
||||||
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
<form method="post" action="{% url 'management:member_import_confirm' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "check" size=16 %} {% blocktrans %}Confirm import ({{ valid_count }}){% endblocktrans %}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -3,114 +3,194 @@
|
|||||||
|
|
||||||
{% block heading %}{% trans "Members" %}{% endblock heading %}
|
{% 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 %}
|
{% 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 %}
|
{% 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-outline gap-2" href="{% url 'management:member_import' %}">{% lucide "upload" size=16 %} {% 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-outline gap-2" href="{% url 'management:family_create' %}">{% lucide "users" size=16 %} {% 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-primary gap-2" href="{% url 'management:member_create' %}">{% lucide "user-plus" size=16 %} {% trans "Add member" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block filter_strip %}
|
||||||
<form method="get" class="mb-2 flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-3 border-b border-line bg-white px-7 py-2.5">
|
||||||
<label class="input grow sm:grow-0">
|
<form method="get" id="member-filters" class="flex flex-wrap items-center gap-2">
|
||||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
{% if selected_kind != "member" %}<input type="hidden" name="kind" value="{{ selected_kind }}">{% endif %}
|
||||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered w-full sm:max-w-xs">
|
<div class="relative">
|
||||||
</label>
|
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=14 %}</span>
|
||||||
{% if selected_kind != "member" %}<input type="hidden" name="kind" value="{{ selected_kind }}">{% endif %}
|
{# No submit button -- data-autosubmit (base.html-loaded script) submits the form itself, debounced, as soon as typing pauses. #}
|
||||||
{# Icon-only below `sm`: label text next to a growing input is what forced this row to wrap on a phone. #}
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Name, email, licence no.' %}" class="input w-56 pl-9" data-autosubmit>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
</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" %}
|
{% include "management/_pagination.html" %}
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if can_manage_members %}
|
||||||
{% trans "Delete member" as delete_member_title %}
|
{% trans "Delete member" as delete_member_title %}
|
||||||
{% trans "Delete" as delete_label %}
|
{% trans "Delete" as delete_label %}
|
||||||
{% for member in members %}
|
{% for member in members %}
|
||||||
|
|||||||
@@ -1,77 +1,33 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n lucide ui %}
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
{% block heading %}{% trans "Memberships" %}{% endblock heading %}
|
{% block heading %}{% trans "Dues & billing" %}{% endblock heading %}
|
||||||
{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
|
|
||||||
|
{% block topbar_context %}
|
||||||
|
{% if selected_season %}
|
||||||
|
<span class="font-mono text-sm text-muted">{{ selected_season.start_date|date:"Y" }}–{{ selected_season.end_date|date:"Y" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock topbar_context %}
|
||||||
|
|
||||||
{% block actions %}
|
{% 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>
|
<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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block filter_strip %}
|
||||||
{% if not current_season %}
|
<div class="border-b border-line bg-white px-7 py-3">
|
||||||
<div class="alert alert-warning mb-6">
|
<form method="get" class="flex flex-row flex-wrap items-center gap-2">
|
||||||
{% lucide "calendar-x" size=20 %}
|
<div class="relative">
|
||||||
<span>{% trans "No season covers today, so there's nothing to show fee status for yet." %}</span>
|
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=15 %}</span>
|
||||||
</div>
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input w-56 pl-9">
|
||||||
{% 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>
|
|
||||||
</div>
|
</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">
|
<select name="season" class="select w-48">
|
||||||
<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">
|
|
||||||
{% for season in seasons %}
|
{% 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>
|
<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 %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
<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 %}
|
{% for value, label in fee_status_choices %}
|
||||||
<option value="{{ value }}" {% if value == selected_fee_status %}selected{% endif %}>{{ label|capfirst }}</option>
|
<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>
|
<option value="all" {% if selected_fee_status == "all" %}selected{% endif %}>{% trans "All" %}</option>
|
||||||
</select>
|
</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>
|
<option value="all" {% if selected_status == "all" %}selected{% endif %}>{% trans "All statuses" %}</option>
|
||||||
{% for value, label in status_choices %}
|
{% for value, label in status_choices %}
|
||||||
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
|
<option value="{{ value }}" {% if value == selected_status %}selected{% endif %}>{{ label|capfirst }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
<option value="" {% if not selected_team %}selected{% endif %}>{% trans "All teams" %}</option>
|
||||||
{% for team in teams %}
|
{% for team in teams %}
|
||||||
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
|
<option value="{{ team.pk }}" {% if team.pk|stringformat:"s" == selected_team %}selected{% endif %}>{{ team }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="col-span-full flex gap-2 lg:col-span-1 lg:contents">
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||||
<button class="btn btn-outline grow gap-2 lg:grow-0" 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>
|
||||||
<a class="btn grow gap-2 lg:grow-0" 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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
|
<form method="post" action="{% url 'management:membership_mark_paid' %}" id="membership-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card overflow-hidden">
|
||||||
<div class="card-body">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4.5 py-3.5">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
<span class="font-display text-sm font-bold tracking-wide text-muted uppercase">
|
||||||
<span class="text-sm opacity-70 font-semibold">
|
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
|
||||||
{% blocktrans count counter=memberships|length %}{{ counter }} membership{% plural %}{{ counter }} memberships{% endblocktrans %}
|
</span>
|
||||||
</span>
|
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
|
||||||
<button class="btn btn-success btn-sm gap-2" type="submit">{% lucide "circle-check" size=14 %} {% trans "Mark selected as paid" %}</button>
|
</div>
|
||||||
</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">
|
||||||
<div class="overflow-x-auto">
|
<table class="table">
|
||||||
<table class="table table-cards">
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
<th><input type="checkbox" class="checkbox" id="select-all-memberships"></th>
|
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
|
||||||
<th>{% trans "Name" %}</th>
|
<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>
|
||||||
<th>{% trans "Email" %}</th>
|
<td>{{ membership.member.contact_email|default:"-" }}</td>
|
||||||
<th>{% trans "Family" %}</th>
|
<td>
|
||||||
<th>{% trans "Status" %}</th>
|
{% if membership.member.family_memberships_display %}
|
||||||
<th>{% trans "Fee status" %}</th>
|
{% for fm in membership.member.family_memberships_display %}
|
||||||
<th>{% trans "Owed" %}</th>
|
{{ fm.family }}{% if not forloop.last %}, {% endif %}
|
||||||
<th>{% trans "Paid" %}</th>
|
{% endfor %}
|
||||||
<th>{% trans "License" %}</th>
|
{% else %}
|
||||||
<th></th>
|
<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">€ {{ membership.fee_amount }}</td>
|
||||||
|
<td class="font-mono tabular-nums">
|
||||||
|
€ {{ 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>
|
</tr>
|
||||||
</thead>
|
{% empty %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for membership in memberships %}
|
<td colspan="10" class="text-center text-muted">{% trans "Nobody matches these filters." %}</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td><input type="checkbox" class="checkbox membership-row-checkbox" name="membership_ids" value="{{ membership.pk }}"></td>
|
{% endfor %}
|
||||||
<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>
|
</tbody>
|
||||||
<td data-label="{% trans 'Email' %}">{{ membership.member.contact_email|default:"-" }}</td>
|
</table>
|
||||||
<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">€ {{ membership.fee_amount }}</td>
|
|
||||||
<td data-label="{% trans 'Paid' %}" class="tabular-nums">
|
|
||||||
€ {{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,16 +2,17 @@
|
|||||||
{% load i18n lucide ui %}
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
{% block heading %}{{ news_item.title }}{% endblock heading %}
|
{% block heading %}{{ news_item.title }}{% endblock heading %}
|
||||||
{% block subheading %}
|
|
||||||
|
{% block topbar_context %}
|
||||||
{% if news_item.status == "draft" %}
|
{% if news_item.status == "draft" %}
|
||||||
{% trans "Draft" %}
|
<span class="badge">{% trans "Draft" %}</span>
|
||||||
{% elif news_item.is_scheduled %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
· {{ news_item.get_visibility_display }}
|
<span class="font-mono text-xs text-dim">{{ news_item.get_visibility_display }}</span>
|
||||||
{% endblock subheading %}
|
{% endblock topbar_context %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
@@ -28,47 +29,57 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if news_item.teams.all %}
|
{% 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 %}
|
{% for team in news_item.teams.all %}
|
||||||
<span class="badge badge-neutral">{{ team.short_name }}</span>
|
<span class="badge badge-neutral">{{ team.short_name }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
<p class="whitespace-pre-line">{{ news_item.body }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if news_item.title_en or news_item.body_en %}
|
{% 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">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% trans "English" %}</h2>
|
<h2 class="card-title">{% lucide "languages" size=18 %} {% trans "English" %}</h2>
|
||||||
{% if news_item.title_en %}<p class="font-semibold">{{ news_item.title_en }}</p>{% endif %}
|
{% 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">{{ news_item.body_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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% for photo in news_item.photos.all %}
|
||||||
<div class="relative">
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if can_edit %}
|
{% 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 %}
|
{% if not photo.is_main %}
|
||||||
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
|
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -85,7 +96,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card w-full">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -16,25 +16,25 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if not update_view %}
|
{% 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 %}
|
{% 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 %}
|
||||||
{% form_field form.title_en %}
|
{% form_field form.title_en %}
|
||||||
</div>
|
</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.teams %}
|
||||||
{% form_field form.visibility %}
|
{% form_field form.visibility %}
|
||||||
</div>
|
</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 %}
|
||||||
{% form_field form.body_en %}
|
{% form_field form.body_en %}
|
||||||
</div>
|
</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>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,65 +5,52 @@
|
|||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{% if can_add_news %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card overflow-hidden divide-y">
|
||||||
<div class="card-body">
|
{% for news_item in news_items %}
|
||||||
<div class="overflow-x-auto">
|
<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 %}">
|
||||||
<table class="table table-cards">
|
<div class="min-w-0 flex-1">
|
||||||
<thead>
|
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||||
<tr>
|
{% if news_item.status == "draft" %}
|
||||||
<th>{% trans "Title" %}</th>
|
<span class="badge badge-sm">{% trans "Draft" %}</span>
|
||||||
<th>{% trans "Teams" %}</th>
|
{% elif news_item.is_scheduled %}
|
||||||
<th>{% trans "Visibility" %}</th>
|
<span class="badge badge-info badge-sm">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
|
||||||
<th>{% trans "Status" %}</th>
|
{% else %}
|
||||||
<th></th>
|
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
|
||||||
</tr>
|
{% endif %}
|
||||||
</thead>
|
<span class="font-mono text-xs text-dim">
|
||||||
<tbody>
|
{% if news_item.status == "draft" %}
|
||||||
{% for news_item in news_items %}
|
{% blocktrans with time=news_item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
|
||||||
<tr>
|
{% else %}
|
||||||
<td><a class="link link-hover font-semibold" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td>
|
{{ news_item.published_at|date:"j M Y" }}
|
||||||
<td data-label="{% trans 'Teams' %}">
|
{% endif %}
|
||||||
<div class="flex flex-wrap gap-2">
|
</span>
|
||||||
{% for team in news_item.teams.all %}
|
</div>
|
||||||
<span class="badge badge-neutral badge-sm">{{ team.short_name }}</span>
|
<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>
|
||||||
{% empty %}
|
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-[13px] text-muted">
|
||||||
<span class="opacity-60">{% trans "Club-wide" %}</span>
|
{% if news_item.created_by %}<span>{{ news_item.created_by }}</span>·{% endif %}
|
||||||
{% endfor %}
|
{% for team in news_item.teams.all %}
|
||||||
</div>
|
<span class="badge badge-outline badge-xs">{{ team.short_name }}</span>
|
||||||
</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>
|
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<span>{% trans "Club-wide" %}</span>
|
||||||
<td colspan="5" class="text-center opacity-60">{% trans "No news items yet." %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
· <span>{{ news_item.get_visibility_display }}</span>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
{% empty %}
|
||||||
|
<div class="px-4.5 py-10 text-center text-sm text-muted">{% trans "No news items yet." %}</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "management/_pagination.html" %}
|
{% include "management/_pagination.html" %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -4,24 +4,24 @@
|
|||||||
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New opponent" %}{% endif %}{% endblock heading %}
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New opponent" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card w-full bg-base-100 shadow">
|
<div class="card w-full">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% for field in form %}
|
||||||
{% form_field field %}
|
{% form_field field %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</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="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<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>
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user