Design: Payroll Adjustments Tab on the payroll dashboard

Second brainstorm output of the day. New tab alongside Pending /
History / Loans & Advances (the URL pattern already established at
?status=pending|paid|loans — this slots in at ?status=adjustments).

Key decisions:
- Semantic badge palette: 5 colour categories mapped across 7 types.
  Loan/advance repayments get +15% saturation — same family, hotter
  signal for "money coming back" vs "money going out".
- Three multi-select filters (Type, Workers, Teams) via Choices.js.
  Teams cross-filter Workers using JSON pair map (mirrors Feature 1's
  project<->team pattern). Auto-remove invalid selections with toast.
- Single-date default with optional range toggle; presets for
  Today / This week / This month.
- Sticky filter bar; sortable columns (Date / Worker / Amount / Status).
- Group-by toggle: Flat / By Type / By Worker. Collapsible group
  headers show count + net sum per group (+R additive / -R deductive).
- Bulk action bar (floating) for multi-row delete on unpaid rows only.
  New endpoint POST /payroll/adjustments/bulk-delete/ filters
  payroll_record__isnull=True for safety.
- Inline row actions reuse existing modals: Preview (unpaid) /
  View Payslip (paid) / Edit + Delete (unpaid). Zero new modal code.
- Empty state, keyboard Esc, URL state for everything (bookmark-safe).

Scope: ~960 lines, ~12 tasks, 2 checkpoints. Uses existing
#addAdjustmentModal / #editAdjustmentModal / #payslipPreviewModal
already on payroll_dashboard.html — zero duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 09:26:01 +02:00
parent 30d0991956
commit 12edafa441

View File

@ -0,0 +1,557 @@
# Payroll Adjustments Tab — Design (23 Apr 2026)
## Goal
Add a new **Adjustments** tab to the payroll dashboard (alongside Pending / History / Loans & Advances) that lets admins browse ALL payroll adjustments across all workers — filter by type, worker, team, paid/unpaid status, and date — with semantic colour-coded badges, bulk delete, group-by-type / group-by-worker render, and inline actions matching the rest of the dashboard.
Today the only place to see a list of every adjustment is `/admin/core/payrolladjustment/` (Django admin). Adjustments surface per-worker on the Pending tab, per-worker in the Worker Payment Hub modal, and per-worker on the payslip detail page — but nowhere as a unified, filterable list.
## Origin
Konrad's request from a brainstorming session on 23 Apr 2026:
> _"At the moment I can only lookup loans and advances easily but I would like to be able to see all payroll adjustments and filter between type (keep loans and advances separate as they are both loans and record keep is very important)."_
Followed by a UX refinement:
> _"Can we have the Adjustments tab next to Loans and advances on the payroll dashboard?"_
And a premium-UX request:
> _"So in Adjustments we need filters for type of adjustment (multiselect), Teams, Name (Multiselect), paid/unpaid, Date of Adjustment... let us keep loans dirty yellow, adjustments dark pastel blue, deductions deep purple, bonuses dirty pastel green, overtime dirty pink."_
## Who it's for
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no payroll-dashboard access.
## Architecture at a glance
- **URL**: existing `/payroll/` view with new `?status=adjustments` query param (mirrors the pending/paid/loans pattern already in `payroll_dashboard_view`)
- **Template**: extend `core/templates/core/payroll_dashboard.html` — add tab in the nav-tabs strip + new content block gated on `active_tab == 'adjustments'`
- **Modal reuse**: the existing `#addAdjustmentModal`, `#editAdjustmentModal`, `#payslipPreviewModal` all live on the payroll dashboard already. The Adjustments tab's row actions just trigger them — zero duplication.
- **New**: CSS semantic badge palette (7 types × dark/light themes = 14 colour tokens + 7 badge classes), one bulk-delete endpoint, and a group-by rendering layer
- **No model changes. No migrations.**
## 1. Colour palette — semantic type badges
### Mapping: 7 adjustment types → 5 colour categories
Loan/Advance **repayment** sub-types use the same category colour as their outgoing counterparts but with ~15% more saturation — "same family, hotter signal" so eyes instantly catch "this one is money coming back, not going out".
| Type | Category | Saturation |
|---|---|---|
| Bonus | Bonus | base |
| Overtime | Overtime | base |
| Deduction | Deduction | base |
| New Loan | Loans | base |
| Loan Repayment | Loans | +15% |
| Advance Payment | Advances | base |
| Advance Repayment | Advances | +15% |
### CSS tokens
Added to `static/css/custom.css` `:root` (dark) and `:root.light` blocks:
```css
/* Dark theme — badge palette */
--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; /* +15% saturation */
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2; /* +15% saturation */
/* Light theme — overrides in :root.light */
--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;
```
Each badge class maps type name → token:
```css
.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); }
```
Template slug helper: a small template filter `|type_slug` that turns `"Advance Payment"``"advance-payment"` for class naming.
**All badges use Inter, 11pt, weight 500, `padding: 0.3rem 0.7rem`, `border-radius: 999px`.** Amount is shown NOT on the badge (badge shows type only); amount is a separate cell with `+` / `` prefix.
## 2. Tab markup (adds one `<li>` to existing nav-tabs)
In `payroll_dashboard.html` at around line 252:
```django
<ul class="nav nav-tabs mb-3" role="tablist">
<!-- existing: Pending, History, Loans & Advances -->
<li class="nav-item">
<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>
</ul>
```
## 3. Filter bar — sticky, multi-select, with cross-filter
Five filters in a single row under the tab:
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Type ▾ 2] [Workers ▾ 3] [Teams ▾ 1] [Status ▾] [Date 📅] [Apply] │
└──────────────────────────────────────────────────────────────────────────────┘
```
| Filter | UI | Behaviour |
|---|---|---|
| **Type** | Choices.js multi-select, 7 options (the 7 adjustment types). Selected chips show the badge colour of that type. | empty = all types |
| **Workers** | Choices.js multi-select, searchable. Cross-filtered by selected Teams (see below). | empty = all workers |
| **Teams** | Choices.js multi-select, all active teams | empty = all teams |
| **Status** | Native single-select: All / Unpaid / Paid | default All |
| **Date** | Single date picker. A `…` button toggles range mode (From / To). | Single date = exact; range = inclusive bounds |
| **Apply** | Button visible only when filters dirty | Submits via `?status=adjustments&type=Bonus&type=Overtime&worker=1&worker=2&team=3&adj_status=unpaid&adj_date_from=...&adj_date_to=...&group_by=type` |
**Sticky:** the filter bar stays at the top as the table scrolls below it. `position: sticky; top: 0; z-index: 10; background: var(--bg-card);`.
### Cross-filter — Team → Workers
When Team(s) are selected, the Workers dropdown shows only workers in those teams. Implementation mirrors Feature 1's project↔team cross-filter:
- Backend: compute a JSON map of `(team_id, worker_id)` pairs from `Team.workers.through`:
```python
team_worker_pairs = list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
)
context['team_worker_pairs_json'] = json.dumps(team_worker_pairs)
```
- Frontend: when the Workers popover opens, filter visible options based on current team selection. Auto-remove now-invalid worker selections with a toast ("Alice removed — not in selected teams").
- Scope: entire active roster (not filtered by date range) — cross-filter is about data possibility, not data in this period.
One-way: Teams filter Workers. (Worker→Team filter is less useful — you usually pick teams first, then drill down to specific workers within them.)
## 4. Columns + row actions
### Flat view (no grouping)
| # | Column | Sort | Notes |
|---|---|---|---|
| 1 | `☐` (bulk checkbox) | No | Only on unpaid rows (paid rows show disabled checkbox) |
| 2 | Date | Yes ▲▼ | `d M Y` format |
| 3 | Worker | Yes | Link to `/workers/<id>/` |
| 4 | Type | No (filter instead) | Badge with `.badge-type-<slug>` |
| 5 | Amount | Yes | Right-aligned, tabular-nums. Sign: `+` for additive, `` for deductive. Plain text colour (`--text-primary`) |
| 6 | Project | No | Link to `/projects/<id>/` or `—` |
| 7 | Team | No | `worker.teams.first().name` or `—` |
| 8 | Description | No | Truncate 40 chars + `title` attr tooltip for full text |
| 9 | Status | Yes | Badge: `Paid #123` (success, links `/payroll/payslip/<pk>/`) or `Unpaid` (warning) |
| 10 | Actions | No | See below |
**Sortable:** 4 columns (Date, Worker, Amount, Status) with click-to-toggle. Bootstrap sort arrows in the headers. URL state: `?sort=<col>&order=<asc|desc>`.
### Row actions
Inline buttons matching the rest of the dashboard — NO expandable rows:
- **Unpaid row:**
- `[Preview]` — opens existing `#payslipPreviewModal` for that worker (same as Pending Payments tab's Preview)
- `[Edit]` — opens existing `#editAdjustmentModal` pre-filled (same as Pending tab's Edit)
- `[×]` (delete) — opens existing delete confirm flow
- **Paid row:**
- `[View Payslip]` — links to `/payroll/payslip/<pk>/` (same as History tab's View)
Zero new modals. Zero new JS — reuse the handlers already on the payroll dashboard.
## 5. Group-by toggle
Three radio pills above the table (next to the filter bar):
```
Show as: [ Flat ● ] [ By Type ○ ] [ By Worker ○ ]
```
### When grouped:
```
▾ BONUS · 3 rows · +R 1 500 ───────────────────────────────────
22 Apr 2026 Alice M. Bonus +R 500 Wilkot ... Unpaid [Preview][Edit][×]
18 Apr 2026 Bob N. Bonus +R 500 Alpha ... Paid #8 [View]
...
▾ OVERTIME · 5 rows · +R 750 ──────────────────────────────────
...
▸ DEDUCTION · 2 rows · R 400 ───────────────────────────────── (collapsed)
...
```
- Click group header → collapse/expand (Bootstrap Collapse, smooth animation)
- Group header shows: `<category>` · row count · net sum (`+R` for net-additive groups, `R` for net-deductive)
- Default: all groups expanded
- URL state: `?group_by=type` or `?group_by=worker`
### By-worker grouping
Same shape but grouped by `worker.id`:
```
▾ Alice Mokoena · 4 rows · +R 1 200 ─────────────────
...
▾ Bob Ndlovu · 2 rows · +R 300 ──────────────────────
...
```
### Implementation
Backend:
```python
if group_by == 'type':
groups = defaultdict(list)
for adj in paginated_adjustments:
groups[adj.type].append(adj)
rendered_groups = [
{'label': t, 'slug': slug(t), 'rows': rows,
'count': len(rows), 'net_sum': sum_with_sign(rows)}
for t, rows in groups.items()
]
elif group_by == 'worker':
# same shape, groups keyed by worker_id; label = worker.name
```
Template iterates over `rendered_groups` if grouping is active, else over the flat page. Both paths render the same row-level HTML (shared partial `_adjustment_row.html`) so there's no markup duplication.
## 6. Bulk actions
### Checkbox column
Leftmost column with `<input type="checkbox" name="bulk_select">` on each unpaid row. Paid rows show a disabled greyed-out checkbox (visually consistent, not interactive). "Select all" checkbox in the thead toggles all visible unpaid rows on the current page.
### Floating action bar
When ≥1 row is selected, a small bar slides up from the bottom of the viewport (not the page — fixed position):
```
┌─────────────────────────────────────────────────────────┐
│ 3 selected · [🗑 Delete] [Clear selection] │
└─────────────────────────────────────────────────────────┘
```
- `Delete` → confirms via native `confirm()` → POSTs to new endpoint `/payroll/adjustments/bulk-delete/`
- `Clear selection` → unticks all, bar slides away
### Bulk-delete endpoint
```python
@login_required
def bulk_delete_adjustments(request):
"""Delete multiple unpaid adjustments at once.
Only unpaid adjustments can be deleted (paid ones are locked into payroll
history). POST body: list of adjustment IDs. Returns JSON with success
count + error count. Admin-only.
"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
ids = request.POST.getlist('adjustment_ids')
# Only unpaid adjustments; silently skip any paid ones (defensive against UI bugs)
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)})
```
URL: `path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments')`.
## 7. Header stats
Directly under the filter bar, above the table:
```
247 adjustments · 38 unpaid (R 126 500) · +R 45 000 net additive · R 2 100 net deductive
```
All four numbers scoped to the current filter set (not the whole DB). Updates on Apply.
## 8. Date filter — single vs range
The Date field is a single date picker by default. A small `…` (ellipsis) button next to it toggles to range mode:
- **Single mode** (default): pick ONE date. Filter: `date = selected_date`.
- **Range mode**: two date pickers (From / To). Filter: `from ≤ date ≤ to`.
Single mode covers Konrad's "see adjustments for a specific date" use case. Range mode handles "this week" / "this month" audit queries.
Preset quick-buttons above the picker: `[Today] [This week] [This month] [Clear]`. Clicking a preset fills the range and stays in range mode.
## 9. URL state & bookmarkability
Every filter + sort + group-by choice lives in the querystring. Shareable/bookmarkable:
```
/payroll/?status=adjustments
&type=Bonus&type=Overtime ← multi-value (same key repeated)
&worker=1&worker=3
&team=2
&adj_status=unpaid
&adj_date_from=2026-03-01
&adj_date_to=2026-04-30
&group_by=type
&sort=amount&order=desc
&page=2
```
## 10. Empty state
When filters return 0 rows:
```
┌──────────────────────────────────────────┐
│ 📭 │
│ No adjustments match these filters. │
│ │
│ [Clear filters] [Add new adjustment] │
└──────────────────────────────────────────┘
```
Action buttons offer immediate recovery paths.
## 11. Backend changes
### New branch in `payroll_dashboard` view
Around line 2496 where `status_filter` is parsed:
```python
elif status_filter == 'adjustments':
active_tab = 'adjustments'
# Parse filters
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()
group_by = request.GET.get('group_by', '').strip()
sort_col = request.GET.get('sort', 'date').strip()
sort_order = request.GET.get('order', 'desc').strip()
adjustments = PayrollAdjustment.objects.select_related(
'worker', 'project', 'payroll_record'
).prefetch_related('worker__teams')
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 per CLAUDE.md ORM gotcha — avoids M2M JOIN inflation
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:
adjustments = adjustments.filter(date__gte=parse_date(adj_date_from))
if adj_date_to:
adjustments = adjustments.filter(date__lte=parse_date(adj_date_to))
# Sorting
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
adjustments = adjustments.order_by(sort_field, '-id')
# Stats (scoped to filtered set)
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')
additive_sum = adjustments.filter(type__in=ADDITIVE_TYPES).aggregate(
total=Sum('amount'))['total'] or Decimal('0.00')
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))
# Group-by rendering
adj_groups = None
if group_by in ('type', 'worker'):
adj_groups = _group_adjustments(adj_page.object_list, group_by)
# Cross-filter data for JS
team_worker_pairs_json = json.dumps(list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
))
context.update({
'adj_page': adj_page,
'adj_groups': adj_groups,
'adj_total_count': adj_total_count,
'adj_unpaid_count': adj_unpaid_count,
'adj_unpaid_sum': adj_unpaid_sum,
'adj_additive_sum': additive_sum,
'adj_deductive_sum': 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, 'group_by': group_by,
'sort': sort_col, 'order': sort_order,
},
'adjustment_types': ADDITIVE_TYPES + 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'),
'team_worker_pairs_json': team_worker_pairs_json,
})
```
### New helper `_group_adjustments`
```python
def _group_adjustments(adjustments, group_by):
"""Regroup a flat list of adjustments by type or by worker.
Returns list of dicts: [{'label', 'slug', 'rows', 'count', 'net_sum'}, ...]
Groups ordered by descending net_sum magnitude (biggest impact 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
```
### New endpoint `bulk_delete_adjustments`
(Shown in section 6.)
### New template filter `|type_slug`
In `core/templatetags/format_tags.py`:
```python
@register.filter
def type_slug(value):
"""Convert 'Advance Payment' -> 'advance-payment' for CSS class naming."""
if not value:
return ''
return value.lower().replace(' ', '-')
```
## 12. Testing
New `AdjustmentsTabTests` class in `core/tests.py` (~8 tests):
- `test_admin_sees_adjustments_tab` — 200 for admin at `?status=adjustments`
- `test_supervisor_forbidden` — 403 for supervisor at `/payroll/?status=adjustments`
- `test_type_multi_filter``?type=Bonus&type=Overtime` returns union (not intersection)
- `test_worker_multi_filter``?worker=1&worker=2` returns union
- `test_team_cross_filter` — team filter uses subquery pattern (no inflation on linked counts)
- `test_status_filter_unpaid` — only unpaid rows returned
- `test_date_filter_single_vs_range` — single date = exact match; range = inclusive
- `test_group_by_type` — response context has `adj_groups` with correct keys
- `test_group_by_worker` — same with worker grouping
- `test_bulk_delete_only_affects_unpaid` — POST with mixed paid+unpaid IDs deletes only unpaid
- `test_bulk_delete_requires_admin` — 403 for supervisor
(~11 tests; aim for ~130 lines of test code.)
## 13. Scope estimate
| Change | Lines |
|---|---|
| `core/views.py` — new `payroll_dashboard` branch + `_group_adjustments` helper + `bulk_delete_adjustments` endpoint | ~180 |
| `core/templates/core/payroll_dashboard.html` — tab `<li>` + filter bar + sticky stats row + group-by toggle + table with grouping + row actions + bulk action bar | ~260 |
| `core/templates/core/_adjustment_row.html` — shared row partial (used by both flat + grouped views) | ~40 |
| `core/templatetags/format_tags.py``type_slug` filter | ~10 |
| `static/css/custom.css` — 14 badge colour tokens × 2 themes + 7 badge classes + sticky filter bar + group header + bulk action bar | ~170 |
| JS (inline in template) — Choices.js init × 3 multi-selects + Team→Worker cross-filter + bulk checkbox logic + group-header collapse + sort header clicks + date single/range toggle | ~170 |
| `core/urls.py` — 1 new path (bulk-delete) | ~3 |
| Tests in `core/tests.py` | ~130 |
| **Total** | **~960 lines** |
~12 tasks, 2 checkpoints.
**Suggested checkpoints:**
1. After core filter logic + Choices.js multi-selects + stats + sort + flat table render
2. After group-by + bulk delete + date single/range + cross-filter + row actions + full QA
## 14. Edge cases
| Case | Behaviour |
|---|---|
| Filter returns 0 rows | Empty-state card with "Clear filters" + "Add new adjustment" CTAs |
| Paid row selected via bulk checkbox (shouldn't happen via UI; defensive) | Backend silently skips paid rows in bulk-delete |
| Worker with no teams (orphan) | Hidden from Workers dropdown when any Team filter is active |
| Sort by column with missing values (e.g. sorting by worker name when worker deleted) | NULLs sort last; fallback to date-desc within same key |
| Group-by on an empty filter result | Empty state message (no group headers shown) |
| 500+ adjustments in DB | Pagination handles it — 50/page, URL `?page=2` |
| User clicks Edit on a paid row (shouldn't have button; defensive) | Edit modal wouldn't allow edit; existing `edit_adjustment` view blocks paid |
| Bulk-delete hits a race condition (another admin pays one of the selected) | Endpoint filters `payroll_record__isnull=True` at DELETE time; stale ones silently excluded |
## 15. Dependencies on Feature 1 (inline filters)
Feature 1's Choices.js infrastructure (CDN loading, SRI hashes, dark/light theme overrides) is already shipped. This feature reuses it directly — no duplicate CDN loads.
Feature 1 retires the report-config modal; this feature is on a different page and doesn't interact with that change.
**Order of implementation: either can ship first.** Feature 1 is smaller (~5-6 tasks) and may inform JS patterns we lift into Feature 2. Feature 2 is larger but self-contained. Suggested order: Feature 1 first, then Feature 2, separate plans.
## 16. Out of scope (YAGNI)
- **Bulk Mark Paid** — entangled with Pay Now flow; use existing per-worker `process_payment` via Preview modal
- **CSV export** — easy to add later if requested; not in original ask
- **Keyboard shortcuts beyond Esc** — browser Tab is fine
- **Persistent sort / filter session state** — URL bookmark covers it
- **Inline editing of description or amount** — Edit modal is sufficient
- **Adjustment history / audit log** — would require a new model; not asked for
- **Group by Project** — Type and Worker cover the two most useful axes
## 17. Rollback
Template + view additions only; one new endpoint; no schema changes. Rollback = revert the commits (or disable the tab via template edit if a panic fix is needed).
## 18. Next step
Hand off to `superpowers:writing-plans`. Two design docs exist today:
- `docs/plans/2026-04-23-inline-filters-design.md` (Feature 1 — report page pills)
- `docs/plans/2026-04-23-adjustments-tab-design.md` (this doc — Feature 2)
Recommended sequence: **Feature 1 first** (smaller, ~5-6 tasks; Choices.js patterns learned here can lift into Feature 2). Ship Feature 1, validate on production, then Feature 2's plan + implementation. Both design docs stay local until their respective implementations ship; then push everything together.