diff --git a/core/templates/core/workers/list.html b/core/templates/core/workers/list.html index 9f44061..585be27 100644 --- a/core/templates/core/workers/list.html +++ b/core/templates/core/workers/list.html @@ -33,11 +33,21 @@
-
+
+
+ + +
-
+
@@ -107,7 +117,7 @@ {% else %}

No workers{% if q %} match "{{ q }}"{% endif %}. - {% if q or status != 'active' %}
Clear filters{% endif %} + {% if q or status != 'active' or team_filter %}
Clear filters{% endif %}

{% endif %}
diff --git a/core/tests.py b/core/tests.py index 2534c00..1aa9286 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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= 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') diff --git a/core/views.py b/core/views.py index ea332bd..9073159 100644 --- a/core/views.py +++ b/core/views.py @@ -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= — 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)