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:
parent
02c6d4da74
commit
4b57cffb77
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user