diff --git a/assets/app.css b/assets/app.css
index c8f8cef..224321f 100644
--- a/assets/app.css
+++ b/assets/app.css
@@ -5,6 +5,7 @@
@source "../templates";
@source "../controlpanel";
@source "../billing";
+@source "../management";
/* daisyUI: light is the default, dark applies automatically when the OS asks
for it. An explicit data-theme on (set by the toggle) overrides both. */
diff --git a/club/admin.py b/club/admin.py
index 0a0f3bd..36a34d9 100644
--- a/club/admin.py
+++ b/club/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
-from .models import Club, ClubMembership, ClubRole, Season
+from .models import Club, ClubMembership, ClubRole, FeePayment, Season
@admin.register(Club)
@@ -20,17 +20,33 @@ class SeasonAdmin(admin.ModelAdmin):
ordering = ["club", "-start_date"]
+class FeePaymentInline(admin.TabularInline):
+ model = FeePayment
+ extra = 0
+ readonly_fields = ["recorded_by"]
+
+
@admin.register(ClubMembership)
class ClubMembershipAdmin(admin.ModelAdmin):
- list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "license"]
+ list_display = ["club__name", "member__last_name", "member__first_name", "season", "status", "fee_status", "fee_amount", "amount_paid", "license"]
search_fields = ["club__name", "member__last_name", "member__first_name", "license"]
list_filter = ["club", "season", "status", "fee_status"]
raw_id_fields = ["member"]
+ # Money is settled by club.services.fees, which re-derives fee_status from the payments.
+ readonly_fields = ["amount_paid", "fee_status"]
fieldsets = [
[None, {"fields": ["club", "season", "member"]}],
- [_("Membership"), {"fields": ["license", "status", "fee_status"]}],
+ [_("Membership"), {"fields": ["license", "status", "fee_status", "fee_amount", "amount_paid"]}],
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
]
+ inlines = [FeePaymentInline]
+
+
+@admin.register(FeePayment)
+class FeePaymentAdmin(admin.ModelAdmin):
+ list_display = ["membership", "amount", "method", "paid_at", "recorded_by"]
+ list_filter = ["method"]
+ search_fields = ["membership__club__name", "membership__member__last_name", "reference"]
@admin.register(ClubRole)
diff --git a/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py
new file mode 100644
index 0000000..eb2056a
--- /dev/null
+++ b/club/migrations/0016_clubmembership_amount_paid_clubmembership_fee_amount_and_more.py
@@ -0,0 +1,50 @@
+# Generated by Django 6.0.6 on 2026-08-03 11:22
+
+import django.core.validators
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+from decimal import Decimal
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('club', '0015_alter_club_logo'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clubmembership',
+ name='amount_paid',
+ field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text='Kept in step with payments by the fee service; not hand-edited.', max_digits=10, verbose_name='amount paid'),
+ ),
+ migrations.AddField(
+ model_name='clubmembership',
+ name='fee_amount',
+ field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='fee amount'),
+ ),
+ migrations.CreateModel(
+ name='FeePayment',
+ 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)),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')),
+ ('method', models.CharField(choices=[('bank_transfer', 'bank transfer'), ('cash', 'cash'), ('card', 'card'), ('other', 'other')], default='bank_transfer', max_length=20, verbose_name='method')),
+ ('reference', models.CharField(blank=True, help_text='Bank reference, transaction id — whatever lets you find this again.', max_length=255, verbose_name='reference')),
+ ('paid_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='paid at')),
+ ('note', models.TextField(blank=True, verbose_name='note')),
+ ('membership', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='club.clubmembership', verbose_name='membership')),
+ ('recorded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_fee_payments', to=settings.AUTH_USER_MODEL, verbose_name='recorded by')),
+ ],
+ options={
+ 'verbose_name': 'fee payment',
+ 'verbose_name_plural': 'fee payments',
+ 'ordering': ['-paid_at'],
+ },
+ ),
+ ]
diff --git a/club/mixins.py b/club/mixins.py
new file mode 100644
index 0000000..d653570
--- /dev/null
+++ b/club/mixins.py
@@ -0,0 +1,51 @@
+from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
+from django.http import Http404
+
+from .services.access import has_management_access, is_club_admin, teams_managed_by
+
+
+class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
+ """Gate for the club-facing management UI.
+
+ Two rules, the mirror image of ``controlpanel.mixins.PlatformStaffRequiredMixin``:
+
+ * **Club subdomain only.** This UI manages *one* club, so it doesn't exist on the
+ base domain — same reasoning as the control panel refusing to exist on a club
+ subdomain, just inverted.
+ * **Staff only.** ADMIN/EDITOR, or a current-season ``StaffAssignment`` (coach,
+ team manager, ...) — see ``has_management_access``. The plain MEMBER role every
+ active player/club member holds automatically does *not* count: a club member
+ with neither is a player/parent, and belongs in the separate app that serves
+ them.
+ """
+
+ def dispatch(self, request, *args, **kwargs):
+ if getattr(request, "club", None) is None:
+ raise Http404("The management app is not available on the base domain.")
+ return super().dispatch(request, *args, **kwargs)
+
+ def test_func(self):
+ return has_management_access(self.request.user, self.request.club)
+
+
+class ClubAdminRequiredMixin(ClubStaffRequiredMixin):
+ """ADMIN role only — club-wide settings that aren't scoped to a single team:
+ seasons, positions, roles, shop configuration."""
+
+ def test_func(self):
+ return is_club_admin(self.request.user, self.request.club)
+
+
+class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
+ """A manager of *this* team, or a club ADMIN. ``self.get_team()`` must return the
+ ``Team`` the view acts on (e.g. from the URL's ``pk``) before ``test_func`` runs.
+ """
+
+ def get_team(self):
+ raise NotImplementedError("Subclasses must return the Team this view acts on.")
+
+ def test_func(self):
+ user, club = self.request.user, self.request.club
+ if is_club_admin(user, club):
+ return True
+ return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
diff --git a/club/models.py b/club/models.py
index e3635fe..f401560 100644
--- a/club/models.py
+++ b/club/models.py
@@ -1,6 +1,8 @@
import datetime
+from decimal import Decimal
-from django.core.validators import FileExtensionValidator, RegexValidator
+from django.conf import settings
+from django.core.validators import FileExtensionValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -182,6 +184,9 @@ class ClubMembership(ClubScopedModel):
status = models.CharField(_("status"), max_length=250, choices=StatusChoices.choices, default=StatusChoices.PENDING)
fee_status = models.CharField(_("fee status"), max_length=250, choices=FeeStatus.choices, default=FeeStatus.UNPAID)
+ fee_amount = models.DecimalField(_("fee amount"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True)
+ amount_paid = models.DecimalField(_("amount paid"), max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, help_text=_("Kept in step with payments by the fee service; not hand-edited."))
+
signed_up_at = models.DateField(_("signed up at"), blank=True, null=True)
activated_at = models.DateField(_("activated at"), blank=True, null=True)
@@ -200,6 +205,35 @@ class ClubMembership(ClubScopedModel):
validate_club_scope(self, self.club_id, same_club_fields=("season",))
+class FeePayment(UUIDModel):
+ """Money received against one membership's fee. Several may land on one
+ membership: a family paying in two installments must not read as unpaid, and
+ the part that did arrive has to be recorded somewhere. Not itself club-scoped
+ -- its club is reached through ``membership``, same as DuePayment/Due."""
+
+ class Method(models.TextChoices):
+ BANK_TRANSFER = "bank_transfer", _("bank transfer")
+ CASH = "cash", _("cash")
+ CARD = "card", _("card")
+ OTHER = "other", _("other")
+
+ membership = models.ForeignKey(ClubMembership, on_delete=models.CASCADE, related_name="payments", verbose_name=_("membership"))
+ amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))])
+ method = models.CharField(_("method"), max_length=20, choices=Method.choices, default=Method.BANK_TRANSFER)
+ reference = models.CharField(_("reference"), max_length=255, blank=True, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
+ paid_at = models.DateTimeField(_("paid at"), default=timezone.now)
+ note = models.TextField(_("note"), blank=True)
+ recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_fee_payments", verbose_name=_("recorded by"))
+
+ class Meta:
+ verbose_name = _("fee payment")
+ verbose_name_plural = _("fee payments")
+ ordering = ["-paid_at"]
+
+ def __str__(self):
+ return f"{self.membership} — {self.amount}"
+
+
class ClubRole(ClubScopedModel):
class Roles(models.TextChoices):
ADMIN = "admin", _("admin")
diff --git a/club/services/access.py b/club/services/access.py
index 495e247..ecdce87 100644
--- a/club/services/access.py
+++ b/club/services/access.py
@@ -49,6 +49,18 @@ def is_club_admin(user: User, club: Club) -> bool:
return has_club_role(user, club, ClubRole.Roles.ADMIN)
+def has_management_access(user: User, club: Club) -> bool:
+ """Anyone with real authority in the club: ADMIN/EDITOR, or *any* current-season
+ staff assignment (coach, team manager, physio, ...).
+
+ Deliberately excludes the plain MEMBER role -- every signed-up player (or club
+ member generally) holds that automatically the moment their ClubMembership goes
+ active (club/signals.py), so it says nothing about whether someone is staff.
+ """
+ elevated = ClubRole.objects.filter(member__user=user, club=club, role__in=(ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)).exists()
+ return elevated or teams_staffed_by(user, club).exists()
+
+
def is_coach_manager(user: User, club: Club) -> bool:
"""Derived from a current-season StaffAssignment in a *management* position."""
return StaffAssignment.objects.filter(
diff --git a/club/services/fees.py b/club/services/fees.py
new file mode 100644
index 0000000..5974410
--- /dev/null
+++ b/club/services/fees.py
@@ -0,0 +1,69 @@
+"""Recording money received against a membership's fee.
+
+Mirrors billing.services.dues.record_payment for a different kind of money: a
+member's own club fee, not the club's platform subscription. amount_paid is kept in
+step here, never recomputed by re-aggregating FeePayment on every read.
+"""
+
+from decimal import Decimal
+
+from django.db.models import F
+from django.utils import timezone
+
+from club.models import ClubMembership, FeePayment
+
+
+def remaining_balance(membership):
+ return max(membership.fee_amount - membership.amount_paid, Decimal("0.00"))
+
+
+def record_payment(membership, *, amount, method=FeePayment.Method.BANK_TRANSFER, reference="", note="", recorded_by=None):
+ """Record money received against one membership's fee. Several payments may
+ land on one membership -- a family paying in two installments must not read as
+ unpaid. Updates amount_paid and re-syncs fee_status/status to match."""
+ 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.save(update_fields=["amount_paid"])
+ membership.refresh_from_db(fields=["amount_paid"])
+ _sync_fee_status(membership)
+
+ return payment
+
+
+def mark_as_paid(membership, *, recorded_by=None):
+ """The "settle this one" action behind both the per-row and bulk buttons. If
+ there's a real remaining balance, records it as a payment (auditable, shows up
+ in history); if fee_amount was never priced (remaining is 0), just flips the
+ flags directly -- there's no real transaction to log."""
+ remaining = remaining_balance(membership)
+ if remaining > 0:
+ record_payment(membership, amount=remaining, method=FeePayment.Method.OTHER, note="Marked as paid", recorded_by=recorded_by)
+ else:
+ _sync_fee_status(membership, force_paid=True)
+
+
+def _sync_fee_status(membership, *, force_paid=False):
+ if membership.fee_status == ClubMembership.FeeStatus.WAIVED:
+ return # manual, independent of payments -- this never overrides it
+
+ if force_paid or (membership.fee_amount > 0 and membership.amount_paid >= membership.fee_amount):
+ new_status = ClubMembership.FeeStatus.PAID
+ elif membership.amount_paid > 0:
+ new_status = ClubMembership.FeeStatus.PARTIALLY_PAID
+ else:
+ new_status = ClubMembership.FeeStatus.UNPAID
+
+ membership.fee_status = new_status
+ update_fields = ["fee_status"]
+
+ # Same "become a full member" behavior the bulk action already had: settling
+ # the fee in full also activates the membership, once, first time only.
+ if new_status == ClubMembership.FeeStatus.PAID:
+ membership.status = ClubMembership.StatusChoices.ACTIVE
+ update_fields.append("status")
+ if membership.activated_at is None:
+ membership.activated_at = timezone.localdate()
+ update_fields.append("activated_at")
+
+ membership.save(update_fields=update_fields)
diff --git a/club/tests.py b/club/tests.py
index 124f3f4..030aa1d 100644
--- a/club/tests.py
+++ b/club/tests.py
@@ -1,6 +1,7 @@
import datetime
import uuid
from contextlib import contextmanager
+from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib import admin as django_admin
@@ -16,7 +17,7 @@ from events.models import Event
from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
-from .models import Club, ClubMembership, ClubRole, Season, club_logo_path
+from .models import Club, ClubMembership, ClubRole, FeePayment, Season, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
@@ -27,6 +28,7 @@ from .services.access import (
teams_managed_by,
teams_staffed_by,
)
+from .services.fees import mark_as_paid, record_payment, remaining_balance
from .tenancy import (
ClubTenantMiddleware,
get_current_club,
@@ -440,14 +442,17 @@ class SeasonGetCurrentTests(TestCase):
self.assertEqual(found.club, self.other)
def test_defaults_to_today(self):
+ # self.other, not self.club -- setUp's self.season (2026-08-01 to 2027-05-31)
+ # would otherwise also cover "today" once real dates reach that window,
+ # colliding with the one created here.
today = timezone.now().date()
current = Season.objects.create(
- club=self.club,
+ club=self.other,
start_date=today - datetime.timedelta(days=10),
end_date=today + datetime.timedelta(days=10),
)
- with with_club(self.club):
+ with with_club(self.other):
self.assertEqual(Season.get_current(), current)
def test_requires_an_active_club(self):
@@ -1083,3 +1088,105 @@ class RootViewTests(TestCase):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
+
+
+class FeeServiceTests(TestCase):
+ """club.services.fees -- record_payment/mark_as_paid/remaining_balance, the
+ service layer behind the Memberships page's per-row payment actions."""
+
+ def setUp(self):
+ self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
+ self.season = make_season(self.club)
+ self.member = Member.objects.create(first_name="Jane", last_name="Doe")
+ self.membership = ClubMembership.objects.create(
+ club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_amount=Decimal("150.00")
+ )
+
+ def roles(self):
+ return ClubRole.objects.filter(club=self.club, member=self.member)
+
+ def test_remaining_balance_starts_at_the_full_fee(self):
+ self.assertEqual(remaining_balance(self.membership), Decimal("150.00"))
+
+ def test_remaining_balance_is_never_negative(self):
+ record_payment(self.membership, amount=Decimal("200.00"))
+
+ self.assertEqual(remaining_balance(self.membership), Decimal("0.00"))
+
+ def test_a_partial_payment_creates_a_record_and_updates_the_running_total(self):
+ payment = record_payment(self.membership, amount=Decimal("50.00"), method=FeePayment.Method.CASH, reference="R1", note="first installment")
+
+ self.assertEqual(payment.membership, self.membership)
+ self.assertEqual(payment.amount, Decimal("50.00"))
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.amount_paid, Decimal("50.00"))
+ self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
+ # Not yet settled -- status doesn't change on a partial payment.
+ self.assertEqual(self.membership.status, ClubMembership.StatusChoices.PENDING)
+
+ def test_multiple_partial_payments_accumulate(self):
+ record_payment(self.membership, amount=Decimal("50.00"))
+ record_payment(self.membership, amount=Decimal("60.00"))
+
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.amount_paid, Decimal("110.00"))
+ self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PARTIALLY_PAID)
+ self.assertEqual(FeePayment.objects.filter(membership=self.membership).count(), 2)
+
+ def test_reaching_the_full_amount_settles_and_activates(self):
+ record_payment(self.membership, amount=Decimal("100.00"))
+ record_payment(self.membership, amount=Decimal("50.00"))
+
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
+ self.assertEqual(self.membership.status, ClubMembership.StatusChoices.ACTIVE)
+ self.assertEqual(self.membership.activated_at, timezone.localdate())
+ self.assertTrue(self.roles().filter(role=ClubRole.Roles.MEMBER).exists())
+
+ def test_settling_in_full_does_not_overwrite_an_earlier_activated_at(self):
+ earlier = datetime.date(2026, 1, 1)
+ self.membership.activated_at = earlier
+ self.membership.save()
+
+ record_payment(self.membership, amount=Decimal("150.00"))
+
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.activated_at, earlier)
+
+ def test_a_waived_membership_is_untouched_by_a_payment(self):
+ self.membership.fee_status = ClubMembership.FeeStatus.WAIVED
+ self.membership.save()
+
+ record_payment(self.membership, amount=Decimal("50.00"))
+
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.WAIVED)
+
+ def test_mark_as_paid_records_the_exact_remaining_balance(self):
+ record_payment(self.membership, amount=Decimal("100.00"))
+
+ mark_as_paid(self.membership)
+
+ self.membership.refresh_from_db()
+ self.assertEqual(self.membership.fee_status, ClubMembership.FeeStatus.PAID)
+ payment = FeePayment.objects.get(membership=self.membership, amount=Decimal("50.00"))
+ self.assertEqual(payment.note, "Marked as paid")
+
+ def test_mark_as_paid_with_no_fee_amount_set_skips_creating_a_zero_payment(self):
+ # FeePayment.amount has a MinValueValidator(0.01) -- a $0 "payment" isn't a
+ # real transaction, so this must flip the flags directly instead.
+ unpriced = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="No", last_name="Price"), season=self.season, status=ClubMembership.StatusChoices.PENDING)
+
+ mark_as_paid(unpriced)
+
+ unpriced.refresh_from_db()
+ self.assertEqual(unpriced.fee_status, ClubMembership.FeeStatus.PAID)
+ self.assertEqual(unpriced.status, ClubMembership.StatusChoices.ACTIVE)
+ self.assertFalse(FeePayment.objects.filter(membership=unpriced).exists())
+
+ def test_recorded_by_is_stored_on_the_payment(self):
+ user = get_user_model().objects.create_user(email="admin-fees@example.com", password="pw-secret-123")
+
+ payment = record_payment(self.membership, amount=Decimal("50.00"), recorded_by=user)
+
+ self.assertEqual(payment.recorded_by, user)
diff --git a/features/admin.py b/features/admin.py
new file mode 100644
index 0000000..8fc6ab2
--- /dev/null
+++ b/features/admin.py
@@ -0,0 +1,17 @@
+from django.contrib import admin
+
+from .models import Flag, Maintenance
+
+
+@admin.register(Flag)
+class FlagAdmin(admin.ModelAdmin):
+ list_display = ["name", "note", "everyone", "percent", "superusers", "staff"]
+ list_filter = ["everyone", "superusers", "staff"]
+ search_fields = ["name", "note"]
+ filter_horizontal = ["clubs"]
+
+
+@admin.register(Maintenance)
+class MaintenanceAdmin(admin.ModelAdmin):
+ list_display = ["__str__", "started_at", "started_by"]
+ readonly_fields = ["started_at", "started_by"]
diff --git a/management/__init__.py b/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/management/apps.py b/management/apps.py
new file mode 100644
index 0000000..f4ad1a6
--- /dev/null
+++ b/management/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ManagementConfig(AppConfig):
+ name = "management"
diff --git a/management/bulk_import.py b/management/bulk_import.py
new file mode 100644
index 0000000..4715219
--- /dev/null
+++ b/management/bulk_import.py
@@ -0,0 +1,163 @@
+"""Mass-uploading members from an Excel template: extracting a workbook into plain
+row data, and validating that data into what will be created.
+
+Kept as two separate functions so binary parsing happens exactly once (at upload
+time) while validation -- the part that must behave identically whether it's
+building the preview or actually creating records -- runs against plain data both
+times (see MemberImportView / MemberImportConfirmView in management/views.py).
+"""
+
+from datetime import date, datetime
+
+import openpyxl
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+from openpyxl.worksheet.datavalidation import DataValidation
+
+from club.models import ClubMembership
+from members.models import Member
+
+from .forms import MemberForm
+
+TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"]
+REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
+
+TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID]
+
+
+def build_member_import_template():
+ """The downloadable .xlsx: header row, one example row, and a dropdown on the
+ status/fee_status columns so a cell can't be typo'd into an invalid value."""
+ workbook = openpyxl.Workbook()
+ sheet = workbook.active
+ sheet.title = "Members"
+
+ sheet.append(TEMPLATE_COLUMNS)
+ for cell in sheet[1]:
+ cell.font = openpyxl.styles.Font(bold=True)
+ sheet.freeze_panes = "A2"
+
+ sheet.append(TEMPLATE_EXAMPLE_ROW)
+
+ for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)):
+ column_index = TEMPLATE_COLUMNS.index(column_name) + 1
+ column_letter = sheet.cell(row=1, column=column_index).column_letter
+ options = ",".join(choices.values)
+ validation = DataValidation(type="list", formula1=f'"{options}"', allow_blank=True)
+ sheet.add_data_validation(validation)
+ validation.add(f"{column_letter}2:{column_letter}1000")
+
+ for column_index, column_name in enumerate(TEMPLATE_COLUMNS, start=1):
+ sheet.column_dimensions[sheet.cell(row=1, column=column_index).column_letter].width = max(12, len(column_name) + 2)
+
+ return workbook
+
+
+def read_member_import_workbook(file):
+ """Uploaded .xlsx -> list[dict], one dict per row keyed by TEMPLATE_COLUMNS.
+
+ Normalizes every cell to a plain str/None here: openpyxl returns typed cells
+ (a date-formatted cell comes back as a datetime.date, a phone number typed as
+ digits-only can come back as a number), so this is the one place that has to
+ deal with that -- everything downstream, including the session, only ever
+ sees plain strings.
+ """
+ workbook = openpyxl.load_workbook(file, data_only=True)
+ sheet = workbook.active
+
+ header = [str(cell.value).strip() if cell.value is not None else "" for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
+ if not REQUIRED_HEADER_COLUMNS.issubset(header):
+ missing = REQUIRED_HEADER_COLUMNS - set(header)
+ raise ValueError(_("This doesn't look like the template — missing column(s): %(columns)s.") % {"columns": ", ".join(sorted(missing))})
+
+ rows = []
+ for excel_row in sheet.iter_rows(min_row=2):
+ values = {}
+ for column_name, cell in zip(header, excel_row, strict=False):
+ if column_name not in TEMPLATE_COLUMNS:
+ continue
+ values[column_name] = _cell_to_str(cell.value)
+
+ if not any(values.values()):
+ continue # a fully blank row (trailing spreadsheet padding) isn't a row to import
+ rows.append(values)
+
+ return rows
+
+
+def _cell_to_str(value):
+ if value is None:
+ return ""
+ if isinstance(value, datetime):
+ return value.date().isoformat()
+ if isinstance(value, date):
+ return value.isoformat()
+ if isinstance(value, float) and value.is_integer():
+ return str(int(value))
+ return str(value).strip()
+
+
+def parse_member_import_rows(rows, club):
+ """list[dict] (as returned by read_member_import_workbook) -> one result per
+ row: {line_number, raw, member, membership_kwargs, errors}. `member` is an
+ unsaved Member instance (ready to .save()) when the row is valid, else None."""
+ results = []
+ seen_emails = set()
+
+ for line_number, raw in enumerate(rows, start=2):
+ errors = []
+ member_fields = {key: raw.get(key, "") for key in ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")}
+ form = MemberForm(data=member_fields)
+
+ member = None
+ if form.is_valid():
+ member = form.save(commit=False)
+ else:
+ for field_errors in form.errors.values():
+ errors.extend(field_errors)
+
+ email = member_fields["email"].strip()
+ if email:
+ if email.lower() in seen_emails:
+ errors.append(_("Duplicate email in this file."))
+ seen_emails.add(email.lower())
+ already_in_club = Member.objects.filter(member_of__club=club).filter(Q(email__iexact=email) | Q(user__email__iexact=email)).exists()
+ if already_in_club:
+ errors.append(_("Already a member of this club."))
+
+ membership_kwargs, status_fee_errors = _parse_membership_fields(raw)
+ errors.extend(status_fee_errors)
+
+ results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors})
+
+ return results
+
+
+def _parse_membership_fields(raw):
+ errors = []
+ license_number = raw.get("license", "").strip()
+
+ status = raw.get("status", "").strip()
+ if status:
+ status_value = _match_choice(status, ClubMembership.StatusChoices)
+ if status_value is None:
+ errors.append(_("Invalid status '%(value)s'.") % {"value": status})
+ else:
+ status_value = ClubMembership.StatusChoices.ACTIVE
+
+ fee_status = raw.get("fee_status", "").strip()
+ if fee_status:
+ fee_status_value = _match_choice(fee_status, ClubMembership.FeeStatus)
+ if fee_status_value is None:
+ errors.append(_("Invalid fee status '%(value)s'.") % {"value": fee_status})
+ else:
+ fee_status_value = ClubMembership.FeeStatus.UNPAID
+
+ return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
+
+
+def _match_choice(value, choices):
+ for choice_value in choices.values:
+ if choice_value.lower() == value.lower():
+ return choice_value
+ return None
diff --git a/management/context_processors.py b/management/context_processors.py
new file mode 100644
index 0000000..b746a39
--- /dev/null
+++ b/management/context_processors.py
@@ -0,0 +1,88 @@
+"""Whether the signed-in user is a club ADMIN, for the management nav to hide
+admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
+
+The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is
+purely so the nav doesn't show a link a coach or manager can't actually follow.
+"""
+
+from club.services.access import has_management_access, is_club_admin
+
+#: Every management URL name, mapped to the nav item it should light up --
+#: management/templates/management/_nav_items.html compares against this.
+#: One dict here beats threading `nav=` through every view in views.py, and
+#: unlike that, a new page can't silently be forgotten (it just renders with
+#: no active item until added below, rather than needing every view touched).
+_NAV_SECTIONS = {
+ "home": "home",
+ "member_list": "member_list",
+ "member_create": "member_list",
+ "member_import_template": "member_list",
+ "member_import": "member_list",
+ "member_import_confirm": "member_list",
+ "member_detail": "member_list",
+ "member_update": "member_list",
+ "member_delete": "member_list",
+ "member_attach_family": "member_list",
+ "member_grant_login": "member_list",
+ "member_detach_family": "member_list",
+ "family_create": "member_list",
+ "family_detail": "member_list",
+ "family_add_child": "member_list",
+ "family_add_parent": "member_list",
+ "family_membership_role_update": "member_list",
+ "membership_list": "membership_list",
+ "membership_mark_paid": "membership_list",
+ "membership_export_pdf": "membership_list",
+ "membership_mark_fully_paid": "membership_list",
+ "membership_record_payment": "membership_list",
+ "position_list": "position_list",
+ "role_list": "role_list",
+ "role_create": "role_list",
+ "role_revoke": "role_list",
+ "team_list": "team_list",
+ "team_create": "team_list",
+ "team_update": "team_list",
+ "team_detail": "team_list",
+ "roster_list": "roster_list",
+ "staff_list": "staff_list",
+ "event_list": "event_list",
+ "event_series_list": "event_series_list",
+ "location_list": "location_list",
+ "opponent_list": "opponent_list",
+ "product_list": "product_list",
+ "order_list": "order_list",
+ "discount_list": "discount_list",
+ "invoice_list": "invoice_list",
+ "form_list": "form_list",
+ "submission_list": "form_list",
+}
+
+
+def active_nav_section(request):
+ """Which management nav item is currently active, derived from the resolved
+ URL name. Guarded on the "management" namespace so a same-named url_name in
+ some other app can never leak into this."""
+ match = request.resolver_match
+ if match is None or match.namespace != "management":
+ return {"nav": None}
+
+ return {"nav": _NAV_SECTIONS.get(match.url_name)}
+
+
+def is_admin(request):
+ club = getattr(request, "club", None)
+ if club is None or not request.user.is_authenticated:
+ return {"is_club_admin": False}
+
+ return {"is_club_admin": is_club_admin(request.user, club)}
+
+
+def management_link(request):
+ """Whether to show a "Management" link in the global navbar (templates/_base.html),
+ next to the Django admin one -- only on a club subdomain, and only for someone with
+ real authority there (see has_management_access)."""
+ club = getattr(request, "club", None)
+ if club is None or not request.user.is_authenticated:
+ return {"has_management_access": False}
+
+ return {"has_management_access": has_management_access(request.user, club)}
diff --git a/management/forms.py b/management/forms.py
new file mode 100644
index 0000000..2596b72
--- /dev/null
+++ b/management/forms.py
@@ -0,0 +1,144 @@
+from decimal import Decimal
+
+from django import forms
+from django.contrib.auth import get_user_model
+from django.utils.translation import gettext_lazy as _
+
+from club.models import ClubMembership, ClubRole, FeePayment
+from members.models import Family, FamilyMembership, Member
+from members.services.family import find_member_by_email
+from teams.models import Team
+
+User = get_user_model()
+
+
+class MemberForm(forms.ModelForm):
+ class Meta:
+ model = Member
+ fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
+ widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}
+
+
+class TeamForm(forms.ModelForm):
+ class Meta:
+ model = Team
+ fields = ["name", "short_name"]
+
+
+class ClubRoleAssignForm(forms.ModelForm):
+ """Grant a club-wide role to a member already affiliated with this club."""
+
+ class Meta:
+ model = ClubRole
+ fields = ["member", "role"]
+
+ def __init__(self, *args, club=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Never list members of other clubs -- this isn't a platform-wide picker.
+ self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct()
+
+
+class FamilyCreateForm(forms.Form):
+ """One new family in one go: a parent (who gets a login) and a child (who
+ doesn't). See members.services.family.register_family."""
+
+ parent_first_name = forms.CharField(label=_("Parent first name"))
+ parent_last_name = forms.CharField(label=_("Parent last name"))
+ parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
+
+ child_first_name = forms.CharField(label=_("Child first name"))
+ child_last_name = forms.CharField(label=_("Child last name"))
+ child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
+
+
+class AddChildForm(forms.Form):
+ """A family that needs one more child registered -- see
+ members.services.family.add_child_to_family."""
+
+ first_name = forms.CharField(label=_("First name"))
+ last_name = forms.CharField(label=_("Last name"))
+ date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
+
+
+class AddParentForm(forms.Form):
+ """A family that needs one more parent/guardian registered -- see
+ members.services.family.add_parent_to_family."""
+
+ email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
+ first_name = forms.CharField(label=_("First name"), required=False)
+ last_name = forms.CharField(label=_("Last name"), required=False)
+
+ def clean(self):
+ cleaned = super().clean()
+ email = cleaned.get("email")
+
+ # Only a brand-new person needs a name; an existing member already has one.
+ if email and find_member_by_email(email) is None:
+ for field in ("first_name", "last_name"):
+ if not cleaned.get(field):
+ self.add_error(field, _("Required: this email has no account yet."))
+
+ return cleaned
+
+
+class AttachToFamilyForm(forms.Form):
+ """Link a standalone member into a family -- a new one, or an existing one they
+ turn out to belong to. See members.services.family.attach_to_family."""
+
+ role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices)
+ family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —"))
+
+ def __init__(self, *args, club=None, member=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Same scoping query as management.views.families_of_club -- inlined rather
+ # than imported, since that function lives in views.py, which imports this
+ # module (a module-level import back here would be circular).
+ queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct()
+ if member is not None:
+ # Already a member of it -- offering it again would be a no-op re-add.
+ queryset = queryset.exclude(memberships__member=member)
+ self.fields["family"].queryset = queryset
+
+
+class MemberImportUploadForm(forms.Form):
+ """The mass-upload entry point -- one .xlsx file, built from the downloadable
+ template. See management.bulk_import.read_member_import_workbook."""
+
+ file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member."))
+
+
+class GrantLoginForm(forms.Form):
+ """A login-less family member (a child, typically) getting their own account --
+ see members.services.family.grant_login. Pre-filled from the member's contact
+ email where one is already on file; still editable, and required either way."""
+
+ email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in."))
+
+ def clean_email(self):
+ email = self.cleaned_data["email"]
+ if User.objects.filter(email__iexact=email).exists():
+ raise forms.ValidationError(_("This email is already in use."))
+ return email
+
+
+class ClubMembershipForm(forms.ModelForm):
+ """This season's standing -- shown and edited right on the member's own page,
+ since a Member has no club of its own without one. fee_amount is the only
+ money field here -- amount_paid is exclusively written by club.services.fees,
+ never hand-edited."""
+
+ class Meta:
+ model = ClubMembership
+ fields = ["license", "status", "fee_status", "fee_amount"]
+
+
+class RecordFeePaymentForm(forms.Form):
+ """Money received against one membership's fee -- see club.services.fees.record_payment.
+ Reusable for any amount, partial or the exact remaining balance; "Mark fully
+ paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely
+ and settles the balance directly in one click."""
+
+ amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
+ method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
+ reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
+ note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
diff --git a/management/pdf.py b/management/pdf.py
new file mode 100644
index 0000000..32624c4
--- /dev/null
+++ b/management/pdf.py
@@ -0,0 +1,30 @@
+"""HTML-to-PDF rendering for exports (currently just the memberships list).
+
+Same lazy-import shape as billing.services.invoices.render_pdf -- WeasyPrint binds
+to native pango/cairo libraries, and a machine without them must still be able to
+run the app, the tests and every other page, so the import happens here, not at
+module scope, and only fails when someone actually asks for a PDF. Kept separate
+from billing's version rather than shared: the two exports have nothing else in
+common, and sharing would make one app depend on the other's error type for no
+real benefit.
+"""
+
+from django.template.loader import render_to_string
+
+
+class PDFExportError(Exception):
+ """Raised when WeasyPrint's native libraries aren't available."""
+
+
+def render_pdf(html: str) -> bytes:
+ try:
+ from weasyprint import HTML
+ except (ImportError, OSError) as error:
+ raise PDFExportError("PDF rendering needs the native pango/cairo libraries (on macOS: brew install pango).") from error
+
+ return HTML(string=html).write_pdf()
+
+
+def membership_list_pdf(context: dict) -> bytes:
+ html = render_to_string("management/membership_list_pdf.html", context)
+ return render_pdf(html)
diff --git a/management/templates/management/_family_members_table.html b/management/templates/management/_family_members_table.html
new file mode 100644
index 0000000..ef1c740
--- /dev/null
+++ b/management/templates/management/_family_members_table.html
@@ -0,0 +1,92 @@
+{% load lucide ui i18n %}
+
+{% comment %}
+Shared by family_detail.html and member_detail.html's own Family panel -- one
+table design for "everyone in this family", included with `group` (a dict from
+management.views.group_by_family: family/guardians/children/others/all) and
+`family_role_choices` (FamilyMembership.FamilyRole.choices, for the role dropdown).
+`next_url`, if passed, is where a role change redirects back to -- member_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.
+{% endcomment %}
+
+
+{% if is_club_admin %}
+{% trans "Delete member" as delete_member_title %}
+{% trans "Delete" as delete_label %}
+{% trans "Remove from family" as remove_from_family_title %}
+{% trans "Remove" as remove_label %}
+{% trans "Grant login" as grant_login_title %}
+{% trans "Grant" as grant_login_submit_label %}
+{% trans "They'll be able to sign in with this email once you confirm." as grant_login_blurb %}
+{% with family_pk_str=group.family.pk|stringformat:"s" %}
+{% for person in group.all %}
+{% with person_pk_str=person.pk|stringformat:"s" %}
+{% url 'management:member_delete' person.pk as member_delete_url %}
+{% url 'management:member_detach_family' person.pk group.family.pk as detach_family_url %}
+{% url 'management:member_grant_login' person.pk as grant_login_url %}
+{% blocktrans with full_name=person.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
+{% blocktrans with full_name=person.get_full_name family_name=group.family asvar detach_family_body %}Remove {{ full_name }} from family {{ family_name }}? They become a standalone member - nothing else about them changes.{% endblocktrans %}
+{% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
+{% include "controlpanel/_confirm_modal.html" with modal_id="remove_family_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
+{% if person.grant_login_form %}
+{% include "controlpanel/_modal_form.html" with modal_id="grant_login_modal_"|add:family_pk_str|add:"_"|add:person_pk_str title=grant_login_title form=person.grant_login_form action_url=grant_login_url submit_label=grant_login_submit_label submit_icon="key-round" blurb=grant_login_blurb %}
+{% endif %}
+{% endwith %}
+{% endfor %}
+{% endwith %}
+{% endif %}
diff --git a/management/templates/management/_generic_list.html b/management/templates/management/_generic_list.html
new file mode 100644
index 0000000..044a665
--- /dev/null
+++ b/management/templates/management/_generic_list.html
@@ -0,0 +1,33 @@
+{% extends "management/base.html" %}
+{% load i18n %}
+
+{% comment %}
+ Shared placeholder for every entity that doesn't have its own list template yet
+ (see management.views.StubListMixin) -- a plain one-column table of __str__, just
+ enough to prove the query scoping and permission gate work. Not meant to stay this
+ bare once each section gets its own page.
+{% endcomment %}
+
+{% block heading %}{{ page_title }}{% endblock heading %}
+
+{% block panel %}
+
+
+
+
+
+ {% for object in object_list %}
+
+
{{ object }}
+
+ {% empty %}
+
+
{% trans "Nothing here yet." %}
+
+ {% endfor %}
+
+
+
+
+
+{% endblock panel %}
diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html
new file mode 100644
index 0000000..1724403
--- /dev/null
+++ b/management/templates/management/_nav_items.html
@@ -0,0 +1,49 @@
+{% load i18n lucide %}
+
+{% comment %}
+ The management nav, in one place: the sidebar renders it on a wide screen and the
+ collapsed menu renders it on a narrow one. Admin-only sections are hidden here for
+ plain staff -- the views are gated regardless (ClubAdminRequiredMixin), this is
+ just so the nav never shows a link they can't follow.
+
+ `nav` (management.context_processors.active_nav_section) is the current page's
+ section, derived from the resolved URL name -- `menu-active` is daisyUI's active
+ state, same convention as controlpanel/templates/controlpanel/_nav_items.html.
+{% endcomment %}
+
+
+ {# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
+
+ {% include "management/_nav_items.html" %}
+
+
+ {% block panel %}{% endblock panel %}
+{% endblock main %}
diff --git a/management/templates/management/family_detail.html b/management/templates/management/family_detail.html
new file mode 100644
index 0000000..0e8f06b
--- /dev/null
+++ b/management/templates/management/family_detail.html
@@ -0,0 +1,31 @@
+{% extends "management/base.html" %}
+{% load lucide i18n %}
+
+{% block heading %}{% blocktrans with name=family %}{{ name }} Family{% endblocktrans %}{% endblock heading %}
+
+{% block actions %}
+{% if is_club_admin %}
+{% trans "Add parent" as add_parent_label %}
+{% trans "Add child" as add_child_label %}
+
+
+{% endif %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+ {% include "management/_family_members_table.html" with group=group %}
+
+
+
+{% if is_club_admin %}
+{% url 'management:family_add_parent' family.pk as add_parent_url %}
+{% url 'management:family_add_child' family.pk as add_child_url %}
+{% trans "Add parent" as add_parent_title %}
+{% trans "Add child" as add_child_title %}
+{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
+{% include "controlpanel/_modal_form.html" with modal_id="add_parent_modal" title=add_parent_title form=add_parent_form action_url=add_parent_url submit_label=add_parent_title submit_icon="user-plus" blurb=add_parent_blurb %}
+{% include "controlpanel/_modal_form.html" with modal_id="add_child_modal" title=add_child_title form=add_child_form action_url=add_child_url submit_label=add_child_title submit_icon="baby" %}
+{% endif %}
+{% endblock panel %}
diff --git a/management/templates/management/family_form.html b/management/templates/management/family_form.html
new file mode 100644
index 0000000..4c00599
--- /dev/null
+++ b/management/templates/management/family_form.html
@@ -0,0 +1,43 @@
+{% extends "management/base.html" %}
+{% load lucide ui i18n %}
+
+{% block heading %}{% trans "Add family" %}{% endblock heading %}
+
+{% block panel %}
+
+ {% lucide "calendar-x" size=20 %}
+
+ {% trans "No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert." %}
+
+
+ {% endif %}
+
+ {% comment %}
+ The club's own numbers that should be zero -- same attention/chart/stat-group
+ data controlpanel/club_detail.html shows a platform admin drilling into this
+ club from outside; club_attention/club_charts/club_statistics are already
+ club-scoped, so this is that same data for the club's own staff. Nothing
+ money-shaped renders below for non-admins, same line the nav already draws
+ around the Shop section.
+ {% endcomment %}
+
+
+
+
{% lucide "user-x" size=16 %} {% trans "Teams without coach" %}
+
{{ attention.teams_without_manager }}
+
{% trans "Teams nobody can pick a squad for" %}
+
+
+
+
+
+
{% lucide "user-minus" size=16 %} {% trans "Unrostered members" %}
+
{{ attention.unrostered }}
+
{% trans "Active members on no team" %}
+
+
+
+
+
+
{% lucide "clock" size=16 %} {% trans "Pending" %}
+
{{ attention.pending_approvals }}
+
{% trans "Memberships awaiting approval" %}
+
+
+
+
+
+
{% lucide "sparkles" size=16 %} {% trans "New members" %}
+
{{ attention.new_members }}
+
{% trans "First season at this club" %}
+
+
+
+
+
+
{% lucide "repeat" size=16 %} {% trans "Renewal rate" %}
+
+ {% if attention.renewal_rate is None %}
+ {% trans "N/A" %}
+ {% else %}
+ {{ attention.renewal_rate }}%
+ {% endif %}
+
+
+ {% if attention.renewal_rate is None %}
+ {% trans "No previous season" %}
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}
+
+ {% if attention.attendance.turnout is None %}
+ {% trans "N/A" %}
+ {% else %}
+ {{ attention.attendance.turnout }}%
+ {% endif %}
+
+
+ {% if attention.attendance.turnout is None %}
+ {% trans "No events this season" %}
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
{% lucide "calendar" size=18 %} {% trans "Upcoming events" %}
+
+ {% comment %}
+ A Member has no club of its own -- ClubMembership is the only thing that
+ actually ties this person to *this* club, so it gets its own panel: the
+ current season's standing, plus every season they've been part of.
+ {% endcomment %}
+
+
+
{% lucide "id-card" size=18 %} {% trans "Club membership" %}
+ {% if current_membership %}
+
+
+
{% trans "License" %}
+
{{ current_membership.license|default:"-" }}
+
+
+
{% trans "Status" %}
+
{{ current_membership.get_status_display }}
+
+
+
{% trans "Fee status" %}
+
{{ current_membership.get_fee_status_display }}
+
+
+ {% else %}
+
{% trans "Not rostered for the current season." %}
+ {% endif %}
+
+ {% if membership_history %}
+
+
{% trans "Season history" %}
+
+
+
+
+
{% trans "Season" %}
+
{% trans "Status" %}
+
{% trans "Fee status" %}
+
{% trans "License" %}
+
+
+
+ {% for membership in membership_history %}
+
+
+ {% trans "Add parent" as add_parent_label %}
+ {% trans "Add child" as add_child_label %}
+ {% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
+ {% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %}
+ {% trans "Remove from family" as remove_from_family_title %}
+ {% trans "Remove" as remove_label %}
+ {% for family_group in family_groups %}
+
+ {% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
+
+
+
+ {% if is_club_admin %}
+ {% url 'management:family_add_parent' family_group.family.pk as add_parent_url %}
+ {% url 'management:family_add_child' family_group.family.pk as add_child_url %}
+ {% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %}
+ {% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %}
+ {% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %}
+ {% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %}
+ {% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
+ {% endif %}
+ {% endfor %}
+
+ {% if is_club_admin %}
+
+
+
+
{% lucide "users" size=18 %} {% trans "Family" %}
+
+
+ {% if not family_groups %}
+
{% trans "Not part of a family." %}
+ {% endif %}
+
+
+
+ {% url 'management:member_attach_family' member.pk as attach_family_url %}
+ {% trans "Add to family" as add_to_family_title %}
+ {% trans "Add" as add_label %}
+ {% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
+ {% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
+ {% endif %}
+{% endblock panel %}
diff --git a/management/templates/management/member_form.html b/management/templates/management/member_form.html
new file mode 100644
index 0000000..ff8d828
--- /dev/null
+++ b/management/templates/management/member_form.html
@@ -0,0 +1,62 @@
+{% extends "management/base.html" %}
+{% load lucide ui i18n %}
+
+{% block heading %}{% if update_view %}{% blocktrans with name=object %}Edit {{ name }}{% endblocktrans %}{% else %}{% trans "New member" %}{% endif %}{% endblock heading %}
+
+{% block actions %}
+ {% if update_view %}
+
+ {% endif %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+
+
+
+
+ {% if update_view %}
+ {% url 'management:member_delete' object.pk as member_delete_url %}
+ {% trans "Delete member" as delete_member_title %}
+ {% trans "Delete" as delete_label %}
+ {% blocktrans with full_name=object.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
+ {% include "controlpanel/_confirm_modal.html" with modal_id="member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
+ {% endif %}
+{% endblock panel %}
diff --git a/management/templates/management/member_import.html b/management/templates/management/member_import.html
new file mode 100644
index 0000000..e6798fc
--- /dev/null
+++ b/management/templates/management/member_import.html
@@ -0,0 +1,39 @@
+{% extends "management/base.html" %}
+{% load lucide ui i18n %}
+
+{% block heading %}{% trans "Mass upload members" %}{% endblock heading %}
+
+{% block actions %}
+ {% lucide "download" size=16 %} {% trans "Download template" %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+
+ {% blocktrans %}Fill in the downloaded template — one row per member — then upload it
+ here. Nothing is created yet: you'll see exactly what will be added
+ before anything is saved.{% endblocktrans %}
+
+
+
+
+
+{% endblock panel %}
diff --git a/management/templates/management/member_import_preview.html b/management/templates/management/member_import_preview.html
new file mode 100644
index 0000000..11fe6dc
--- /dev/null
+++ b/management/templates/management/member_import_preview.html
@@ -0,0 +1,68 @@
+{% extends "management/base.html" %}
+{% load lucide i18n %}
+
+{% block heading %}{% trans "Review import" %}{% endblock heading %}
+
+{% block panel %}
+ {% if not season %}
+
+ {% lucide "triangle-alert" size=16 %}
+ {% trans "No active season — members will still be created, but won't be rostered for one until it exists." %}
+
+ {% endif %}
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
{% trans "Row" %}
+
{% trans "Last name" %}
+
{% trans "First name" %}
+
{% trans "Email" %}
+
{% trans "Outcome" %}
+
+
+
+ {% for result in results %}
+
+
{{ result.line_number }}
+
{{ result.raw.last_name }}
+
{{ result.raw.first_name }}
+
{{ result.raw.email }}
+
+ {% if result.member %}
+ {% trans "Will create" %}
+ {% else %}
+ {% trans "Skipped" %}
+
+
+ {% if is_club_admin %}
+ {% trans "Delete member" as delete_member_title %}
+ {% trans "Delete" as delete_label %}
+ {% for member in members %}
+ {% url 'management:member_delete' member.pk as member_delete_url %}
+ {% blocktrans with full_name=member.get_full_name asvar delete_member_body %}Delete {{ full_name }}? This also removes their club membership, roster spots, staff assignments, and family links. This cannot be undone.{% endblocktrans %}
+ {% include "controlpanel/_confirm_modal.html" with modal_id=member.pk|dom_id:"member_delete_modal" title=delete_member_title body=delete_member_body action_url=member_delete_url submit_label=delete_label %}
+ {% endfor %}
+ {% endif %}
+{% endblock panel %}
diff --git a/management/templates/management/membership_list.html b/management/templates/management/membership_list.html
new file mode 100644
index 0000000..de2c81c
--- /dev/null
+++ b/management/templates/management/membership_list.html
@@ -0,0 +1,226 @@
+{% extends "management/base.html" %}
+{% load i18n lucide ui %}
+
+{% block heading %}{% trans "Memberships" %}{% endblock heading %}
+{% block subheading %}{% if current_season %}{% blocktrans %}Fee status for season{% endblocktrans %}{% endif %}{% endblock subheading %}
+
+{% block actions %}
+ {% lucide "file-down" size=16 %} {% trans "Export to PDF" %}
+{% endblock actions %}
+
+{% block panel %}
+ {% if not current_season %}
+
+ {% lucide "calendar-x" size=20 %}
+ {% trans "No season covers today, so there's nothing to show fee status for yet." %}
+
+ {% else %}
+
+
+
+
{% lucide "users" size=16 %} {% trans "Registered" %}
+
{{ kpi_total }}
+
{% trans "This season" %}
+
+
+
+
+
{% lucide "circle-check" size=16 %} {% trans "Paid" %}
+
{{ kpi_paid }}
+
+
+
+
+
{% lucide "circle-dashed" size=16 %} {% trans "Partially paid" %}
+
{{ kpi_partial }}
+
+
+
+
+
{% lucide "circle-x" size=16 %} {% trans "Unpaid" %}
+
{{ kpi_unpaid }}
+
+
+
+
+
{% lucide "circle-check" size=16 %} {% trans "Waived" %}
+
{{ kpi_waived }}
+
+
+
+
+
{% lucide "percent" size=16 %} {% trans "Paid rate" %}
+
+ {% if kpi_paid_rate is None %}{% trans "N/A" %}{% else %}{{ kpi_paid_rate }}%{% endif %}
+
+
+
+
+ {% endif %}
+
+
+
+
+
+ {% trans "Record payment" as record_payment_title %}
+ {% trans "Record" as record_payment_submit_label %}
+ {% for membership in memberships %}
+ {% if membership.record_payment_form %}
+ {# A standalone form, not nested inside the bulk form above -- its submit button lives in the table row and links back here via the form="..." attribute. #}
+
+ {% url 'management:membership_record_payment' membership.pk as record_payment_url %}
+ {% blocktrans with name=membership.member asvar record_payment_blurb %}For {{ name }}. Any amount is fine -- partial payments accumulate until the fee is settled.{% endblocktrans %}
+ {% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"record_payment_modal" title=record_payment_title form=membership.record_payment_form action_url=record_payment_url submit_label=record_payment_submit_label submit_icon="receipt" blurb=record_payment_blurb %}
+ {% endif %}
+ {% endfor %}
+{% endblock panel %}
+
+{% block extra_body %}
+
+{% endblock extra_body %}
diff --git a/management/templates/management/membership_list_pdf.html b/management/templates/management/membership_list_pdf.html
new file mode 100644
index 0000000..c746a6c
--- /dev/null
+++ b/management/templates/management/membership_list_pdf.html
@@ -0,0 +1,99 @@
+{% load i18n %}
+
+{% comment %}
+ Rendered by WeasyPrint, not a browser: a standalone document with its own print
+ stylesheet, same convention as billing/templates/billing/invoice.html -- it
+ deliberately doesn't pull in app.css (daisyUI's dark theme/flex layouts mean
+ nothing on paper).
+{% endcomment %}
+
+
+
+
+ {% trans "Memberships" %}
+
+
+
+
+
+
{{ club.name }}
+
{% trans "Membership fee status" %}{% if selected_season %} — {{ selected_season }}{% endif %}