# 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 `