Redesign the Coach mode shell: ice-blue header, flat sheet, Squad/Schedule tabs
Reworks the coach shell per feedback that the previous dark-ink header didn't read as visually distinct from Member mode's own navy header, and that the rounded "sheet overlaps header" treatment wasn't landing: - .coach-header is now solid ice-blue (--color-ice, never club-themed -- Coach mode's own signature colour) with --color-ice-ink foreground text, instead of dark ink with white text. Every header_extra block across the coach templates (attendance, lineup) is recoloured to match. - .coach-sheet drops the 20px rounded-top/negative-margin overlap -- flush edge-to-edge below the header now, same join the member shell already uses. - The header's "Head coach" label was hardcoded regardless of the account's actual role -- CoachScopeMixin now resolves active_team_role from the person's own current-season StaffAssignment.position on the active team, so a team manager or physio sees their real title, not someone else's. Tab bar is now Today / Squad / [+] / Schedule -- no Me tab (the account's own settings already live in Member mode via the role switcher, no need for a second one). Squad (CoachSquadView) is a new roster+staff screen for the active team; Schedule (CoachScheduleView) is a new full upcoming-events list for the team, each row routed straight to the coach-relevant action (Attendance for a practice, Line-up for a game) rather than the Member- shell RSVP page. The "+" is a raised ice-blue circle opening a small popup with New event/New post/Add player -- three genuinely different actions, so the button opens a menu rather than committing to one destination. Today's own inline New event/New post/Add player buttons are gone now that the tab bar covers the same ground. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -447,29 +447,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Coach mode shell (C1-C6) ---------------------------------------------------
|
/* --- Coach mode shell (C1-C6) ---------------------------------------------------
|
||||||
Dark-chrome mirror of the member .app-header/.tab-bar pair above. .coach-sheet is
|
Ice-blue header (never club-themed -- same --color-ice token the rest of Coach
|
||||||
the mode's signature: a light body that overlaps the ink header by 20px via a
|
mode already uses) is the mode's own signature, distinct at a glance from the
|
||||||
matching negative margin, so the header appears to sit "behind" a rounded sheet
|
member shell's club-themed navy header. .coach-sheet sits flush below it, edge to
|
||||||
rather than the two stacking edge-to-edge like the member shell's navy header does. */
|
edge like the member shell's own header/body join -- no rounded overlap. */
|
||||||
|
|
||||||
.coach-header {
|
.coach-header {
|
||||||
padding: max(env(safe-area-inset-top), 14px) 16px 14px;
|
padding: max(env(safe-area-inset-top), 14px) 16px 14px;
|
||||||
background: var(--color-ink);
|
background: var(--color-ice);
|
||||||
color: #fff;
|
color: var(--color-ice-ink);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coach-sheet {
|
.coach-sheet {
|
||||||
background: var(--color-paper);
|
background: var(--color-paper);
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
margin-top: -20px;
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coach-tab-bar {
|
.coach-tab-bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative; /* anchors the "+" action's popup menu */
|
||||||
background: var(--color-ink);
|
background: var(--color-ink);
|
||||||
border-top: 1px solid var(--color-hairline);
|
border-top: 1px solid var(--color-hairline);
|
||||||
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
|
padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
|
||||||
@@ -490,4 +488,28 @@
|
|||||||
.coach-tab-bar-item-active {
|
.coach-tab-bar-item-active {
|
||||||
color: var(--color-ice);
|
color: var(--color-ice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The tab bar's own "+" action -- raised above the bar line in a solid ice
|
||||||
|
circle so it reads as the one prominent action among otherwise-equal nav
|
||||||
|
items, same idea as a FAB parked inside a bottom bar. */
|
||||||
|
.coach-tab-bar-add {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach-tab-bar-add-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: -20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-ice);
|
||||||
|
color: var(--color-ice-ink);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4px solid var(--color-ink);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ several people the way Member mode's person switcher does.
|
|||||||
from club.services.access import current_season, teams_managed_by, teams_staffed_by
|
from club.services.access import current_season, teams_managed_by, teams_staffed_by
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from members.views import ClubScopedPublicMixin
|
from members.views import ClubScopedPublicMixin
|
||||||
|
from teams.models import StaffAssignment
|
||||||
|
|
||||||
#: Session key remembering which team was last active, so navigating between
|
#: Session key remembering which team was last active, so navigating between
|
||||||
#: coach screens (or leaving and coming back) doesn't reset the picker to
|
#: coach screens (or leaving and coming back) doesn't reset the picker to
|
||||||
@@ -33,6 +34,11 @@ class CoachScopeMixin(ClubScopedPublicMixin):
|
|||||||
disabled -- a coach on staff but without a management position (e.g. a
|
disabled -- a coach on staff but without a management position (e.g. a
|
||||||
physio) can see Coach mode but shouldn't see edit affordances they don't
|
physio) can see Coach mode but shouldn't see edit affordances they don't
|
||||||
have the authority to use.
|
have the authority to use.
|
||||||
|
|
||||||
|
``active_team_role`` is ``self.me``'s own StaffAssignment.position on
|
||||||
|
``active_team`` this season (e.g. "Team manager", "Physio") -- the
|
||||||
|
header shows this instead of a hardcoded "Head coach", since not every
|
||||||
|
staffed team is one the account holder actually coaches.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@@ -41,8 +47,15 @@ class CoachScopeMixin(ClubScopedPublicMixin):
|
|||||||
self.managed_teams = list(teams_managed_by(request.user, request.club)) if request.user.is_authenticated else []
|
self.managed_teams = list(teams_managed_by(request.user, request.club)) if request.user.is_authenticated else []
|
||||||
self.active_team = self._resolve_active_team(request)
|
self.active_team = self._resolve_active_team(request)
|
||||||
self.can_manage_active_team = self.active_team is not None and self.active_team in self.managed_teams
|
self.can_manage_active_team = self.active_team is not None and self.active_team in self.managed_teams
|
||||||
|
self.active_team_role = self._resolve_active_team_role(request)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def _resolve_active_team_role(self, request):
|
||||||
|
if self.me is None or self.active_team is None:
|
||||||
|
return None
|
||||||
|
assignment = StaffAssignment.objects.filter(member=self.me, team=self.active_team, season=current_season(request.club)).select_related("position").first()
|
||||||
|
return assignment.position if assignment is not None else None
|
||||||
|
|
||||||
def _resolve_active_team(self, request):
|
def _resolve_active_team(self, request):
|
||||||
requested_id = request.GET.get("team")
|
requested_id = request.GET.get("team")
|
||||||
for team in self.staffed_teams:
|
for team in self.staffed_teams:
|
||||||
@@ -66,6 +79,7 @@ class CoachScopeMixin(ClubScopedPublicMixin):
|
|||||||
staffed_teams=self.staffed_teams,
|
staffed_teams=self.staffed_teams,
|
||||||
active_team=self.active_team,
|
active_team=self.active_team,
|
||||||
can_manage_active_team=self.can_manage_active_team,
|
can_manage_active_team=self.can_manage_active_team,
|
||||||
|
active_team_role=self.active_team_role,
|
||||||
season=current_season(self.request.club),
|
season=current_season(self.request.club),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from management.forms import EventForm, NewsForm
|
|||||||
from members.models import Member
|
from members.models import Member
|
||||||
from news.models import News
|
from news.models import News
|
||||||
from news.services import notify_editors_of_pending_review
|
from news.services import notify_editors_of_pending_review
|
||||||
from teams.models import Team, TeamMembership
|
from teams.models import StaffAssignment, Team, TeamMembership
|
||||||
from teams.services import eligible_roster_members
|
from teams.services import eligible_roster_members
|
||||||
|
|
||||||
from .coach_mixins import CoachScopeMixin
|
from .coach_mixins import CoachScopeMixin
|
||||||
@@ -535,3 +535,46 @@ class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
|
|||||||
publish_lineup(lineup)
|
publish_lineup(lineup)
|
||||||
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
|
||||||
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
|
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
|
||||||
|
|
||||||
|
|
||||||
|
class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""Bottom-tab "Squad" -- the active team's roster and staff for the
|
||||||
|
current season, view-only beyond the "Add player" entry point (which
|
||||||
|
reuses CoachAddPlayerView/C6). No per-row edit here (jersey number,
|
||||||
|
position, captaincy) -- that stays a desktop-only action for now via
|
||||||
|
management.forms.TeamMembershipForm; this screen is about seeing the
|
||||||
|
squad, not managing individual rows from a phone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/coach/squad.html"
|
||||||
|
screen_title = _("Squad")
|
||||||
|
active_tab = "coach_squad"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
roster, staff = [], []
|
||||||
|
if self.active_team is not None and season is not None:
|
||||||
|
roster = list(TeamMembership.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
||||||
|
staff = list(StaffAssignment.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
|
||||||
|
|
||||||
|
return super().get_context_data(roster=roster, staff=staff, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full
|
||||||
|
stop (not Today's own "just the next session" scope). Each row jumps
|
||||||
|
straight into the coach-relevant action -- Bench attendance for a
|
||||||
|
practice, the Line-up for a game -- rather than mobile:event_detail (the
|
||||||
|
Member-shell RSVP page a coach browsing their own team's schedule has no
|
||||||
|
use for)."""
|
||||||
|
|
||||||
|
template_name = "mobile/coach/schedule.html"
|
||||||
|
screen_title = _("Schedule")
|
||||||
|
active_tab = "coach_schedule"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
events = []
|
||||||
|
if self.active_team is not None:
|
||||||
|
events = list(Event.objects.filter(teams=self.active_team, cancelled=False, start__gte=timezone.now()).order_by("start"))
|
||||||
|
|
||||||
|
return super().get_context_data(events=events, **kwargs)
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
{% block header_extra %}
|
{% block header_extra %}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Attendance" %}</span>
|
<span class="font-display text-xl leading-none font-extrabold text-ice-ink uppercase">{% trans "Attendance" %}</span>
|
||||||
<span class="font-mono text-sm text-on-dark">{{ checked_in_count }}/{{ total_count }}</span>
|
<span class="font-mono text-sm text-ice-ink/70">{{ checked_in_count }}/{{ total_count }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
<div class="mt-1 text-xs text-ice-ink/65">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-steel">
|
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-steel">
|
||||||
<div class="h-full bg-ice" style="width: {% widthratio checked_in_count total_count|default:1 100 %}%"></div>
|
<div class="h-full bg-ice" style="width: {% widthratio checked_in_count total_count|default:1 100 %}%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,19 +4,21 @@
|
|||||||
App shell for Coach mode (C1-C6) -- design_handoff_rosterchief_platform/README.md's
|
App shell for Coach mode (C1-C6) -- design_handoff_rosterchief_platform/README.md's
|
||||||
"Coach mode (mobile, dark chrome)" section. Standalone from mobile/templates/mobile/
|
"Coach mode (mobile, dark chrome)" section. Standalone from mobile/templates/mobile/
|
||||||
base.html (Member mode's shell) rather than a shared parent: the two have almost no
|
base.html (Member mode's shell) rather than a shared parent: the two have almost no
|
||||||
markup in common beyond the outer <html>/<body> skeleton -- dark ink header instead
|
markup in common beyond the outer <html>/<body> skeleton -- an ice-blue header
|
||||||
of navy, a light .coach-sheet body that overlaps the header by 20px (the mode's own
|
(never club-themed, the mode's own signature colour, see assets/mobile.css's own
|
||||||
signature, see assets/mobile.css's own comment), and a dark .coach-tab-bar instead of
|
comment) instead of the member shell's club-themed navy, and a dark .coach-tab-bar
|
||||||
the white one.
|
instead of the white one.
|
||||||
|
|
||||||
Reuses the SAME stylesheet (static/css/mobile.css) and the same mobile:manifest/
|
Reuses the SAME stylesheet (static/css/mobile.css) and the same mobile:manifest/
|
||||||
mobile:icon/mobile:service_worker PWA plumbing as Member mode -- one app, one
|
mobile:icon/mobile:service_worker PWA plumbing as Member mode -- one app, one
|
||||||
manifest, two modes, not two separate PWAs.
|
manifest, two modes, not two separate PWAs.
|
||||||
|
|
||||||
The tab bar deliberately stays minimal (Today + Me) even with all six coach screens
|
Tab bar: Today / Squad / Schedule, plus a raised "+" action in the middle that opens
|
||||||
built -- C2/C3/C4/C5/C6 are reached from Today's own action buttons and "needs you"
|
a small popup menu (New event / New post / Add player) rather than committing to one
|
||||||
list, not separate tab items; Me reuses the *existing* member mobile:me page rather
|
destination -- coach mode has three genuinely different "add" actions and picking a
|
||||||
than a separate coach-Me screen (see CoachTodayView's own docstring).
|
single one for the button would just hide the other two. No Me tab here -- the
|
||||||
|
account's own settings live in Member mode's mobile:me, reached via the role
|
||||||
|
switcher above, not duplicated as a second Me screen.
|
||||||
|
|
||||||
hx-boost="true" on <body>: every same-shell link/form becomes an AJAX navigation
|
hx-boost="true" on <body>: every same-shell link/form becomes an AJAX navigation
|
||||||
instead of a full browser reload -- see mobile/templates/mobile/base.html's own
|
instead of a full browser reload -- see mobile/templates/mobile/base.html's own
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#0b1220">
|
<meta name="theme-color" content="#14b8e8">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -66,15 +68,15 @@
|
|||||||
<span class="app-crest app-crest-fallback">{{ club.initials }}</span>
|
<span class="app-crest app-crest-fallback">{{ club.initials }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate font-display text-[19px] leading-none font-extrabold text-white uppercase">{{ active_team.name|default:club.name }}</span>
|
<span class="block truncate font-display text-[19px] leading-none font-extrabold text-ice-ink uppercase">{{ active_team.name|default:club.name }}</span>
|
||||||
{% if me %}<span class="block font-mono text-xs text-on-dark">{% trans "Head coach" %} · {{ me.first_name }}</span>{% endif %}
|
{% if me and active_team_role %}<span class="block font-mono text-xs text-ice-ink/70">{{ active_team_role }} · {{ me.first_name }}</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if staffed_teams|length > 1 %}
|
{% if staffed_teams|length > 1 %}
|
||||||
<div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide">
|
<div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide">
|
||||||
{% for team in staffed_teams %}
|
{% for team in staffed_teams %}
|
||||||
<a class="shrink-0 rounded-full px-3 py-1.5 font-display text-xs font-extrabold tracking-wide uppercase {% if team == active_team %}bg-ice text-ice-ink{% else %}bg-steel text-on-dark{% endif %}" href="?team={{ team.pk }}">
|
<a class="shrink-0 rounded-full px-3 py-1.5 font-display text-xs font-extrabold tracking-wide uppercase {% if team == active_team %}bg-ink text-ice{% else %}bg-white/40 text-ice-ink{% endif %}" href="?team={{ team.pk }}">
|
||||||
{{ team.short_name }}
|
{{ team.short_name }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -108,14 +110,35 @@
|
|||||||
{% lucide "house" size=21 %}
|
{% lucide "house" size=21 %}
|
||||||
<span class="tab-bar-label">{% trans "Today" %}</span>
|
<span class="tab-bar-label">{% trans "Today" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% comment %}
|
<a class="coach-tab-bar-item {% if active_tab == "coach_squad" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_squad" %}">
|
||||||
hx-boost="false" -- mobile:me is a Member-shell view (bg-paper, not
|
{% lucide "users" size=21 %}
|
||||||
this page's bg-ink); see base.html's own comment for why a boosted
|
<span class="tab-bar-label">{% trans "Squad" %}</span>
|
||||||
cross-shell swap would leave the wrong <body> class in place.
|
</a>
|
||||||
{% endcomment %}
|
|
||||||
<a class="coach-tab-bar-item" href="{% url "mobile:me" %}" hx-boost="false">
|
{% if can_manage_active_team %}
|
||||||
{% lucide "user" size=21 %}
|
<div class="coach-tab-bar-add" x-data="{ open: false }">
|
||||||
<span class="tab-bar-label">{% trans "Me" %}</span>
|
<button class="coach-tab-bar-add-button" type="button" @click="open = !open" aria-label="{% trans "Add" %}">
|
||||||
|
{% lucide "plus" size=24 %}
|
||||||
|
</button>
|
||||||
|
<div class="absolute inset-x-3 bottom-full z-20 mb-3 flex flex-col gap-1 rounded-xl border border-line bg-white p-2 shadow-lg" x-show="open" x-cloak @click.outside="open = false">
|
||||||
|
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_create_event" %}">
|
||||||
|
{% lucide "calendar-plus" size=18 class="text-club" %} {% trans "New event" %}
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_create_news" %}">
|
||||||
|
{% lucide "newspaper" size=18 class="text-club" %} {% trans "New post" %}
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_add_player" %}">
|
||||||
|
{% lucide "user-plus" size=18 class="text-club" %} {% trans "Add player" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="coach-tab-bar-add"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="coach-tab-bar-item {% if active_tab == "coach_schedule" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_schedule" %}">
|
||||||
|
{% lucide "calendar" size=21 %}
|
||||||
|
<span class="tab-bar-label">{% trans "Schedule" %}</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
|
|
||||||
{% block header_extra %}
|
{% block header_extra %}
|
||||||
<div class="mt-3 flex items-center justify-between">
|
<div class="mt-3 flex items-center justify-between">
|
||||||
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Line-up" %}</span>
|
<span class="font-display text-xl leading-none font-extrabold text-ice-ink uppercase">{% trans "Line-up" %}</span>
|
||||||
{% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %}
|
{% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
<div class="mt-1 text-xs text-ice-ink/65">{{ event.title }} · {{ event.start|date:"D d M H:i" }}</div>
|
||||||
{% endblock header_extra %}
|
{% endblock header_extra %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
39
mobile/templates/mobile/coach/schedule.html
Normal file
39
mobile/templates/mobile/coach/schedule.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "mobile/coach/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Bottom-tab "Schedule" -- every upcoming event for the active team (not
|
||||||
|
just the next one, that's Today's job). Each row links straight into the
|
||||||
|
coach-relevant action for its kind rather than mobile:event_detail (the
|
||||||
|
Member-shell RSVP page) -- see CoachScheduleView's own docstring.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not active_team %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
|
||||||
|
</div>
|
||||||
|
{% elif not events %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="m-card flex flex-col overflow-hidden">
|
||||||
|
{% for event in events %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
|
||||||
|
<div class="w-9.5 shrink-0 text-center">
|
||||||
|
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
|
||||||
|
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
|
||||||
|
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} · {{ event.location.name }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
62
mobile/templates/mobile/coach/squad.html
Normal file
62
mobile/templates/mobile/coach/squad.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "mobile/coach/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Bottom-tab "Squad" -- roster + staff for the active team, current season.
|
||||||
|
See CoachSquadView's own docstring for why there's no per-row edit here
|
||||||
|
(jersey number/position/captaincy stays a desktop-only action for now).
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not active_team %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Roster" %}</span>
|
||||||
|
{% if can_manage_active_team %}
|
||||||
|
<a class="flex items-center gap-1 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_add_player" %}">
|
||||||
|
{% lucide "user-plus" size=14 %} {% trans "Add" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="m-card overflow-hidden">
|
||||||
|
{% for membership in roster %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<div class="flex items-center gap-3 p-3.5">
|
||||||
|
<div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ membership.jersey_number|default:"—" }}</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[15px] font-semibold text-ink">
|
||||||
|
{{ membership.member.get_full_name }}
|
||||||
|
{% if membership.is_captain %}<span class="ml-1 font-display text-xs font-extrabold text-club-dark">C</span>{% endif %}
|
||||||
|
{% if membership.is_alternate_captain %}<span class="ml-1 font-display text-xs font-extrabold text-club-dark">A</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted">{{ membership.position|default:"—" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-6 text-center text-sm text-muted">{% trans "No one on the roster for this season yet." %}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Staff" %}</div>
|
||||||
|
<div class="m-card overflow-hidden">
|
||||||
|
{% for assignment in staff %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<div class="flex items-center gap-3 p-3.5">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[15px] font-semibold text-ink">{{ assignment.member.get_full_name }}</div>
|
||||||
|
<div class="text-xs text-muted">{{ assignment.position }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-6 text-center text-sm text-muted">{% trans "No staff assigned for this season yet." %}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
stat tiles, a tonight's-session card (only rendered when one exists),
|
stat tiles, a tonight's-session card (only rendered when one exists),
|
||||||
a "needs you" list, and an "Also yours" card for the coach's own
|
a "needs you" list, and an "Also yours" card for the coach's own
|
||||||
member-side RSVP. See CoachTodayView's own docstring for what's scoped
|
member-side RSVP. See CoachTodayView's own docstring for what's scoped
|
||||||
down from the mock and why.
|
down from the mock and why. The mock's own New event/New post/Add
|
||||||
|
player actions live on the shell's own "+" tab-bar button now (coach/
|
||||||
|
base.html) rather than repeated here as inline buttons.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -16,14 +18,6 @@
|
|||||||
<p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p>
|
<p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if can_manage_active_team %}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<a class="btn btn-dark flex-1" href="{% url "mobile:coach_create_event" %}">{% trans "New event" %}</a>
|
|
||||||
<a class="btn btn-secondary flex-1" href="{% url "mobile:coach_create_news" %}">{% trans "New post" %}</a>
|
|
||||||
<a class="btn btn-secondary flex-1" href="{% url "mobile:coach_add_player" %}">{% trans "Add player" %}</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2.5">
|
<div class="grid grid-cols-3 gap-2.5">
|
||||||
<div class="m-card p-3 text-center">
|
<div class="m-card p-3 text-center">
|
||||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>
|
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>
|
||||||
|
|||||||
168
mobile/tests.py
168
mobile/tests.py
@@ -1891,6 +1891,174 @@ class CoachTodayViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertNotContains(response, "Line-up not published")
|
self.assertNotContains(response, "Line-up not published")
|
||||||
|
|
||||||
|
def test_header_shows_the_persons_actual_role_not_a_hardcoded_label(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Head coach")
|
||||||
|
self.assertEqual(response.context["active_team_role"], self.position)
|
||||||
|
|
||||||
|
def test_header_role_reflects_a_non_coaching_staff_position(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Team manager", short_name="TM", staff_position=True, management_position=True)
|
||||||
|
physio_user = User.objects.create_user(email="tm@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Tom", last_name="Manager", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Team manager")
|
||||||
|
self.assertNotContains(response, "Head coach")
|
||||||
|
|
||||||
|
def test_tab_bar_has_no_me_tab(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, reverse("mobile:me"))
|
||||||
|
|
||||||
|
def test_tab_bar_links_to_squad_and_schedule(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:coach_squad"))
|
||||||
|
self.assertContains(response, reverse("mobile:coach_schedule"))
|
||||||
|
|
||||||
|
def test_add_menu_hidden_for_non_managing_staff(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
|
||||||
|
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, reverse("mobile:coach_create_event"))
|
||||||
|
self.assertNotContains(response, reverse("mobile:coach_create_news"))
|
||||||
|
self.assertNotContains(response, reverse("mobile:coach_add_player"))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CoachSquadViewTests(TestCase):
|
||||||
|
"""Bottom-tab "Squad" -- roster + staff for the active team."""
|
||||||
|
|
||||||
|
@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="coach@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||||
|
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
|
||||||
|
|
||||||
|
def _get(self):
|
||||||
|
return self.client.get(reverse("mobile:coach_squad"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_lists_roster_and_staff(self):
|
||||||
|
player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False)
|
||||||
|
player = Member.objects.create(first_name="Anna", last_name="Player")
|
||||||
|
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=player_position, jersey_number=9)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, "Anna Player")
|
||||||
|
self.assertContains(response, "Forward")
|
||||||
|
self.assertContains(response, "Sam Coach")
|
||||||
|
self.assertContains(response, "Head coach")
|
||||||
|
|
||||||
|
def test_add_link_hidden_for_non_managing_staff(self):
|
||||||
|
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
|
||||||
|
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
|
||||||
|
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
|
||||||
|
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
|
||||||
|
self.client.force_login(physio_user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, reverse("mobile:coach_add_player"))
|
||||||
|
|
||||||
|
def test_no_staff_assignment_shows_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, "Not staffing a team yet")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CoachScheduleViewTests(TestCase):
|
||||||
|
"""Bottom-tab "Schedule" -- every upcoming event for the active team,
|
||||||
|
each row routed to the coach-relevant action for its kind."""
|
||||||
|
|
||||||
|
@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="coach@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||||
|
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
|
||||||
|
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
|
||||||
|
|
||||||
|
def _get(self):
|
||||||
|
return self.client.get(reverse("mobile:coach_schedule"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_game_row_links_to_lineup(self):
|
||||||
|
game = Event.objects.create(club=self.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=2))
|
||||||
|
game.teams.add(self.team)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:coach_lineup", kwargs={"event_id": game.pk}))
|
||||||
|
|
||||||
|
def test_training_row_links_to_attendance(self):
|
||||||
|
practice = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=2))
|
||||||
|
practice.teams.add(self.team)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, reverse("mobile:coach_attendance", kwargs={"event_id": practice.pk}))
|
||||||
|
|
||||||
|
def test_past_and_cancelled_events_are_excluded(self):
|
||||||
|
past = Event.objects.create(club=self.club, title="Old practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(days=2))
|
||||||
|
past.teams.add(self.team)
|
||||||
|
cancelled = Event.objects.create(club=self.club, title="Cancelled game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=2), cancelled=True)
|
||||||
|
cancelled.teams.add(self.team)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.context["events"], [])
|
||||||
|
|
||||||
|
def test_no_staff_assignment_shows_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, "Not staffing a team yet")
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
class CoachAttendanceViewTests(TestCase):
|
class CoachAttendanceViewTests(TestCase):
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ urlpatterns = [
|
|||||||
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
||||||
# Coach mode (C1-C6).
|
# Coach mode (C1-C6).
|
||||||
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
|
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
|
||||||
|
path("coach/squad/", coach_views.CoachSquadView.as_view(), name="coach_squad"),
|
||||||
|
path("coach/schedule/", coach_views.CoachScheduleView.as_view(), name="coach_schedule"),
|
||||||
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
|
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
|
||||||
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
|
||||||
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),
|
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),
|
||||||
|
|||||||
@@ -2696,6 +2696,9 @@
|
|||||||
.inset-0 {
|
.inset-0 {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
.inset-x-3 {
|
||||||
|
inset-inline: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.dropdown-right {
|
.dropdown-right {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--anchor-h: right;
|
--anchor-h: right;
|
||||||
@@ -4618,6 +4621,12 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-white\/40 {
|
||||||
|
background-color: color-mix(in srgb, #fff 40%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-white) 40%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.btn-link {
|
.btn-link {
|
||||||
@layer daisyui {
|
@layer daisyui {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user