Build M5 Me & my people for the mobile app
An account-level hub: a header for the signed-in account's own record
(member-since year, a "Team manager {team}" label for someone managing
1-2 teams, otherwise "Staff"), a "People I manage" list linking each
person into M6's edit screen, and a settings card linking into M6 (for
the account's own record) and M7 (Notifications).
The mockup's "Household & contacts"/"Payments & dues" rows and its
"Coach mode" promo have nowhere to lead in this build -- no dedicated
screen for the former, no Coach mode screens at all for the latter (same
precedent base.html's own role switcher already established) -- so
they're omitted rather than built as dead links. "Licence OK" has no
backing field either; each row's meta line uses real current-season
roster data (team + jersey number) instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
74
mobile/templates/mobile/me.html
Normal file
74
mobile/templates/mobile/me.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me &
|
||||||
|
my people". See MeView's own docstring (mobile/views.py) for the judgment
|
||||||
|
calls: no license/eligibility field backing "licence OK", so each row's
|
||||||
|
meta line is real roster data instead; "Household & contacts" and
|
||||||
|
"Payments & dues" have no screen to lead to and are omitted, same for the
|
||||||
|
mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode
|
||||||
|
screens exist yet, so it's never rendered, not even as a dead/inert link).
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not managed_people %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, your profile will show up here." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="-mx-4 -mt-4 flex items-center gap-3 bg-ink px-4 pt-4 pb-5 text-white">
|
||||||
|
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-club font-display text-xl font-extrabold text-white">{{ me.first_name|slice:":1" }}{{ me.last_name|slice:":1" }}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-display text-xl leading-none font-extrabold uppercase">{{ me.get_full_name }}</div>
|
||||||
|
{% if member_since or team_manager_label %}
|
||||||
|
<div class="mt-1.5 text-[13px] text-on-dark-dim">
|
||||||
|
{% if member_since %}{% blocktrans with year=member_since|date:"Y" %}Member since {{ year }}{% endblocktrans %}{% endif %}
|
||||||
|
{% if member_since and team_manager_label %}·{% endif %}
|
||||||
|
{{ team_manager_label }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "People I manage" %}</div>
|
||||||
|
<div class="m-card overflow-hidden">
|
||||||
|
{% for row in people_rows %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:edit_profile" row.person.pk %}">
|
||||||
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-steel font-display text-sm font-extrabold text-white">{{ row.person.first_name|slice:":1" }}{{ row.person.last_name|slice:":1" }}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[15px] font-semibold text-ink">
|
||||||
|
{{ row.person.get_full_name }}
|
||||||
|
{% if row.person == me %}<span class="text-xs text-muted">{% trans "(me)" %}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if row.membership %}
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{{ row.membership.team.short_name }}
|
||||||
|
{% if row.membership.jersey_number %}· #{{ row.membership.jersey_number }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-card overflow-hidden">
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:edit_profile" me.pk %}">
|
||||||
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Personal details" %}</span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<div class="h-px bg-rule"></div>
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}">
|
||||||
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="pb-2 text-center text-xs text-dim">{% blocktrans with club_name=club.name %}RosterChief · {{ club_name }} · v1.0{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -11,7 +11,7 @@ from events.models import Attendance, Event
|
|||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from news.models import News
|
from news.models import News
|
||||||
from notifications.models import Notification
|
from notifications.models import Notification
|
||||||
from teams.models import Position, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
from .models import PushSubscription
|
from .models import PushSubscription
|
||||||
from .services.icons import render_fallback_icon
|
from .services.icons import render_fallback_icon
|
||||||
@@ -756,3 +756,97 @@ class NewsDetailScreenTests(TestCase):
|
|||||||
response = self._get(news_item)
|
response = self._get(news_item)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class MeViewTests(TestCase):
|
||||||
|
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
|
||||||
|
& my people". See MeView's own docstring for the judgment calls: no
|
||||||
|
licence/eligibility field backing "licence OK" (real roster data used
|
||||||
|
instead), "Household & contacts"/"Payments & dues"/"Coach mode" all
|
||||||
|
omitted since none has anywhere to lead in this build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
|
||||||
|
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||||
|
|
||||||
|
cls.family = Family.objects.create(name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=cls.family, member=cls.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
cls.child = Member.objects.create(first_name="Noor", last_name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season)
|
||||||
|
|
||||||
|
def _get(self):
|
||||||
|
return self.client.get(reverse("mobile:me"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_own_record_shows_the_me_suffix(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Lars Bakker")
|
||||||
|
self.assertContains(response, "(me)")
|
||||||
|
|
||||||
|
def test_every_managed_child_appears_in_the_list(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Noor Bakker")
|
||||||
|
|
||||||
|
def test_a_persons_row_links_to_their_edit_profile_url(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:edit_profile", kwargs={"member_id": self.member.pk}))
|
||||||
|
self.assertContains(response, reverse("mobile:edit_profile", kwargs={"member_id": self.child.pk}))
|
||||||
|
|
||||||
|
def test_a_managed_persons_current_season_team_and_number_show_as_the_meta_line(self):
|
||||||
|
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||||
|
TeamMembership.objects.create(team=team, member=self.child, season=self.season, jersey_number=9)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "U16")
|
||||||
|
self.assertContains(response, "#9")
|
||||||
|
|
||||||
|
def test_coach_mode_promo_is_never_rendered(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, "Coach mode")
|
||||||
|
|
||||||
|
def test_team_manager_label_shows_for_a_current_season_management_staff_assignment(self):
|
||||||
|
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||||
|
position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=position)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Team manager U16")
|
||||||
|
|
||||||
|
def test_empty_account_gets_a_graceful_empty_state(self):
|
||||||
|
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||||
|
self.client.force_login(bare_user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "No one to show yet")
|
||||||
|
self.assertNotContains(response, "Coach mode")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from django.views import View
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from club.models import ClubMembership
|
from club.models import ClubMembership
|
||||||
from club.services.access import current_season
|
from club.services.access import current_season, has_management_access, teams_managed_by
|
||||||
from club.services.fees import remaining_balance
|
from club.services.fees import remaining_balance
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from events.services.calendar import week_bounds
|
from events.services.calendar import week_bounds
|
||||||
@@ -415,10 +415,74 @@ class NewsDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MeView(_PlaceholderScreen):
|
class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
|
||||||
|
& my people". A header for ``self.me`` (member-since year plus a staff
|
||||||
|
label, see below), a "People I manage" card (one row per managed_people,
|
||||||
|
each with their current-season team + jersey number when they're on a
|
||||||
|
roster), and a settings-ish card linking into M6 (edit_profile) for
|
||||||
|
``self.me`` and into M7 (notifications).
|
||||||
|
|
||||||
|
The mockup's "Household & contacts" and "Payments & dues" rows, and its
|
||||||
|
"Coach mode" promo card, have nowhere to lead in this build (no dedicated
|
||||||
|
screen, no Coach mode screens at all yet -- see base.html's own comment)
|
||||||
|
and are deliberately omitted rather than built as dead or inert links.
|
||||||
|
|
||||||
|
There's no license/eligibility field on Member or ClubMembership to power
|
||||||
|
the mockup's "licence OK" text, so each managed person's meta line is
|
||||||
|
real roster data instead: current-season team + jersey number when
|
||||||
|
they're on one, nothing extra otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/me.html"
|
||||||
screen_title = _("Me")
|
screen_title = _("Me")
|
||||||
active_tab = "me"
|
active_tab = "me"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club = self.request.club
|
||||||
|
season = current_season(club)
|
||||||
|
|
||||||
|
member_since = None
|
||||||
|
team_manager_label = None
|
||||||
|
|
||||||
|
if self.me is not None and season is not None:
|
||||||
|
membership = ClubMembership.objects.filter(club=club, member=self.me, season=season).first()
|
||||||
|
if membership is not None:
|
||||||
|
member_since = membership.created
|
||||||
|
|
||||||
|
# PersonScopeMixin.get_context_data computes this same check for the
|
||||||
|
# ``has_staff_access`` context var, but only as a context value, not
|
||||||
|
# an attribute on self -- recomputed here since it's needed before
|
||||||
|
# that runs.
|
||||||
|
if self.me is not None and has_management_access(self.request.user, club):
|
||||||
|
# teams_managed_by returns *every* club team for an ADMIN (see its
|
||||||
|
# own docstring) -- fine for authority checks, but a wall of team
|
||||||
|
# names makes a poor header subtitle, so it only gets spelled out
|
||||||
|
# for someone managing a small, specific handful; anyone else
|
||||||
|
# (including a full club admin) just reads as "Staff".
|
||||||
|
managed_teams = list(teams_managed_by(self.request.user, club))
|
||||||
|
if 1 <= len(managed_teams) <= 2:
|
||||||
|
team_manager_label = _("Team manager %(teams)s") % {"teams": ", ".join(sorted(team.short_name for team in managed_teams))}
|
||||||
|
else:
|
||||||
|
team_manager_label = _("Staff")
|
||||||
|
|
||||||
|
people_rows = []
|
||||||
|
if self.managed_people:
|
||||||
|
memberships_by_member = {}
|
||||||
|
if season is not None:
|
||||||
|
memberships_by_member = {
|
||||||
|
membership.member_id: membership
|
||||||
|
for membership in TeamMembership.objects.filter(member__in=self.managed_people, season=season).select_related("team")
|
||||||
|
}
|
||||||
|
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
member_since=member_since,
|
||||||
|
team_manager_label=team_manager_label,
|
||||||
|
people_rows=people_rows,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditProfileView(_PlaceholderScreen):
|
class EditProfileView(_PlaceholderScreen):
|
||||||
screen_title = _("Edit info")
|
screen_title = _("Edit info")
|
||||||
|
|||||||
@@ -4814,6 +4814,9 @@
|
|||||||
.pb-3 {
|
.pb-3 {
|
||||||
padding-bottom: calc(var(--spacing) * 3);
|
padding-bottom: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.pb-5 {
|
||||||
|
padding-bottom: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.pb-6 {
|
.pb-6 {
|
||||||
padding-bottom: calc(var(--spacing) * 6);
|
padding-bottom: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user