diff --git a/docs/plans/2026-04-23-adjustments-tab-design.md b/docs/plans/2026-04-23-adjustments-tab-design.md new file mode 100644 index 0000000..d78e597 --- /dev/null +++ b/docs/plans/2026-04-23-adjustments-tab-design.md @@ -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 `
  • ` to existing nav-tabs) + +In `payroll_dashboard.html` at around line 252: + +```django + +``` + +## 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//` | +| 4 | Type | No (filter instead) | Badge with `.badge-type-` | +| 5 | Amount | Yes | Right-aligned, tabular-nums. Sign: `+` for additive, `−` for deductive. Plain text colour (`--text-primary`) | +| 6 | Project | No | Link to `/projects//` 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//`) 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=&order=`. + +### 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//` (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: `` · 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 `` 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 `
  • ` + 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.