feat(workers): add team filter to /workers/ page

New ?team=<id> URL param narrows the worker list to that team's
members via the Team.workers M2M. ?team=none filters to workers
not assigned to any team. Default (empty) still shows all
matching workers across all teams.

UI: new "Team" dropdown in the filter row, between Search and
Status. Lists active teams alphabetically. Layout reflowed to
col-md-4 / col-md-3 / col-md-3 / col-md-2.

Konrad's checkpoint feedback: "in the worker page - can i have a
filter for teams so i can easely see who is in what team".

4 regression tests covering no-filter, by-team, no-team, and
dropdown options.
This commit is contained in:
Konrad du Plessis 2026-05-15 00:34:35 +02:00
parent 02c6d4da74
commit 4b57cffb77
3 changed files with 95 additions and 6 deletions

View File

@ -33,11 +33,21 @@
<div class="card mb-3">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-6">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Search</label>
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="Name, ID number, or phone...">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Team</label>
<select name="team" class="form-select">
<option value="" {% if not team_filter %}selected{% endif %}>All teams</option>
<option value="none" {% if team_filter == 'none' %}selected{% endif %}>— No team assigned —</option>
{% for t in teams_for_filter %}
<option value="{{ t.id }}" {% if team_filter == t.id|stringformat:'s' %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Status</label>
<select name="status" class="form-select">
@ -46,7 +56,7 @@
<option value="all" {% if status == 'all' %}selected{% endif %}>All workers</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-1"></i>Filter
</button>
@ -107,7 +117,7 @@
{% else %}
<p class="text-center py-5 mb-0" style="color: var(--text-secondary);">
No workers{% if q %} match "<strong>{{ q }}</strong>"{% endif %}.
{% if q or status != 'active' %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
{% if q or status != 'active' or team_filter %}<br><a href="{% url 'worker_list' %}">Clear filters</a>{% endif %}
</p>
{% endif %}
</div>

View File

@ -2880,3 +2880,62 @@ class AbsenceAdminAndCascadeTests(TestCase):
absence.refresh_from_db()
self.assertFalse(absence.is_paid)
self.assertIsNone(absence.payroll_adjustment)
class WorkerListTeamFilterTests(TestCase):
"""The /workers/ page accepts ?team=<id> to narrow the list to that
team's members, ?team=none for unassigned workers, and ?team= (empty)
for the default 'All teams' view."""
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_user(
username='admin', password='pw', is_staff=True, is_superuser=True,
)
# Two teams with workers and one worker unassigned to any team.
cls.team_a = Team.objects.create(name='Alpha Team')
cls.team_b = Team.objects.create(name='Bravo Team')
cls.w_alpha = Worker.objects.create(
name='Alice Alpha', id_number='AA1', monthly_salary=Decimal('6000'),
)
cls.w_bravo = Worker.objects.create(
name='Bob Bravo', id_number='BB1', monthly_salary=Decimal('6000'),
)
cls.w_orphan = Worker.objects.create(
name='Otto Orphan', id_number='OO1', monthly_salary=Decimal('6000'),
)
cls.team_a.workers.add(cls.w_alpha)
cls.team_b.workers.add(cls.w_bravo)
# w_orphan stays unassigned
def setUp(self):
self.client.force_login(self.admin)
def test_no_filter_shows_all_active(self):
resp = self.client.get('/workers/')
self.assertContains(resp, 'Alice Alpha')
self.assertContains(resp, 'Bob Bravo')
self.assertContains(resp, 'Otto Orphan')
def test_team_filter_narrows_to_team(self):
resp = self.client.get(f'/workers/?team={self.team_a.id}')
self.assertContains(resp, 'Alice Alpha')
self.assertNotContains(resp, 'Bob Bravo')
self.assertNotContains(resp, 'Otto Orphan')
def test_team_filter_none_shows_only_unassigned(self):
resp = self.client.get('/workers/?team=none')
self.assertNotContains(resp, 'Alice Alpha')
self.assertNotContains(resp, 'Bob Bravo')
self.assertContains(resp, 'Otto Orphan')
def test_team_filter_dropdown_lists_active_teams(self):
"""The Team filter dropdown should render the active teams as
options, plus 'All teams' and 'No team assigned'."""
resp = self.client.get('/workers/')
self.assertContains(resp, 'All teams')
self.assertContains(resp, 'No team assigned')
self.assertContains(resp, 'Alpha Team')
self.assertContains(resp, 'Bravo Team')

View File

@ -1478,19 +1478,22 @@ def export_workers_csv(request):
@login_required
def worker_list(request):
"""Admin-friendly list of all workers with search + status filter.
"""Admin-friendly list of all workers with search + status + team filter.
Query params:
?q=search_term search name / ID number / phone
?status=active default, only active workers
?status=inactive only inactive
?status=all both
?team=<id> only workers belonging to this team (M2M)
?team=none workers NOT assigned to any team
"""
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
q = (request.GET.get('q') or '').strip()
status = request.GET.get('status') or 'active'
team_filter = (request.GET.get('team') or '').strip()
workers = Worker.objects.all()
if status == 'active':
@ -1504,15 +1507,32 @@ def worker_list(request):
Q(name__icontains=q) | Q(id_number__icontains=q) | Q(phone_number__icontains=q)
)
# Annotate days worked (distinct WorkLog dates) — shown in the table
# === Team filter ===
# 'none' is a special value meaning "no team assigned at all".
# Any numeric value is treated as a Team primary key and narrows
# the list to that team's members via the Team.workers M2M.
if team_filter == 'none':
workers = workers.filter(teams__isnull=True)
elif team_filter.isdigit():
workers = workers.filter(teams__id=int(team_filter))
# Annotate days worked (distinct WorkLog dates) — shown in the table.
# `.distinct()` is also needed to avoid duplicate Worker rows when
# the team filter joins through the M2M (a worker on multiple teams
# would appear twice without it — uncommon but possible).
workers = workers.annotate(
days_worked=Count('work_logs__date', distinct=True),
).order_by('name')
).distinct().order_by('name')
# Build the team dropdown options. Only active teams shown; alphabetical.
teams_for_filter = Team.objects.filter(active=True).order_by('name')
context = {
'workers': workers,
'q': q,
'status': status,
'team_filter': team_filter,
'teams_for_filter': teams_for_filter,
'total_count': workers.count(),
}
return render(request, 'core/workers/list.html', context)