diff --git a/docs/plans/2026-04-23-adjustments-tab-plan.md b/docs/plans/2026-04-23-adjustments-tab-plan.md new file mode 100644 index 0000000..e89da38 --- /dev/null +++ b/docs/plans/2026-04-23-adjustments-tab-plan.md @@ -0,0 +1,1884 @@ +# Payroll Adjustments Tab — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or superpowers:subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Add a new **Adjustments** tab to the payroll dashboard so admins can browse every `PayrollAdjustment` across all workers with multi-select filters, semantic badges, group-by, bulk-delete, and inline actions that reuse the existing modal infrastructure. + +**Architecture:** Extend the existing `payroll_dashboard` view with a new `?status=adjustments` branch (mirrors the pattern already used for `pending` / `paid` / `loans`). Template gets a new `
  • ` in the nav-tabs strip plus a gated content block. Modals and JS handlers already on the payroll dashboard (`#addAdjustmentModal`, `#editAdjustmentModal`, `#previewPayslipModal`) are reused as-is. No new models, no migrations. One new endpoint (`bulk_delete_adjustments`), one new template filter (`type_slug`), one new shared partial (`_adjustment_row.html`). + +**Tech Stack:** Django 5.2.7, Python 3.13, Bootstrap 5.3, Choices.js 10.2.0 (already on the payroll dashboard), Font Awesome 6, WeasyPrint (not touched). Local dev = SQLite + `run_dev.bat`; production = MySQL on Flatlogic. + +**Design doc:** `docs/plans/2026-04-23-adjustments-tab-design.md` (commit `12edafa`, local-only). Read sections 1–13 for full rationale; this plan executes on that doc. + +**CLAUDE.md — mandatory read-before-touch:** +- "Coding Style" section — section-header comment convention + plain-English comments for the non-programmer owner +- "PayrollAdjustment Type Handling" — ADDITIVE_TYPES / DEDUCTIVE_TYPES constants +- "Schema name-drifts to remember" — `PayrollAdjustment.description` (NOT `reason`); `log.adjustments_by_work_log` (NOT `payrolladjustment_set`) +- "Django ORM gotcha — M2M filter + aggregate inflation" — the subquery pattern `id__in=Model.objects.filter(m2m__field=X).values('id')` is mandatory when you filter on an M2M relation AND aggregate + +--- + +## Who it's for + +Admins only (`is_admin(user)` = `is_staff OR is_superuser`). Supervisors get 403. + +## Files touched + +| File | What | +|------|------| +| `core/views.py` | New `elif status_filter == 'adjustments'` branch in `payroll_dashboard`; new `_group_adjustments` helper; new `bulk_delete_adjustments` view | +| `core/urls.py` | One new path for bulk-delete | +| `core/templatetags/format_tags.py` | New `type_slug` filter | +| `core/templates/core/payroll_dashboard.html` | New `
  • ` in tab strip; new `{% elif active_tab == 'adjustments' %}` block; reuse existing modals | +| `core/templates/core/_adjustment_row.html` | New shared partial used by flat AND grouped views | +| `static/css/custom.css` | Badge palette tokens (dark + light themes) + 7 badge classes + sticky filter bar + group header + bulk action bar styles | +| `core/tests.py` | New `AdjustmentsTabTests` class (~11 tests) | + +## Task sequence + checkpoint + +| # | Task | Type | Checkpoint gate? | +|---|------|------|:-:| +| 1 | `type_slug` template filter | TDD | | +| 2 | CSS badge palette + foundational styles | UI | | +| 3 | Backend filter branch + stats (no group/bulk/cross-filter yet) | TDD | | +| 4 | Tab markup + filter bar + flat table + row actions | UI + smoke test | | +| — | **CHECKPOINT 1** — Konrad demos the core experience | | ✅ hard-pause | +| 5 | Group-by helper + toggle + rendering | TDD | | +| 6 | Bulk-delete endpoint + floating action bar | TDD | | +| 7 | Cross-filter Team → Workers + auto-removal toast | Integration | | +| 8 | Date picker single vs. range + preset buttons | UI | | +| 9 | Sort header clicks + URL state | UI | | +| 10 | Empty state + sticky filter bar polish | UI | | +| 11 | Manual QA sweep + design-doc "Shipped" block | Docs | | + +Target: ~960 LOC across all files, per design §13. + +--- + +## Task 1: `type_slug` template filter + +**Goal:** a tiny helper that turns `"Advance Payment"` → `"advance-payment"` so templates can build class names like `badge-type-advance-payment`. + +**Files:** +- Modify: `core/templatetags/format_tags.py` (existing file — `money` filter lives here; add alongside) +- Test: `core/tests.py` (new `TypeSlugFilterTests` class) + +**Step 1: Write the failing test** + +Append to `core/tests.py` (near the bottom, after the last `Tests` class): + +```python +# ============================================================================= +# === TESTS FOR |type_slug FILTER === +# Used by Adjustments tab to build CSS class names from type labels. +# ============================================================================= + + +class TypeSlugFilterTests(TestCase): + """format_tags.type_slug converts adjustment-type labels to slugs.""" + + def test_spaces_become_hyphens_and_lowercased(self): + from core.templatetags.format_tags import type_slug + self.assertEqual(type_slug('Advance Payment'), 'advance-payment') + self.assertEqual(type_slug('New Loan'), 'new-loan') + self.assertEqual(type_slug('Bonus'), 'bonus') + + def test_empty_or_none_returns_empty_string(self): + from core.templatetags.format_tags import type_slug + self.assertEqual(type_slug(''), '') + self.assertEqual(type_slug(None), '') + + def test_idempotent_on_already_slugged(self): + from core.templatetags.format_tags import type_slug + self.assertEqual(type_slug('bonus'), 'bonus') +``` + +**Step 2: Run to verify failure** + +```bash +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2 +``` + +Expected: `ImportError: cannot import name 'type_slug' from 'core.templatetags.format_tags'`. + +**Step 3: Implement** + +Edit `core/templatetags/format_tags.py` — append: + +```python +# === type_slug filter === +# Converts adjustment-type labels like "Advance Payment" to +# CSS-class-friendly slugs like "advance-payment". Used by the Adjustments +# tab to pick the right colour badge class per row. +@register.filter +def type_slug(value): + """Return a hyphen-separated lowercase version of `value`. + + Used in templates: ``. + Returns '' for None / empty — no class generated, no crash. + """ + if not value: + return '' + return value.lower().replace(' ', '-') +``` + +**Step 4: Run to verify pass** + +```bash +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2 +``` + +Expected: 3 tests pass. Full suite: `python manage.py test core.tests` → 47 + 3 = 50 tests pass. + +**Step 5: Commit** + +```bash +git add core/templatetags/format_tags.py core/tests.py +git commit -m "feat(adjustments): add |type_slug template filter for badge class naming" +``` + +--- + +## Task 2: CSS badge palette + foundational styles + +**Goal:** Add the 14 badge colour tokens (7 types × 2 themes) plus 7 badge classes plus the sticky filter bar / group header / bulk-action-bar skeleton styles. No functional wiring yet — just the visual vocabulary. + +No TDD here (CSS-only). Smoke test: render a throwaway `Bonus` in any template and eyeball. + +**Files:** +- Modify: `static/css/custom.css` (append near end, after the inline-filters block — line ~1855+) + +**Step 1: Add badge palette tokens to both themes** + +Find the `:root {` block near the top of `custom.css` (the CSS-variables root). Append the 7 dark-theme tokens INSIDE the existing `:root` block — keep them near other colour tokens: + +```css +/* === ADJUSTMENTS TAB — badge palette (dark theme) === */ +/* Each adjustment type has its own colour family. Loan-Repayment and + Advance-Repayment are +15% saturation siblings of their parent colour + so "money coming back" reads as a hotter signal than "money going out". */ +--badge-bonus-bg: #5b8260; --badge-bonus-fg: #e8f3ea; +--badge-overtime-bg: #a16881; --badge-overtime-fg: #fce4ec; +--badge-deduction-bg: #5b4f8c; --badge-deduction-fg: #e0daf3; +--badge-loan-bg: #9b7f39; --badge-loan-fg: #fef4d1; +--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1; +--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2; +--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2; +``` + +Then find the `:root.light {` block (light-theme overrides). Append the light-theme pairs INSIDE it: + +```css +/* === ADJUSTMENTS TAB — badge palette (light theme) === */ +--badge-bonus-bg: #d7e8d9; --badge-bonus-fg: #385640; +--badge-overtime-bg: #f3d1dd; --badge-overtime-fg: #703347; +--badge-deduction-bg: #d8d0ef; --badge-deduction-fg: #3b2f6d; +--badge-loan-bg: #f0dc9d; --badge-loan-fg: #6a5320; +--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418; +--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56; +--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550; +``` + +**Step 2: Add badge classes + layout skeleton** + +Append at the END of `custom.css`: + +```css +/* ============================================================================= + * ADJUSTMENTS TAB + * Visual vocabulary for the Payroll → Adjustments tab. + * - 7 badge classes, one per adjustment type + * - Sticky filter bar that stays visible as the table scrolls + * - Group-by header style (collapsible section divider) + * - Floating bulk-action bar at the bottom of the viewport + * ============================================================================= */ + +/* --- Type badges (one class per PayrollAdjustment type) --- */ +.badge-type-bonus, +.badge-type-overtime, +.badge-type-deduction, +.badge-type-new-loan, +.badge-type-loan-repayment, +.badge-type-advance-payment, +.badge-type-advance-repayment { + display: inline-block; + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-family: 'Inter', sans-serif; + font-size: 0.7rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} +.badge-type-bonus { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } +.badge-type-overtime { background: var(--badge-overtime-bg); color: var(--badge-overtime-fg); } +.badge-type-deduction { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); } +.badge-type-new-loan { background: var(--badge-loan-bg); color: var(--badge-loan-fg); } +.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); } +.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); } +.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); } + +/* --- Sticky filter bar (keeps filters visible as the table scrolls) --- */ +.adjustments-filter-bar { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-card); + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-default); + border-radius: 0.5rem 0.5rem 0 0; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; +} + +/* --- Group header (collapsible section divider for group-by mode) --- */ +.adj-group-header { + cursor: pointer; + padding: 0.75rem 1rem; + background: var(--bg-inset); + border-top: 1px solid var(--border-default); + border-bottom: 1px solid var(--border-default); + display: flex; + align-items: center; + gap: 0.75rem; + user-select: none; + transition: background-color 120ms; +} +.adj-group-header:hover { background: var(--bg-card-hover); } +.adj-group-header .fa-chevron-down, +.adj-group-header .fa-chevron-right { opacity: 0.7; width: 0.8rem; } +.adj-group-header .adj-group-label { font-weight: 600; } +.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; } + +/* --- Floating bulk action bar (appears when >=1 row selected) --- */ +.adj-bulk-bar { + position: fixed; + left: 50%; + bottom: 1.5rem; + transform: translateX(-50%); + z-index: 1050; + background: var(--bg-card); + border: 2px solid var(--accent); + border-radius: 2rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + padding: 0.6rem 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + animation: adj-bulk-bar-in 180ms ease-out; +} +.adj-bulk-bar[hidden] { display: none; } +@keyframes adj-bulk-bar-in { + from { opacity: 0; transform: translate(-50%, 10px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +/* --- Empty state card --- */ +.adj-empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} +.adj-empty-state .adj-empty-icon { font-size: 2.5rem; opacity: 0.35; margin-bottom: 1rem; } + +/* --- Group-by toggle pill buttons (Flat / By Type / By Worker) --- */ +.adj-groupby-toggle .btn { font-size: 0.8rem; padding: 0.3rem 0.75rem; } + +/* --- Sort header arrows --- */ +th.sortable { cursor: pointer; user-select: none; } +th.sortable .sort-arrow { + opacity: 0.4; + margin-left: 0.25rem; + font-size: 0.7rem; + transition: opacity 120ms; +} +th.sortable:hover .sort-arrow, +th.sortable.sorted .sort-arrow { opacity: 1; } +``` + +**Step 3: Smoke-test in the browser** + +Start dev server (likely already running): + +```bash +run_dev.bat # or: set USE_SQLITE=true && python manage.py runserver 0.0.0.0:8000 +``` + +Temporarily edit `payroll_dashboard.html` to add a throwaway line right after the tab strip (we'll remove this before commit): + +```html +
    + Bonus + Overtime + Deduction + New Loan + Loan Repayment + Advance Payment + Advance Repayment +
    +``` + +Load `/payroll/` in the browser → all 7 badges should render with their colours. Toggle the theme (sun/moon topbar button) → badges should re-colour using light-theme tokens. + +Then **remove that throwaway div** before committing. + +**Step 4: Commit** + +```bash +git add static/css/custom.css +git commit -m "feat(adjustments): add semantic badge palette + sticky filter bar / group-header / bulk-bar styles" +``` + +--- + +## Task 3: Backend filter branch + stats (the view work) + +**Goal:** Add the new `elif status_filter == 'adjustments'` branch inside `payroll_dashboard`. Handles type / worker / team / status / date filters, sort, stats, pagination. **No group-by, no bulk-delete, no cross-filter in this task** — those are Tasks 5, 6, 7. + +**Files:** +- Modify: `core/views.py` — `payroll_dashboard` function (starts at line 2544). The new branch goes inside it, before the final `context.update({...})` call (around line 2880). +- Test: `core/tests.py` — new `AdjustmentsTabTests` class + +**Step 1: Write the failing tests (5 tests for this task)** + +Append to `core/tests.py`: + +```python +# ============================================================================= +# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) === +# Groups shared setUp by fixture factory so each test starts fresh. +# ============================================================================= + + +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 × 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 + pr = PayrollRecord.objects.create( + worker=self.w1, date=datetime.date(2026, 4, 15), + days_worked=20, total_amount=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–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')) +``` + +**Step 2: Run to verify failure** + +```bash +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2 +``` + +Expected: all 8 tests fail with various `KeyError` / `AttributeError` / 404 / 500 errors because the branch doesn't exist. + +**Step 3: Implement the view branch** + +Open `core/views.py`. Find `def payroll_dashboard(request):` at line 2544. Find the final `context.update({...})` call (around line ~2880). Inside `payroll_dashboard`, right AFTER `status_filter = request.GET.get('status', 'pending')` (line 2549) and BEFORE the existing `active_workers = ...` fetch at line 2553 — **gate the expensive Pending workflow** so the Adjustments tab doesn't pay the cost of the Pending tab's query: + +Actually — to keep this minimally invasive (and because the existing view isn't structured as an `if / elif / elif` tree but a "compute everything, then render" pattern), add the Adjustments branch as a standalone block right BEFORE the final `context.update({...})` call. The `active_tab` context key already picks up `status_filter` so the template can gate on it. + +Locate this area (line ~2880): +```python + context = { + # ... existing keys ... + 'active_tab': status_filter, + # ... more existing keys ... + } + return render(request, 'core/payroll_dashboard.html', context) +``` + +Right BEFORE `return render(...)` — after the existing context is populated — insert: + +```python + # ========================================================================= + # === ADJUSTMENTS TAB CONTEXT === + # Populated only when ?status=adjustments. Handles type/worker/team/status/ + # date filters, sort, stats, and pagination. Group-by rendering, bulk-select + # selection state, and Team→Workers cross-filter are layered in later tasks. + # ========================================================================= + if status_filter == 'adjustments': + from django.core.paginator import Paginator + from django.utils.dateparse import parse_date + + # --- Parse query params --- + 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 --- + adjustments = PayrollAdjustment.objects.select_related( + 'worker', 'project', 'payroll_record' + ).prefetch_related('worker__teams') + + # --- Apply filters --- + 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 ORM gotcha): keeps the outer + # queryset JOIN-free so aggregations don't inflate. + 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 --- + 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 for stable ordering + adjustments = adjustments.order_by(sort_field, '-id') + + # --- Stats (all scoped to filtered set, BEFORE pagination) --- + 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 --- + paginator = Paginator(adjustments, 50) + adj_page = paginator.get_page(request.GET.get('page', 1)) + + # --- Context for the template --- + 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, + }, + 'adjustment_types': 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'), + }) +``` + +**Step 4: Run the tests to verify pass** + +```bash +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2 +``` + +Expected: 8 tests pass. + +Full suite: + +```bash +USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1 +``` + +Expected: `Ran 58 tests` (50 from before + 8 new). + +**Step 5: Commit** + +```bash +git add core/views.py core/tests.py +git commit -m "feat(adjustments): backend filter branch for ?status=adjustments + +Type / worker / team / status / date filters, sort, stats, pagination. +Subquery pattern on the team filter avoids M2M JOIN inflation +(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter +come later (Tasks 5/6/7)." +``` + +--- + +## Task 4: Tab markup + filter bar + flat table + row actions + +**Goal:** Wire the view's context into the template — add the nav tab, the filter bar with Choices.js multi-selects, the flat table with badges and row actions, the stats header. This is the task that lets Konrad *see and use* the feature end-to-end. + +**Files:** +- Create: `core/templates/core/_adjustment_row.html` (shared row partial) +- Modify: `core/templates/core/payroll_dashboard.html` (add tab + content block + inline JS) + +**Step 1: Create the shared row partial** + +Create `core/templates/core/_adjustment_row.html` with this content: + +```django +{# === _adjustment_row.html === + Single used by BOTH the flat Adjustments view and the grouped view. + Context: `adj` (PayrollAdjustment), `show_checkbox` (bool, default true). + Row actions differ by paid status: + - Paid → [View Payslip] + - Unpaid → [Preview][Edit][×] (reuses existing modals on the dashboard) +#} +{% load format_tags %} + + + {# --- Bulk-select checkbox (disabled for paid rows; interactive for unpaid) --- #} + + {% if adj.payroll_record %} + + {% else %} + + {% endif %} + + + {# --- Date --- #} + {{ adj.date|date:"d M Y" }} + + {# --- Worker (link to worker profile) --- #} + + + {{ adj.worker.name }} + + + + {# --- Type badge --- #} + {{ adj.type }} + + {# --- Amount (sign reflects additive / deductive) --- #} + + {% if adj.type in additive_types %} + +R {{ adj.amount|money }} + {% else %} + −R {{ adj.amount|money }} + {% endif %} + + + {# --- Project --- #} + + {% if adj.project %} + + {{ adj.project.name }} + + {% else %}{% endif %} + + + {# --- Team (worker's first team; may be blank) --- #} + + {% with team=adj.worker.teams.first %} + {% if team %}{{ team.name }}{% else %}{% endif %} + {% endwith %} + + + {# --- Description (truncated with tooltip for full text) --- #} + + {% if adj.description %} + + {{ adj.description|truncatechars:40 }} + + {% else %}{% endif %} + + + {# --- Status: Paid #N / Unpaid --- #} + + {% if adj.payroll_record %} + + Paid #{{ adj.payroll_record.id }} + + {% else %} + Unpaid + {% endif %} + + + {# --- Row actions --- #} + + {% if adj.payroll_record %} + + + + {% else %} + + + + {% endif %} + + +``` + +**Step 2: Add the nav tab `
  • `** + +Edit `core/templates/core/payroll_dashboard.html`. Find the tab strip at line 252. Add a new `
  • ` inside `