diff --git a/core/tests.py b/core/tests.py index 340d270..c220a5c 100644 --- a/core/tests.py +++ b/core/tests.py @@ -937,3 +937,123 @@ class TypeSlugFilterTests(TestCase): def test_idempotent_on_already_slugged(self): from core.templatetags.format_tags import type_slug self.assertEqual(type_slug('bonus'), 'bonus') + + +# ============================================================================= +# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) === +# Covers the new tab's backend: filters, sort, stats, pagination. +# Each test creates its own fresh fixture via setUp. +# NOTE: PayrollRecord only accepts worker/date/amount_paid (see core/models.py). +# The plan spec used days_worked/total_amount — those do NOT exist. Adapted. +# ============================================================================= + + +class AdjustmentsTabTests(TestCase): + """New Adjustments tab on /payroll/?status=adjustments.""" + + def setUp(self): + self.admin = User.objects.create_user( + username='adj-admin', password='pass', is_staff=True, is_superuser=True + ) + self.sup = User.objects.create_user( + username='adj-sup', password='pass' + ) + self.w1 = Worker.objects.create( + name='Alice', id_number='A1', monthly_salary=Decimal('4000') + ) + self.w2 = Worker.objects.create( + name='Bob', id_number='B1', monthly_salary=Decimal('4000') + ) + self.team = Team.objects.create(name='Alpha', supervisor=self.admin) + self.team.workers.add(self.w1, self.w2) + self.proj = Project.objects.create(name='Site X') + # 3 unpaid adjustments — 1 bonus Alice, 1 bonus Bob, 1 deduction Alice + self.a1 = PayrollAdjustment.objects.create( + worker=self.w1, project=self.proj, type='Bonus', + amount=Decimal('500'), date=datetime.date(2026, 4, 10), + description='April bonus', + ) + self.a2 = PayrollAdjustment.objects.create( + worker=self.w2, project=self.proj, type='Bonus', + amount=Decimal('300'), date=datetime.date(2026, 4, 11), + description='Project milestone', + ) + self.a3 = PayrollAdjustment.objects.create( + worker=self.w1, project=self.proj, type='Deduction', + amount=Decimal('100'), date=datetime.date(2026, 3, 28), + description='Missing tool', + ) + self.url = reverse('payroll_dashboard') + '?status=adjustments' + + def _login_admin(self): + self.client.login(username='adj-admin', password='pass') + + def test_admin_sees_adjustments_tab(self): + self._login_admin() + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.context['active_tab'], 'adjustments') + # All 3 fixture adjustments should be in the listing + self.assertEqual(len(resp.context['adj_page'].object_list), 3) + + def test_supervisor_forbidden(self): + self.client.login(username='adj-sup', password='pass') + resp = self.client.get(self.url) + # Existing payroll_dashboard pattern: non-admin is redirected home + self.assertEqual(resp.status_code, 302) + + def test_type_multi_filter(self): + self._login_admin() + resp = self.client.get(self.url + '&type=Bonus') + ids = {a.id for a in resp.context['adj_page'].object_list} + self.assertIn(self.a1.id, ids) + self.assertIn(self.a2.id, ids) + self.assertNotIn(self.a3.id, ids) + + def test_worker_multi_filter(self): + self._login_admin() + resp = self.client.get(self.url + f'&worker={self.w1.id}') + ids = {a.id for a in resp.context['adj_page'].object_list} + self.assertIn(self.a1.id, ids) + self.assertNotIn(self.a2.id, ids) + self.assertIn(self.a3.id, ids) + + def test_team_filter_uses_subquery_no_inflation(self): + """Filtering by team must NOT multiply rows (M2M JOIN inflation + would give 6 rows for 3 adjustments x 2 workers on team Alpha).""" + self._login_admin() + resp = self.client.get(self.url + f'&team={self.team.id}') + self.assertEqual(len(resp.context['adj_page'].object_list), 3) + + def test_status_filter_unpaid(self): + self._login_admin() + # Mark a1 as paid — PayrollRecord fields are worker/date/amount_paid + pr = PayrollRecord.objects.create( + worker=self.w1, date=datetime.date(2026, 4, 15), + amount_paid=Decimal('4000'), + ) + self.a1.payroll_record = pr + self.a1.save() + resp = self.client.get(self.url + '&adj_status=unpaid') + ids = {a.id for a in resp.context['adj_page'].object_list} + self.assertNotIn(self.a1.id, ids) + self.assertIn(self.a2.id, ids) + self.assertIn(self.a3.id, ids) + + def test_date_range_filter(self): + self._login_admin() + # March 1 to March 31 -> only a3 (dated 28 Mar) + resp = self.client.get( + self.url + '&adj_date_from=2026-03-01&adj_date_to=2026-03-31' + ) + ids = {a.id for a in resp.context['adj_page'].object_list} + self.assertEqual(ids, {self.a3.id}) + + def test_stats_scoped_to_filtered_set(self): + self._login_admin() + resp = self.client.get(self.url + '&type=Bonus') + # 2 bonuses, 0 paid, total R 800 additive, 0 deductive + self.assertEqual(resp.context['adj_total_count'], 2) + self.assertEqual(resp.context['adj_unpaid_count'], 2) + self.assertEqual(resp.context['adj_additive_sum'], Decimal('800.00')) + self.assertEqual(resp.context['adj_deductive_sum'], Decimal('0.00')) diff --git a/core/views.py b/core/views.py index 73d0784..6699cc5 100644 --- a/core/views.py +++ b/core/views.py @@ -2896,6 +2896,136 @@ def payroll_dashboard(request): 'active_loans_count': active_loans_count, 'active_loans_balance': active_loans_balance, } + + # ========================================================================= + # === ADJUSTMENTS TAB CONTEXT === + # This block only runs when the user is on the Adjustments tab + # (i.e. the URL has ?status=adjustments). It builds a filtered, sorted, + # paginated list of adjustments plus the little stats cards above it. + # + # Group-by rendering, bulk-select, and Team->Workers cross-filter + # will be added in later tasks — this task just covers the basic data. + # ========================================================================= + if status_filter == 'adjustments': + from django.core.paginator import Paginator + from django.utils.dateparse import parse_date + + # --- Read the filter choices the user picked from the URL --- + # Lists come in as ?type=Bonus&type=Deduction etc. + type_filter = request.GET.getlist('type') + worker_filter = [ + int(v) for v in request.GET.getlist('worker') if v.strip().isdigit() + ] + team_filter = [ + int(v) for v in request.GET.getlist('team') if v.strip().isdigit() + ] + adj_status = request.GET.get('adj_status', '').strip() + adj_date_from = request.GET.get('adj_date_from', '').strip() + adj_date_to = request.GET.get('adj_date_to', '').strip() + sort_col = request.GET.get('sort', 'date').strip() + sort_order = request.GET.get('order', 'desc').strip() + + # --- Base queryset with eager-loading of related tables --- + # select_related pulls worker/project/payment in the same SQL query + # so we don't hit the database once per row later. + adjustments = PayrollAdjustment.objects.select_related( + 'worker', 'project', 'payroll_record' + ).prefetch_related('worker__teams') + + # --- Apply each filter only if the user actually set one --- + if type_filter: + adjustments = adjustments.filter(type__in=type_filter) + if worker_filter: + adjustments = adjustments.filter(worker_id__in=worker_filter) + if team_filter: + # SUBQUERY PATTERN (CLAUDE.md "M2M filter + aggregate inflation"): + # Joining straight on workers__teams would multiply the row count + # if a worker is on multiple teams, so we pick the matching worker + # IDs in a subquery first and then filter the outer queryset + # without any JOIN expansion. + adjustments = adjustments.filter( + worker__in=Worker.objects.filter( + teams__id__in=team_filter + ).values('id') + ) + if adj_status == 'unpaid': + adjustments = adjustments.filter(payroll_record__isnull=True) + elif adj_status == 'paid': + adjustments = adjustments.filter(payroll_record__isnull=False) + if adj_date_from: + parsed = parse_date(adj_date_from) + if parsed: + adjustments = adjustments.filter(date__gte=parsed) + if adj_date_to: + parsed = parse_date(adj_date_to) + if parsed: + adjustments = adjustments.filter(date__lte=parsed) + + # --- Sort the results --- + # The URL's "sort" value is a short label; translate it to the + # actual database column. Unknown values fall back to date. + sort_map = { + 'date': 'date', + 'worker': 'worker__name', + 'amount': 'amount', + 'status': 'payroll_record', + } + sort_field = sort_map.get(sort_col, 'date') + if sort_order == 'desc': + sort_field = '-' + sort_field + # Secondary key "-id" keeps rows in a stable order when the + # main sort key has ties (e.g. two adjustments on the same date). + adjustments = adjustments.order_by(sort_field, '-id') + + # --- Stats cards (all computed BEFORE pagination) --- + # These numbers always reflect what the current filter produces, + # not just what fits on the current page. + adj_total_count = adjustments.count() + unpaid_qs = adjustments.filter(payroll_record__isnull=True) + adj_unpaid_count = unpaid_qs.count() + adj_unpaid_sum = unpaid_qs.aggregate( + total=Sum('amount') + )['total'] or Decimal('0.00') + adj_additive_sum = adjustments.filter( + type__in=ADDITIVE_TYPES + ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00') + adj_deductive_sum = adjustments.filter( + type__in=DEDUCTIVE_TYPES + ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00') + + # --- Pagination: 50 rows per page --- + paginator = Paginator(adjustments, 50) + adj_page = paginator.get_page(request.GET.get('page', 1)) + + # --- Everything the Adjustments tab template will need --- + context.update({ + 'adj_page': adj_page, + 'adj_total_count': adj_total_count, + 'adj_unpaid_count': adj_unpaid_count, + 'adj_unpaid_sum': adj_unpaid_sum, + 'adj_additive_sum': adj_additive_sum, + 'adj_deductive_sum': adj_deductive_sum, + 'adj_filter_values': { + 'type': type_filter, + 'worker': worker_filter, + 'team': team_filter, + 'adj_status': adj_status, + 'adj_date_from': adj_date_from, + 'adj_date_to': adj_date_to, + 'sort': sort_col, + 'order': sort_order, + }, + # Flat list of type labels for the Adjustments tab filter dropdown. + # Stored under a separate key so we don't clobber the existing + # 'adjustment_types' context var (which is TYPE_CHOICES tuples + # used by the Add/Edit adjustment modals). + 'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES), + 'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'), + 'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'), + # Task 4 will use this to decide +/- signs on each row. + 'additive_types': list(ADDITIVE_TYPES), + }) + return render(request, 'core/payroll_dashboard.html', context)