38686-vm/docs/plans/2026-04-23-adjustments-tab-plan.md
Konrad du Plessis cf82215511 docs(adjustments): add task-by-task implementation plan
11 tasks + 1 hard-pause checkpoint after Task 4. Targets ~960 LOC
across core/views.py, payroll_dashboard.html, _adjustment_row.html
(new), format_tags.py, custom.css, and tests.py.

Derived from docs/plans/2026-04-23-adjustments-tab-design.md (commit
12edafa) — execution plan for subagent-driven development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:52:06 +02:00

76 KiB
Raw Permalink Blame History

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 <li> 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 113 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 <li> 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):

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

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:

# === 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: `<span class="badge-type-{{ adj.type|type_slug }}">`.
    Returns '' for None / empty — no class generated, no crash.
    """
    if not value:
        return ''
    return value.lower().replace(' ', '-')

Step 4: Run to verify pass

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

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 <span class="badge-type-bonus">Bonus</span> 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:

/* === 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:

/* === 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:

/* =============================================================================
 * 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):

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):

<div class="mb-2">
    <span class="badge-type-bonus">Bonus</span>
    <span class="badge-type-overtime">Overtime</span>
    <span class="badge-type-deduction">Deduction</span>
    <span class="badge-type-new-loan">New Loan</span>
    <span class="badge-type-loan-repayment">Loan Repayment</span>
    <span class="badge-type-advance-payment">Advance Payment</span>
    <span class="badge-type-advance-repayment">Advance Repayment</span>
</div>

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

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.pypayroll_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:

# =============================================================================
# === 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 1March 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

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):

    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:

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

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2

Expected: 8 tests pass.

Full suite:

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

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:

{# === _adjustment_row.html ===
   Single <tr> 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 %}

<tr data-adj-id="{{ adj.id }}" class="{% if adj.payroll_record %}adj-row-paid{% else %}adj-row-unpaid{% endif %}">
    {# --- Bulk-select checkbox (disabled for paid rows; interactive for unpaid) --- #}
    <td class="bulk-select-cell">
        {% if adj.payroll_record %}
            <input type="checkbox" class="form-check-input" disabled title="Paid rows cannot be bulk-deleted">
        {% else %}
            <input type="checkbox" class="form-check-input adj-bulk-checkbox" value="{{ adj.id }}">
        {% endif %}
    </td>

    {# --- Date --- #}
    <td>{{ adj.date|date:"d M Y" }}</td>

    {# --- Worker (link to worker profile) --- #}
    <td>
        <a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">
            {{ adj.worker.name }}
        </a>
    </td>

    {# --- Type badge --- #}
    <td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>

    {# --- Amount (sign reflects additive / deductive) --- #}
    <td class="text-end" style="font-variant-numeric: tabular-nums;">
        {% if adj.type in additive_types %}
            <span style="color: var(--text-primary);">+R {{ adj.amount|money }}</span>
        {% else %}
            <span style="color: var(--text-primary);">R {{ adj.amount|money }}</span>
        {% endif %}
    </td>

    {# --- Project --- #}
    <td>
        {% if adj.project %}
            <a href="{% url 'project_detail' adj.project.id %}" class="text-decoration-none">
                {{ adj.project.name }}
            </a>
        {% else %}<span class="text-muted">—</span>{% endif %}
    </td>

    {# --- Team (worker's first team; may be blank) --- #}
    <td>
        {% with team=adj.worker.teams.first %}
            {% if team %}{{ team.name }}{% else %}<span class="text-muted">—</span>{% endif %}
        {% endwith %}
    </td>

    {# --- Description (truncated with tooltip for full text) --- #}
    <td>
        {% if adj.description %}
            <span title="{{ adj.description }}" data-bs-toggle="tooltip">
                {{ adj.description|truncatechars:40 }}
            </span>
        {% else %}<span class="text-muted">—</span>{% endif %}
    </td>

    {# --- Status: Paid #N / Unpaid --- #}
    <td>
        {% if adj.payroll_record %}
            <a href="{% url 'payslip_detail' adj.payroll_record.id %}" class="badge bg-success text-decoration-none">
                Paid #{{ adj.payroll_record.id }}
            </a>
        {% else %}
            <span class="badge bg-warning">Unpaid</span>
        {% endif %}
    </td>

    {# --- Row actions --- #}
    <td class="text-end">
        {% if adj.payroll_record %}
            <a href="{% url 'payslip_detail' adj.payroll_record.id %}"
               class="btn btn-sm btn-outline-secondary" title="View payslip">
                <i class="fas fa-eye"></i>
            </a>
        {% else %}
            <button type="button"
                    class="btn btn-sm btn-outline-secondary preview-payslip-btn"
                    data-worker-id="{{ adj.worker.id }}"
                    title="Preview payslip">
                <i class="fas fa-eye"></i>
            </button>
            <button type="button"
                    class="btn btn-sm btn-outline-primary edit-adjustment-btn"
                    data-adj-id="{{ adj.id }}"
                    data-type="{{ adj.type }}"
                    data-amount="{{ adj.amount }}"
                    data-description="{{ adj.description|default:'' }}"
                    data-bs-toggle="modal"
                    data-bs-target="#editAdjustmentModal"
                    title="Edit">
                <i class="fas fa-pen"></i>
            </button>
            <button type="button"
                    class="btn btn-sm btn-outline-danger delete-adjustment-btn"
                    data-adj-id="{{ adj.id }}"
                    title="Delete">
                <i class="fas fa-times"></i>
            </button>
        {% endif %}
    </td>
</tr>

Step 2: Add the nav tab <li>

Edit core/templates/core/payroll_dashboard.html. Find the tab strip at line 252. Add a new <li> inside <ul class="nav nav-tabs">, RIGHT AFTER the "Loans & Advances" tab (current last child, around line 267):

        <li class="nav-item" role="presentation">
            <a class="nav-link {% if active_tab == 'adjustments' %}active{% endif %}" href="?status=adjustments">
                <i class="fas fa-sliders-h me-1"></i> Adjustments
            </a>
        </li>

Step 3: Add the new tab content block

Still in payroll_dashboard.html. After the existing Loans tab's closing {% endif %} (the block that renders {% if active_tab == 'loans' %}...{% endif %}), add:

{# =============================================== #}
{# === ADJUSTMENTS TAB === #}
{# =============================================== #}
{% elif active_tab == 'adjustments' %}

{# --- Sticky filter bar --- #}
<div class="adjustments-filter-bar" id="adjustmentsFilters">
    <form method="get" action="{% url 'payroll_dashboard' %}" class="d-flex flex-wrap gap-3 align-items-end w-100" id="adjFilterForm">
        <input type="hidden" name="status" value="adjustments">

        {# --- Type multi-select --- #}
        <div class="flex-grow-1" style="min-width: 180px;">
            <label class="form-label small mb-1">Type</label>
            <select name="type" class="form-select form-select-sm adj-multi" multiple data-placeholder="All types">
                {% for t in adjustment_types %}
                    <option value="{{ t }}" {% if t in adj_filter_values.type %}selected{% endif %}>{{ t }}</option>
                {% endfor %}
            </select>
        </div>

        {# --- Workers multi-select (cross-filtered by Teams in Task 7) --- #}
        <div class="flex-grow-1" style="min-width: 180px;">
            <label class="form-label small mb-1">Workers</label>
            <select name="worker" id="adjWorkerSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All workers">
                {% for w in all_workers_for_filter %}
                    <option value="{{ w.id }}" {% if w.id in adj_filter_values.worker %}selected{% endif %}>{{ w.name }}</option>
                {% endfor %}
            </select>
        </div>

        {# --- Teams multi-select --- #}
        <div class="flex-grow-1" style="min-width: 160px;">
            <label class="form-label small mb-1">Teams</label>
            <select name="team" id="adjTeamSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All teams">
                {% for t in all_teams_for_filter %}
                    <option value="{{ t.id }}" {% if t.id in adj_filter_values.team %}selected{% endif %}>{{ t.name }}</option>
                {% endfor %}
            </select>
        </div>

        {# --- Status single-select --- #}
        <div style="min-width: 120px;">
            <label class="form-label small mb-1">Status</label>
            <select name="adj_status" class="form-select form-select-sm">
                <option value="" {% if not adj_filter_values.adj_status %}selected{% endif %}>All</option>
                <option value="unpaid" {% if adj_filter_values.adj_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
                <option value="paid" {% if adj_filter_values.adj_status == 'paid' %}selected{% endif %}>Paid</option>
            </select>
        </div>

        {# --- Date range --- #}
        <div style="min-width: 130px;">
            <label class="form-label small mb-1">From</label>
            <input type="date" name="adj_date_from" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}">
        </div>
        <div style="min-width: 130px;">
            <label class="form-label small mb-1">To</label>
            <input type="date" name="adj_date_to" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_to }}">
        </div>

        {# --- Sort (hidden state — click on <th> sets these via JS in Task 9) --- #}
        <input type="hidden" name="sort" value="{{ adj_filter_values.sort }}">
        <input type="hidden" name="order" value="{{ adj_filter_values.order }}">

        {# --- Apply / Clear --- #}
        <div class="d-flex gap-2">
            <button type="submit" class="btn btn-sm btn-accent">
                <i class="fas fa-filter me-1"></i>Apply
            </button>
            <a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">Clear</a>
        </div>
    </form>
</div>

{# --- Stats row (scoped to filtered set) --- #}
<div class="d-flex flex-wrap gap-3 mb-3 px-2 small text-muted">
    <span><strong>{{ adj_total_count }}</strong> adjustment{{ adj_total_count|pluralize }}</span>
    <span>·</span>
    <span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
    <span>·</span>
    <span>+R {{ adj_additive_sum|money }} net additive</span>
    <span>·</span>
    <span>R {{ adj_deductive_sum|money }} net deductive</span>
</div>

{# --- Flat table --- #}
{% if adj_page.object_list %}
<div class="card">
    <div class="card-body p-0">
        <div class="table-responsive">
            <table class="table table-sm mb-0 align-middle">
                <thead>
                    <tr>
                        <th style="width: 40px;">
                            <input type="checkbox" class="form-check-input" id="adjSelectAll" title="Select all unpaid on this page">
                        </th>
                        <th class="sortable" data-sort="date">Date <i class="fas fa-sort sort-arrow"></i></th>
                        <th class="sortable" data-sort="worker">Worker <i class="fas fa-sort sort-arrow"></i></th>
                        <th>Type</th>
                        <th class="text-end sortable" data-sort="amount">Amount <i class="fas fa-sort sort-arrow"></i></th>
                        <th>Project</th>
                        <th>Team</th>
                        <th>Description</th>
                        <th class="sortable" data-sort="status">Status <i class="fas fa-sort sort-arrow"></i></th>
                        <th class="text-end">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    {% for adj in adj_page.object_list %}
                        {% include 'core/_adjustment_row.html' with adj=adj additive_types=adjustment_types %}
                    {% endfor %}
                </tbody>
            </table>
        </div>
    </div>
</div>

{# --- Pagination --- #}
{% if adj_page.has_other_pages %}
<nav class="mt-3 d-flex justify-content-center">
    <ul class="pagination pagination-sm">
        {% if adj_page.has_previous %}
            <li class="page-item">
                <a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.previous_page_number }}">Previous</a>
            </li>
        {% endif %}
        <li class="page-item active"><span class="page-link">{{ adj_page.number }} of {{ adj_page.paginator.num_pages }}</span></li>
        {% if adj_page.has_next %}
            <li class="page-item">
                <a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.next_page_number }}">Next</a>
            </li>
        {% endif %}
    </ul>
</nav>
{% endif %}

{% else %}
    {# Simple placeholder empty state — polish comes in Task 10 #}
    <div class="adj-empty-state">
        <div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
        <p>No adjustments match these filters.</p>
    </div>
{% endif %}

{# Need the additive-types list in the row partial for +/- sign rendering #}
{{ adjustment_types|json_script:"adjAdditiveTypes" }}

Note: the additive_types value passed into the row partial is really the list of ADDITIVE_TYPES names. Pass it explicitly so the row partial doesn't accidentally import the full adjustment_types list.

Refine: at the end of views.py's adjustments branch, add a dedicated 'additive_types': list(ADDITIVE_TYPES) context key and change the row include to {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}.

Step 4: Add the Choices.js init JS block

Still in payroll_dashboard.html. Near the bottom of the file — find the existing big <script> block that inits the payroll dashboard JS. Inside that block (so it runs on DOMContentLoaded with everything else), add:

// === ADJUSTMENTS TAB — Choices.js multi-selects ===
// Three multi-select filters (Type / Workers / Teams). Lazy-init only when
// the user is actually on the Adjustments tab (Choices.js is expensive).
if (document.getElementById('adjustmentsFilters')) {
    document.querySelectorAll('#adjFilterForm .adj-multi').forEach(function(sel) {
        new Choices(sel, {
            removeItemButton: true,
            shouldSort: false,
            placeholder: true,
            placeholderValue: sel.getAttribute('data-placeholder') || '',
        });
    });
}

Step 5: Verify in the browser

Start dev server (if not already). Navigate to /payroll/?status=adjustments. Expect:

  1. Nav tab "Adjustments" active
  2. Sticky filter bar across the top
  3. 3 stat numbers in the small-text row below
  4. Table with all existing PayrollAdjustment rows — each with a coloured badge per type
  5. Row actions on unpaid rows: Preview + Edit + ×; on paid rows: View Payslip only
  6. Clicking the Type/Workers/Teams filter + Apply submits a form GET and reloads with the filter active

Step 6: Run the suite

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1

Expected: 58 tests still pass (no new test here — Task 4 is UI-side, existing tests cover the backend).

Step 7: Commit

git add core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html core/views.py
git commit -m "feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions

Reuses existing modals (#editAdjustmentModal, #previewPayslipModal, delete
confirm flow) — zero new JS for row actions. Choices.js lazy-inits only
when the tab is active. Stats row scoped to filter set. Subquery pattern
on team filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come
in Tasks 5/6/7."

🚧 CHECKPOINT 1 — demo-able midpoint

Hard-pause here. Do NOT proceed to Task 5 until Konrad approves.

The browser-testable surface at this point:

  1. /payroll/?status=adjustments loads, tab is active
  2. All PayrollAdjustment rows show in the table with the right coloured badges
  3. Multi-select filters (Type / Workers / Teams) + Status + Date range work via Apply
  4. Stats row updates when filters are applied
  5. Row actions: Paid row → View Payslip link; Unpaid row → Preview / Edit / × buttons
  6. Pagination works past 50 rows
  7. Existing /payroll/, /payroll/?status=paid, /payroll/?status=loans tabs keep working

Ask Konrad:

  • Is the palette legible? (he specified these colours in brainstorming; worth a gut-check on real data)
  • Does clicking Edit/Preview/× on an unpaid row open the expected modal behaviour?
  • Are the filters covering his daily use case ("see unpaid bonuses this month", "all of Alice's overtime", etc.)?

Red light → fix before proceeding. Green light → Task 5.


Task 5: Group-by helper + toggle + template rendering

Goal: Add the _group_adjustments helper + the Flat / By Type / By Worker toggle UI + the grouped-table rendering path. Rows still use _adjustment_row.html (no markup duplication).

Files:

  • Modify: core/views.py — add helper, wire into adjustments branch
  • Modify: core/templates/core/payroll_dashboard.html — add toggle pills + grouped rendering
  • Test: core/tests.py — extend AdjustmentsTabTests

Step 1: Write the failing tests (2 tests)

Append to AdjustmentsTabTests:

    def test_group_by_type(self):
        self._login_admin()
        resp = self.client.get(self.url + '&group_by=type')
        groups = resp.context['adj_groups']
        self.assertIsNotNone(groups)
        labels = {g['label'] for g in groups}
        self.assertEqual(labels, {'Bonus', 'Deduction'})
        bonus_group = next(g for g in groups if g['label'] == 'Bonus')
        self.assertEqual(bonus_group['count'], 2)
        self.assertEqual(bonus_group['net_sum'], Decimal('800.00'))  # +R 500 + +R 300
        deduction_group = next(g for g in groups if g['label'] == 'Deduction')
        self.assertEqual(deduction_group['net_sum'], Decimal('-100.00'))

    def test_group_by_worker(self):
        self._login_admin()
        resp = self.client.get(self.url + '&group_by=worker')
        groups = resp.context['adj_groups']
        self.assertIsNotNone(groups)
        labels = {g['label'] for g in groups}
        self.assertEqual(labels, {'Alice', 'Bob'})
        alice = next(g for g in groups if g['label'] == 'Alice')
        # Alice: +R 500 bonus + (-R 100) deduction = +R 400 net
        self.assertEqual(alice['count'], 2)
        self.assertEqual(alice['net_sum'], Decimal('400.00'))

Step 2: Run to verify failure

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_group_by_type core.tests.AdjustmentsTabTests.test_group_by_worker -v 2

Expected: KeyError: 'adj_groups' in context.

Step 3: Implement the helper

Add _group_adjustments to core/views.py near other helpers (e.g. just before def payroll_dashboard):

# =============================================================================
# === ADJUSTMENT GROUPING HELPER ===
# Used by the Adjustments tab's By Type / By Worker render path.
# =============================================================================

def _group_adjustments(adjustments, group_by):
    """Regroup a flat list/queryset of PayrollAdjustment into buckets.

    `group_by` is 'type' or 'worker'. Returns a list of dicts:
        {'label', 'slug', 'rows', 'count', 'net_sum'}

    Ordered by descending magnitude of net_sum so the highest-impact
    bucket sits at the top of the view (big groups first).
    """
    from collections import defaultdict
    buckets = defaultdict(list)
    for adj in adjustments:
        key = adj.type if group_by == 'type' else adj.worker_id
        buckets[key].append(adj)

    groups = []
    for key, rows in buckets.items():
        if group_by == 'type':
            label = key
            slug = key.lower().replace(' ', '-')
        else:  # worker
            label = rows[0].worker.name
            slug = f'worker-{key}'
        net_sum = sum(
            (r.amount if r.type in ADDITIVE_TYPES else -r.amount)
            for r in rows
        )
        groups.append({
            'label': label,
            'slug': slug,
            'rows': rows,
            'count': len(rows),
            'net_sum': net_sum,
        })
    groups.sort(key=lambda g: -abs(g['net_sum']))
    return groups

Then inside the adjustments branch, right after adj_page = paginator.get_page(...), wire it up:

        # --- Group-by rendering (passed to template as adj_groups; None = flat view) ---
        group_by = request.GET.get('group_by', '').strip()
        adj_groups = None
        if group_by in ('type', 'worker'):
            adj_groups = _group_adjustments(adj_page.object_list, group_by)

And add 'adj_groups': adj_groups and 'group_by': group_by keys to the context.update({...}) at the end of the branch. Update adj_filter_values to include 'group_by': group_by.

Step 4: Add toggle + grouped render in template

Edit payroll_dashboard.html. Inside the Adjustments tab block (after the filter bar, before the stats row), add the toggle pills:

{# --- Group-by toggle --- #}
<div class="d-flex align-items-center gap-2 mb-3 adj-groupby-toggle">
    <span class="text-muted small">Show as:</span>
    <div class="btn-group" role="group">
        <a href="?{% url_replace request 'group_by' '' %}"
           class="btn {% if not adj_filter_values.group_by %}btn-accent{% else %}btn-outline-secondary{% endif %}">Flat</a>
        <a href="?{% url_replace request 'group_by' 'type' %}"
           class="btn {% if adj_filter_values.group_by == 'type' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Type</a>
        <a href="?{% url_replace request 'group_by' 'worker' %}"
           class="btn {% if adj_filter_values.group_by == 'worker' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Worker</a>
    </div>
</div>

The url_replace template tag doesn't exist yet — add it to format_tags.py:

# === url_replace tag ===
# Returns the current request's querystring with one key replaced.
# Used by filter-toggle links like "?status=adjustments&group_by=type".
@register.simple_tag
def url_replace(request, key, value):
    qd = request.GET.copy()
    if value == '' or value is None:
        qd.pop(key, None)
    else:
        qd[key] = value
    return qd.urlencode()

Then replace the <tbody> flat loop with a conditional on adj_groups:

{% if adj_groups %}
    {# --- Grouped view: one collapsible section per bucket --- #}
    {% for group in adj_groups %}
    <tr class="adj-group-header" data-bs-toggle="collapse" data-bs-target="#adj-group-{{ group.slug }}">
        <td colspan="10">
            <i class="fas fa-chevron-down"></i>
            <span class="adj-group-label">{{ group.label }}</span>
            <span class="adj-group-meta">
                {{ group.count }} row{{ group.count|pluralize }} ·
                {% if group.net_sum >= 0 %}+{% else %}{% endif %}R {{ group.net_sum|money_abs }} net
            </span>
        </td>
    </tr>
    <tbody id="adj-group-{{ group.slug }}" class="collapse show">
        {% for adj in group.rows %}
            {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
        {% endfor %}
    </tbody>
    {% endfor %}
{% else %}
    {# --- Flat view (Task 4) --- #}
    <tbody>
        {% for adj in adj_page.object_list %}
            {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
        {% endfor %}
    </tbody>
{% endif %}

Note |money_abs — a new filter needed because {{ -500|money }} would render R -500 but we want R 500.00. Add to format_tags.py:

# === money_abs filter ===
# Formats the absolute value of a Decimal in ZAR style (space-separated).
# Callers handle the sign explicitly. Pairs with the + /  prefix logic
# in the Adjustments group-header template.
@register.filter
def money_abs(value):
    if value is None:
        return ''
    return money(abs(value))

Step 5: Run the tests

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2

Expected: 10 tests pass.

Step 6: Browser smoke test

Navigate /payroll/?status=adjustments&group_by=type → headers "Bonus" / "Deduction" with row counts + net sums; rows under each. Click a header → section collapses. Click again → expands. Same for ?group_by=worker. Back to Flat via the toggle pill.

Step 7: Commit

git add core/views.py core/templates/core/payroll_dashboard.html core/templatetags/format_tags.py core/tests.py
git commit -m "feat(adjustments): group-by type / worker + collapsible headers

Adds _group_adjustments helper, |money_abs + url_replace template
helpers. Groups ordered by |net_sum| desc so biggest impact is at
the top. Shares the _adjustment_row.html partial — no row markup
duplication."

Task 6: Bulk-delete endpoint + floating action bar

Goal: Let admins select multiple unpaid rows and delete them in one shot via a new endpoint. Paid rows are silently skipped at the DB level (defensive).

Files:

  • Modify: core/views.py — new bulk_delete_adjustments view
  • Modify: core/urls.py — add URL pattern
  • Modify: core/templates/core/payroll_dashboard.html — add floating action bar + JS
  • Test: core/tests.py — extend AdjustmentsTabTests

Step 1: Write the failing tests (2 tests)

    def test_bulk_delete_only_affects_unpaid(self):
        """POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
        deletes ONLY unpaid rows. Paid rows are untouched (payroll history
        is immutable)."""
        self._login_admin()
        # Pay a1
        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()
        # Request delete on all three (a1 paid, a2 + a3 unpaid)
        resp = self.client.post(
            reverse('bulk_delete_adjustments'),
            {'adjustment_ids': [self.a1.id, self.a2.id, self.a3.id]},
        )
        self.assertEqual(resp.status_code, 200)
        body = resp.json()
        self.assertEqual(body['deleted'], 2)
        self.assertEqual(body['requested'], 3)
        # a1 survives (paid), a2 + a3 gone
        self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
        self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
        self.assertFalse(PayrollAdjustment.objects.filter(id=self.a3.id).exists())

    def test_bulk_delete_requires_admin(self):
        self.client.login(username='adj-sup', password='pass')
        resp = self.client.post(
            reverse('bulk_delete_adjustments'),
            {'adjustment_ids': [self.a1.id]},
        )
        self.assertEqual(resp.status_code, 403)
        # a1 still present
        self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())

Step 2: Run to verify failure

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_bulk_delete_only_affects_unpaid core.tests.AdjustmentsTabTests.test_bulk_delete_requires_admin -v 2

Expected: NoReverseMatch: 'bulk_delete_adjustments'.

Step 3: Implement the endpoint

Add to core/views.py, near delete_adjustment:

# =============================================================================
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
# Only unpaid adjustments are deleted; paid rows survive (payroll
# history is immutable and the existing edit_adjustment view also
# blocks edits on paid rows).
# =============================================================================

@login_required
@require_POST
def bulk_delete_adjustments(request):
    """Delete multiple unpaid PayrollAdjustment rows in one DB call.

    Body (form-encoded): `adjustment_ids` — one entry per ID.
    Returns JSON: `{"deleted": N, "requested": M}`.
    """
    if not is_admin(request.user):
        return JsonResponse({'error': 'Admin access required'}, status=403)

    ids = request.POST.getlist('adjustment_ids')
    # Keep only unpaid — paid rows are silently skipped.
    to_delete = PayrollAdjustment.objects.filter(
        id__in=ids,
        payroll_record__isnull=True,
    )
    deleted_count = to_delete.count()
    to_delete.delete()

    return JsonResponse({
        'deleted': deleted_count,
        'requested': len(ids),
    })

Make sure the imports at the top of views.py include from django.views.decorators.http import require_POST (or require_http_methods(['POST'])). Grep to check.

Step 4: Add URL

In core/urls.py, in the payroll section around line 56 (just after delete_adjustment):

    # Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
    path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),

Step 5: Run the tests

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2

Expected: 12 tests pass.

Step 6: Add floating action bar + selection JS

In payroll_dashboard.html, immediately BEFORE the closing </div> of the adjustments tab block (or anywhere inside {% elif active_tab == 'adjustments' %} after the table), add:

{# --- Floating bulk action bar (shown when >=1 unpaid row is selected) --- #}
<div class="adj-bulk-bar" id="adjBulkBar" hidden>
    <span><strong id="adjBulkCount">0</strong> selected</span>
    <button type="button" class="btn btn-sm btn-outline-danger" id="adjBulkDeleteBtn">
        <i class="fas fa-trash me-1"></i>Delete
    </button>
    <button type="button" class="btn btn-sm btn-outline-secondary" id="adjBulkClearBtn">Clear</button>
</div>

JS (inside the same <script> DOMContentLoaded block as the Choices.js init):

// === ADJUSTMENTS TAB — Bulk select + delete ===
if (document.getElementById('adjustmentsFilters')) {
    var bulkBar = document.getElementById('adjBulkBar');
    var bulkCount = document.getElementById('adjBulkCount');
    var bulkDeleteBtn = document.getElementById('adjBulkDeleteBtn');
    var bulkClearBtn = document.getElementById('adjBulkClearBtn');
    var selectAll = document.getElementById('adjSelectAll');

    function getCheckedIds() {
        return Array.from(document.querySelectorAll('.adj-bulk-checkbox:checked'))
            .map(function(cb) { return cb.value; });
    }
    function refreshBulkBar() {
        var ids = getCheckedIds();
        bulkCount.textContent = ids.length;
        bulkBar.hidden = ids.length === 0;
    }

    // Per-row checkbox change
    document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
        cb.addEventListener('change', refreshBulkBar);
    });

    // Select-all (only unpaid rows are interactive)
    if (selectAll) {
        selectAll.addEventListener('change', function() {
            document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
                cb.checked = selectAll.checked;
            });
            refreshBulkBar();
        });
    }

    // Clear selection
    bulkClearBtn.addEventListener('click', function() {
        document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
            cb.checked = false;
        });
        if (selectAll) selectAll.checked = false;
        refreshBulkBar();
    });

    // Delete selected — confirm + POST
    bulkDeleteBtn.addEventListener('click', function() {
        var ids = getCheckedIds();
        if (ids.length === 0) return;
        if (!confirm('Delete ' + ids.length + ' adjustment(s)? This cannot be undone.')) return;

        var form = new FormData();
        ids.forEach(function(id) { form.append('adjustment_ids', id); });
        form.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);

        fetch('{% url "bulk_delete_adjustments" %}', {
            method: 'POST', body: form, credentials: 'same-origin',
        })
        .then(function(r) { return r.json(); })
        .then(function(data) {
            // Simple refresh — server-rendered view reflects the deletion
            window.location.reload();
        })
        .catch(function(err) {
            alert('Bulk delete failed: ' + err);
        });
    });
}

Step 7: Commit

git add core/views.py core/urls.py core/templates/core/payroll_dashboard.html core/tests.py
git commit -m "feat(adjustments): bulk-delete unpaid rows + floating action bar

New POST endpoint silently skips paid rows at the DB level (defensive
against UI bugs). Floating bar slides up from the bottom when >=1 row
is selected — Clear / Delete / count. Confirm dialog before POST."

Task 7: Cross-filter Team → Workers + auto-removal toast

Goal: When Team(s) are selected in the filter bar, the Workers dropdown should only show workers from those teams. If a previously-selected worker is no longer in any selected team, auto-remove them and show a toast.

Exactly the pattern from Feature 1 (Inline Filters) — lift the JS approach.

Files:

  • Modify: core/views.py — add team_worker_pairs_json context key
  • Modify: core/templates/core/payroll_dashboard.html — add {% json_script %} + JS
  • Test: core/tests.py — 1 test that pairs are exposed

Step 1: Add the failing test

    def test_team_worker_pairs_json_context_key(self):
        """Cross-filter map is a raw Python list of {team_id, worker_id}
        dicts. Django's |json_script filter handles serialisation at
        template render time (no double-encoding — see 2026-04-23
        inline-filters regression test)."""
        self._login_admin()
        resp = self.client.get(self.url)
        pairs = resp.context['team_worker_pairs_json']
        self.assertIsInstance(pairs, list)
        for entry in pairs:
            self.assertIn('team_id', entry)
            self.assertIn('worker_id', entry)
        # Our fixture: team Alpha has w1 and w2
        pair_set = {(p['team_id'], p['worker_id']) for p in pairs}
        self.assertIn((self.team.id, self.w1.id), pair_set)
        self.assertIn((self.team.id, self.w2.id), pair_set)

Step 2: Run to verify failure

Expected: KeyError: 'team_worker_pairs_json'.

Step 3: Implement

In core/views.py adjustments branch, inside the context.update({...}) block, add:

        # --- Cross-filter source: (team_id, worker_id) pairs ---
        # Consumed by the frontend JS to filter the Workers dropdown when
        # Team(s) are picked. Raw list — |json_script in the template
        # handles safe serialisation (NOT json.dumps — see 2026-04-23
        # inline-filters regression).
        'team_worker_pairs_json': list(
            Team.workers.through.objects.values('team_id', 'worker_id').distinct()
        ),

Step 4: Template + JS

In payroll_dashboard.html, inside the adjustments tab block (near the bottom), add:

{# --- Cross-filter data for the Workers ↔ Teams JS --- #}
{{ team_worker_pairs_json|json_script:"teamWorkerPairsAdj" }}

Inside the DOMContentLoaded script for adjustments, add:

// === ADJUSTMENTS TAB — Team → Workers cross-filter ===
if (document.getElementById('teamWorkerPairsAdj')) {
    var pairsAdj = JSON.parse(document.getElementById('teamWorkerPairsAdj').textContent);
    var workerByTeamAdj = {};
    pairsAdj.forEach(function(p) {
        if (!workerByTeamAdj[p.team_id]) workerByTeamAdj[p.team_id] = new Set();
        workerByTeamAdj[p.team_id].add(p.worker_id);
    });

    var teamSel = document.getElementById('adjTeamSelect');
    var workerSel = document.getElementById('adjWorkerSelect');

    function selectedTeamIds() {
        return Array.from(teamSel.selectedOptions).map(function(o) {
            return parseInt(o.value, 10);
        });
    }
    function applyWorkerCrossFilter() {
        var teamIds = selectedTeamIds();
        if (teamIds.length === 0) {
            // No constraint — enable everything
            Array.from(workerSel.options).forEach(function(o) { o.disabled = false; });
            return;
        }
        var validIds = new Set();
        teamIds.forEach(function(tid) {
            if (workerByTeamAdj[tid]) {
                workerByTeamAdj[tid].forEach(function(wid) { validIds.add(wid); });
            }
        });
        Array.from(workerSel.options).forEach(function(o) {
            var wid = parseInt(o.value, 10);
            // Keep already-selected workers enabled (so user can see + deselect)
            o.disabled = !validIds.has(wid) && !o.selected;
        });
    }

    // Run once on load; re-run when Team selection changes
    applyWorkerCrossFilter();
    teamSel.addEventListener('change', applyWorkerCrossFilter);
}

Step 5: Commit

git add core/views.py core/templates/core/payroll_dashboard.html core/tests.py
git commit -m "feat(adjustments): Team → Workers cross-filter via JSON pair map

Mirrors Feature 1's inline-filters cross-filter pattern. Workers
dropdown options disable when Team(s) are selected and the worker
isn't in any of them. Already-selected workers stay enabled so
the user can still deselect them."

Task 8: Date picker single vs. range + preset buttons

Goal: The current filter bar has From / To date inputs (always both). Konrad wants a single date by default with a button that expands to range mode, plus preset quick-buttons (Today / This week / This month / Clear).

Files:

  • Modify: core/templates/core/payroll_dashboard.html — rework the date inputs

Step 1: Replace the two date inputs

In the filter bar, replace the two <div>s for "From" and "To" with:

{# --- Date picker (single or range) --- #}
<div id="adjDateWrap" style="min-width: 140px;">
    <label class="form-label small mb-1 d-flex align-items-center gap-2">
        <span id="adjDateLabel">Date</span>
        <button type="button" id="adjDateRangeToggle" class="btn btn-link btn-sm p-0 ms-auto"
                title="Toggle range mode">
            <i class="fas fa-ellipsis-h"></i>
        </button>
    </label>
    <input type="date" name="adj_date_from" id="adjDateFrom"
           class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}"
           placeholder="Date">
    <input type="date" name="adj_date_to" id="adjDateTo"
           class="form-control form-control-sm mt-1" value="{{ adj_filter_values.adj_date_to }}"
           hidden>
    <div class="d-flex gap-1 mt-1" id="adjDatePresets">
        <button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
        <button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">This week</button>
        <button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">This month</button>
        <button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
    </div>
</div>

Step 2: Add the JS — single vs. range toggle + preset handlers

Inside the same script block:

// === ADJUSTMENTS TAB — Date single/range toggle + presets ===
if (document.getElementById('adjDateWrap')) {
    var dateFrom = document.getElementById('adjDateFrom');
    var dateTo = document.getElementById('adjDateTo');
    var toggleBtn = document.getElementById('adjDateRangeToggle');
    var dateLabel = document.getElementById('adjDateLabel');

    // Single mode hides `to`; range mode shows it. Submit logic:
    // - single: from == to (backend treats as exact match)
    // - range : from <= date <= to
    function applyMode(rangeMode) {
        dateTo.hidden = !rangeMode;
        dateLabel.textContent = rangeMode ? 'Date range' : 'Date';
        // In single mode keep dateTo mirrored to dateFrom on form submit
        if (!rangeMode) dateTo.value = dateFrom.value;
    }
    // Start in range mode if both fields pre-populated AND differ
    var initialRange = dateFrom.value && dateTo.value && dateFrom.value !== dateTo.value;
    applyMode(initialRange);

    toggleBtn.addEventListener('click', function() {
        applyMode(dateTo.hidden);  // toggling hidden
    });
    dateFrom.addEventListener('change', function() {
        if (dateTo.hidden) dateTo.value = dateFrom.value;
    });

    // Preset quick-buttons
    document.querySelectorAll('#adjDatePresets [data-preset]').forEach(function(btn) {
        btn.addEventListener('click', function() {
            var preset = btn.getAttribute('data-preset');
            var today = new Date();
            var iso = function(d) { return d.toISOString().slice(0, 10); };
            if (preset === 'today') {
                dateFrom.value = iso(today);
                dateTo.value = iso(today);
                applyMode(false);
            } else if (preset === 'week') {
                var weekStart = new Date(today);
                weekStart.setDate(today.getDate() - ((today.getDay() + 6) % 7));  // Monday
                var weekEnd = new Date(weekStart);
                weekEnd.setDate(weekStart.getDate() + 6);
                dateFrom.value = iso(weekStart);
                dateTo.value = iso(weekEnd);
                applyMode(true);
            } else if (preset === 'month') {
                dateFrom.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
                dateTo.value = iso(new Date(today.getFullYear(), today.getMonth() + 1, 0));
                applyMode(true);
            } else if (preset === 'clear') {
                dateFrom.value = '';
                dateTo.value = '';
                applyMode(false);
            }
        });
    });
}

Step 3: Browser smoke test

Navigate the tab. Confirm:

  • Single mode shows just the first date input + button to toggle
  • Click → second date input appears; label changes to "Date range"
  • Presets work: Today sets both same; This week / This month sets a range
  • Submit (Apply) → URL includes adj_date_from and adj_date_to matching the inputs

Step 4: Commit

git add core/templates/core/payroll_dashboard.html
git commit -m "feat(adjustments): date picker single/range toggle + preset quick-buttons

Single by default (one input); `…` button expands to range. Today /
This week / This month / Clear presets. Submit logic keeps from==to
for exact-match days."

Task 9: Sort header clicks + URL state

Goal: Clicking a <th class="sortable"> toggles sort column and direction via URL. Sort arrows reflect current state.

Files:

  • Modify: core/templates/core/payroll_dashboard.html — JS + CSS class toggles

Step 1: Update sort arrows to reflect current state

In the <thead> block (Task 4 step 3), replace the <i class="fas fa-sort sort-arrow"></i> with conditional rendering that picks up adj_filter_values.sort and .order:

<th class="sortable{% if adj_filter_values.sort == 'date' %} sorted{% endif %}" data-sort="date">
    Date
    <i class="fas fa-sort{% if adj_filter_values.sort == 'date' %}-{{ adj_filter_values.order }}{% endif %} sort-arrow"></i>
</th>

(Font Awesome: fa-sort-asc / fa-sort-desc are the directional variants.)

Do the same for Worker, Amount, Status.

Step 2: Add the JS handler

// === ADJUSTMENTS TAB — Sort header clicks ===
if (document.getElementById('adjustmentsFilters')) {
    document.querySelectorAll('th.sortable').forEach(function(th) {
        th.addEventListener('click', function() {
            var col = th.getAttribute('data-sort');
            var currentSort = document.querySelector('input[name=sort]');
            var currentOrder = document.querySelector('input[name=order]');
            // Toggle direction if clicking the same column; else default desc
            if (currentSort.value === col) {
                currentOrder.value = currentOrder.value === 'asc' ? 'desc' : 'asc';
            } else {
                currentSort.value = col;
                currentOrder.value = 'desc';
            }
            // Submit the filter form so the sort persists in the URL
            document.getElementById('adjFilterForm').submit();
        });
    });
}

Step 3: Commit

git add core/templates/core/payroll_dashboard.html
git commit -m "feat(adjustments): sortable column headers — click to toggle direction

4 sortable columns (Date / Worker / Amount / Status). Click cycles
desc → asc → desc. URL state via hidden sort/order inputs so the
sort is bookmarkable and survives pagination."

Task 10: Empty state + sticky filter bar polish + small fit-and-finish

Goal: Make the "no rows" case genuinely useful; polish any visual leftovers from earlier tasks.

Files:

  • Modify: core/templates/core/payroll_dashboard.html — improve empty state

Step 1: Replace the placeholder empty state with the full version

Find the <div class="adj-empty-state"> (from Task 4 step 3) and replace with:

<div class="adj-empty-state card">
    <div class="card-body text-center py-5">
        <div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
        <h6 class="mb-2">No adjustments match these filters.</h6>
        <p class="text-muted small mb-3">Try clearing filters or adding a new adjustment.</p>
        <div class="d-flex gap-2 justify-content-center">
            <a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">
                <i class="fas fa-times me-1"></i>Clear filters
            </a>
            <button type="button" class="btn btn-sm btn-accent"
                    data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
                <i class="fas fa-plus me-1"></i>Add adjustment
            </button>
        </div>
    </div>
</div>

Step 2: Verify sticky behaviour

Scroll the table long enough that the stats row + filter bar would normally scroll out of view. The filter bar should stay pinned to the top of the viewport. If not, adjust .adjustments-filter-bar { position: sticky; top: 0; ... } in custom.css — the sticky context ancestor may need a position: relative and no overflow: hidden.

Step 3: Run the full suite

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1

Expected: 60 tests pass (50 pre-existing + 10 Adjustments).

Step 4: Commit

git add core/templates/core/payroll_dashboard.html static/css/custom.css
git commit -m "style(adjustments): empty-state card with recovery CTAs + sticky bar polish"

Task 11: Manual QA + "Shipped" block on design doc

Goal: Run the QA matrix, record deviations on the design doc, update CLAUDE.md with one line about the new tab's URL.

Files:

  • Modify: docs/plans/2026-04-23-adjustments-tab-design.md — append "Shipped" section
  • Modify: CLAUDE.md — one line in URL Routes table

Step 1: Manual QA matrix

Run each flow in the browser with real data and note any bugs:

  1. /payroll/?status=adjustments loads, "Adjustments" tab is active
  2. All existing PayrollAdjustment rows show with correct coloured badges
  3. Type filter: pick Bonus + Overtime → rows filter to union
  4. Worker filter: pick Alice → rows filter to Alice's only
  5. Team filter: pick a team → worker dropdown narrows to that team's workers (cross-filter)
  6. Status = Unpaid → only unpaid rows
  7. Date picker: single mode filters to exact day; toggles range; presets fill correctly
  8. Sort: click Date → desc by date; click again → asc; click Amount → sort by amount
  9. Group-by: By Type → collapsible headers with counts and net sums; By Worker → same
  10. Bulk select: tick 3 rows → floating bar shows "3 selected"; Delete → confirms → deletes → reloads; Clear unticks all
  11. Paid row → View Payslip button only; bulk-select disabled
  12. Unpaid row → Preview (opens #previewPayslipModal), Edit (opens #editAdjustmentModal pre-filled), × (opens existing delete confirm)
  13. Empty state card with recovery CTAs when filters return 0 rows
  14. Pagination: if 50+ rows, page 2 link works and keeps all filters in URL
  15. Non-admin supervisor → 302/403 at /payroll/?status=adjustments

Step 2: Append the Shipped block to the design doc

At the bottom of docs/plans/2026-04-23-adjustments-tab-design.md:

---

## 19. Shipped — <DATE-YOU-COMPLETE>

Implementation complete; all 11 QA flows pass. Commits on `ai-dev`:

| # | Commit | Scope |
|---|--------|-------|
| 1 | `<sha>` | `type_slug` filter + tests |
| 2 | `<sha>` | CSS badge palette + foundational styles |
| 3 | `<sha>` | Backend filter branch + stats |
| 4 | `<sha>` | Tab + filter bar + flat table + row actions |
| 5 | `<sha>` | Group-by helper + toggle |
| 6 | `<sha>` | Bulk-delete endpoint + floating bar |
| 7 | `<sha>` | Team → Workers cross-filter |
| 8 | `<sha>` | Date single/range + presets |
| 9 | `<sha>` | Sort header clicks + URL state |
| 10 | `<sha>` | Empty state + polish |
| 11 | `<sha>` | Docs + QA |

Deviations from the original design: <list any, or "none">.

Tests: 50 → <N> passing (+<delta> new).

Step 3: Add one line to CLAUDE.md's URL Routes table

Find the URL Routes table in CLAUDE.md. Add:

| `/payroll/?status=adjustments` | `payroll_dashboard` | Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by; bulk-delete unpaid) |
| `/payroll/adjustments/bulk-delete/` | `bulk_delete_adjustments` | Admin: POST-only; delete multiple unpaid adjustments in one DB call |

Step 4: Commit

git add docs/plans/2026-04-23-adjustments-tab-design.md CLAUDE.md
git commit -m "docs(adjustments): Shipped block on design doc + CLAUDE.md URL route

Captures the 11-task implementation, deviations, and test delta.
CLAUDE.md URL Routes table updated so future sessions surface the
new ?status=adjustments branch + bulk-delete endpoint."

Final checklist before push

After Task 11:

  • python manage.py test core.tests → all pass (expect ~60+)
  • python manage.py check → clean (pre-existing staticfiles.W004 OK)
  • grep -rn "TODO\|FIXME" core/views.py core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html static/css/custom.css → zero new markers
  • Browser sweep (QA matrix above)
  • Light-theme toggle check: filter bar, badges, group headers, floating bar all readable
  • Cross-browser spot check: Chrome + at least one Firefox pane

Then dispatch superpowers:requesting-code-review for a full-feature review subagent pass. Address any issues. Then single batched push of all commits (17 inline-filters + 11 adjustments + 3 pre-existing design docs/plan + any fix-up commits) to origin/ai-dev.

Out of scope reminders (per design §16)

  • Bulk Mark Paid → use existing per-worker Pay Now flow via Preview modal
  • CSV export → not in original ask; easy to add later
  • Keyboard shortcuts beyond Esc → native Tab works
  • Inline editing of amount/description → Edit modal already handles it
  • Adjustment audit log → would require a new model
  • Group by Project → Type and Worker cover the two most useful axes