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