38686-vm/docs/plans/2026-04-23-adjustments-tab-design.md
Konrad du Plessis 12edafa441 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>
2026-04-23 09:26:01 +02:00

558 lines
26 KiB
Markdown
Raw 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 — 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.