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

1885 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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):
```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: `<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**
```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 `<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:
```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
<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**
```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 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**
```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 <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):
```html
<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:
```django
{# =============================================== #}
{# === 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:
```javascript
// === 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**
```bash
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**
```bash
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`:
```python
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**
```bash
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`):
```python
# =============================================================================
# === 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:
```python
# --- 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:
```django
{# --- 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`:
```python
# === 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`:
```django
{% 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`:
```python
# === 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**
```bash
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**
```bash
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)**
```python
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**
```bash
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`:
```python
# =============================================================================
# === 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`):
```python
# 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**
```bash
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:
```html
{# --- 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):
```javascript
// === 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**
```bash
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**
```python
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:
```python
# --- 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:
```django
{# --- Cross-filter data for the Workers ↔ Teams JS --- #}
{{ team_worker_pairs_json|json_script:"teamWorkerPairsAdj" }}
```
Inside the DOMContentLoaded script for adjustments, add:
```javascript
// === 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**
```bash
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:
```html
{# --- 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:
```javascript
// === 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**
```bash
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`:
```django
<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**
```javascript
// === 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**
```bash
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:
```html
<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**
```bash
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**
```bash
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`:
```markdown
---
## 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:
```markdown
| `/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**
```bash
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