Captures the 11-task implementation, 5 deviations (biggest: the CP1 pivot from Choices.js chip-multiselect to popover-checkbox filter UX after Konrad flagged the chip pattern as intrusive), 14 new adjustments-tab tests, and total code churn (~+1400 lines). CLAUDE.md URL Routes table gains two rows so future sessions surface /payroll/?status=adjustments and the bulk-delete endpoint. Feature ready for final whole-feature code review + batched push.
32 KiB
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=adjustmentsquery param (mirrors the pending/paid/loans pattern already inpayroll_dashboard_view) - Template: extend
core/templates/core/payroll_dashboard.html— add tab in the nav-tabs strip + new content block gated onactive_tab == 'adjustments' - Modal reuse: the existing
#addAdjustmentModal,#editAdjustmentModal,#payslipPreviewModalall 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:
/* 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:
.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:
<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 fromTeam.workers.through: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#payslipPreviewModalfor that worker (same as Pending Payments tab's Preview)[Edit]— opens existing#editAdjustmentModalpre-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 (+Rfor net-additive groups,−Rfor net-deductive) - Default: all groups expanded
- URL state:
?group_by=typeor?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:
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 nativeconfirm()→ POSTs to new endpoint/payroll/adjustments/bulk-delete/Clear selection→ unticks all, bar slides away
Bulk-delete endpoint
@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:
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
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:
@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=adjustmentstest_supervisor_forbidden— 403 for supervisor at/payroll/?status=adjustmentstest_type_multi_filter—?type=Bonus&type=Overtimereturns union (not intersection)test_worker_multi_filter—?worker=1&worker=2returns uniontest_team_cross_filter— team filter uses subquery pattern (no inflation on linked counts)test_status_filter_unpaid— only unpaid rows returnedtest_date_filter_single_vs_range— single date = exact match; range = inclusivetest_group_by_type— response context hasadj_groupswith correct keystest_group_by_worker— same with worker groupingtest_bulk_delete_only_affects_unpaid— POST with mixed paid+unpaid IDs deletes only unpaidtest_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:
- After core filter logic + Choices.js multi-selects + stats + sort + flat table render
- 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_paymentvia 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.
19. Shipped — 2026-04-23
Implementation complete. 11 tasks + 1 hard-pause checkpoint + 1 round of Konrad feedback fixes. 65 tests passing (up from 47 pre-feature).
Commit map
| Task | Commits | Scope |
|---|---|---|
| 1 | 97d8a69 |
type_slug template filter (+ tests) |
| 2 | a20a025 |
CSS badge palette + foundational styles |
| 3 | 10d381e, 89f109a |
Backend filter branch + stats; strengthened subquery test |
| 4 | b450bd3, 06b3315 |
Tab markup + filter bar + flat table; pagination / a11y / N+1 fixes |
| 4* | e088192, 4c1cdb6 |
Two multi-line {# #} comment hotfixes — see Deviations #2 |
| CP1 A | b59eb31 |
Row actions → modals + project link → History tab |
| CP1 B | 4f15e4b |
Replaced Choices.js chip-multiselect with popover-checkbox filter UX — see Deviations #1 |
| 5 | 0862805, e5d06f9 |
Group-by type/worker + toggle + colour-accented headers; chevron + ordering polish |
| 6 | 03f177e, 5f2e6d8, 4c3e90f |
Bulk-delete endpoint; id-collision fix; cascade logic fix — see Deviations #3 |
| 7 | 6905703 |
Team → Workers cross-filter |
| 8 | c851b49 |
Date picker single/range toggle + preset buttons |
| 9 | 7b71048 |
Sortable column headers with URL state |
| 10 | 9bb9ede |
Empty-state card with recovery CTAs |
Deviations from the original design
-
Choices.js chip-multiselect → popover-checkbox filters. The original design (§3) specified Choices.js for Type/Workers/Teams multi-selects — the same pattern used in the report page's retired modal. At Checkpoint 1 Konrad flagged that the chip-style rendering was intrusive once multiple options were selected, dominating the filter bar. We replaced the Choices.js widgets with pill-buttons that open popovers containing a scrollable checkbox list + search + Select All / Invert / Clear. Reuses Feature 1's
.filter-pill/.filter-popoverCSS vocabulary. Implemented in4f15e4b. -
Multi-line
{# ... #}comment bug, twice. Django's{# #}comment syntax is single-line only — multi-line blocks need{% comment %}...{% endcomment %}. We shipped the bug in the Task 4 row partial (e088192fixed it) and then AGAIN in the Fix-A worker cell (4c1cdb6fixed it). Both shipped into production-looking renders, not caught by automated tests. Lesson: add a repo-wide grep guard or a Django linter for this class of template bug. -
Bulk-delete cascade gap. The original Task 6 spec's reference implementation (
PayrollAdjustment.objects.filter(...).delete()) silently orphaned linkedLoanrows andpriced_workersM2M entries when bulk-deleting adjustments of type "New Loan", "Advance Payment", or "Overtime". The single-rowdelete_adjustmentview had 30+ lines of cascade logic the bulk view didn't use. Code review caught it. Fix: extracted_delete_adjustment_with_cascade(adj)helper and delegated both views to it — ensuring bulk and single-row have identical semantics. Also added a 'has_paid_repayments' skip reason in the JSON response so the UI can indicate why some rows were kept. Implemented in4c3e90f. -
Row actions → modals (CP1 Fix A). The original design §4 said row actions "match the rest of the dashboard — NO expandable rows". We interpreted this as table-to-page navigation (Worker name →
/workers/<id>/, View Payslip →/payroll/payslip/<pk>/). At CP1 Konrad clarified he wanted in-place MODALS matching the Pending tab: worker name opens#workerLookupModal, paid-row eye icon opens#previewPayslipModal, project name goes to/projects/<id>/#history(History tab active). Implemented inb59eb31; tiny tab-activation helper inprojects/detail.htmlpicks up the URL hash. -
id collision. Task 4 added
id="adjSelectAll"to the table header checkbox, but the Add Adjustment modal already used that id for its Select-All anchor.document.getElementByIdreturns only the first match, so the modal's handler silently bound to the table checkbox. Renamed the table's to#adjTableSelectAllin5f2e6d8.
Tests
Added 14 tests in AdjustmentsTabTests:
test_admin_sees_adjustments_tab— 200 + active_tab settest_supervisor_forbidden— non-admin redirectedtest_type_multi_filter— union on multi-value param (uses adj_total_count)test_worker_multi_filter— worker filtertest_team_filter_uses_subquery_no_inflation— proves the subquery pattern with 2 teams × 2 workers × 3 adjustments (naive would return 6)test_status_filter_unpaid— payroll_record__isnull filtertest_date_range_filter— date__gte/ltetest_stats_scoped_to_filtered_set— counts + sums respect filtertest_group_by_type— buckets + net_sum + descending-magnitude orderingtest_group_by_worker— buckets by worker_idtest_bulk_delete_only_affects_unpaid— paid row survivestest_bulk_delete_requires_admin— 403 for supervisorstest_bulk_delete_cascades_new_loan— Loan + unpaid repayments gone tootest_bulk_delete_skips_loan_with_paid_repayments— refuses, reports reasontest_team_worker_pairs_json_context_key— raw Python list shape (not double-encoded)
Also extended existing tests:
test_group_by_typegained a descending-magnitude ordering assertionTypeSlugFilterTestshas 3 tests for the new template filter
Net code churn
core/views.py: ~+200 lines (filter branch + 2 helpers + bulk-delete view)core/templates/core/payroll_dashboard.html: ~+450 lines (tab + filter bar + popover markup + table + JS modules)core/templates/core/_adjustment_row.html: new file, ~120 linescore/templatetags/format_tags.py: ~+35 lines (type_slug,money_abs,url_replace)static/css/custom.css: ~+220 lines (badge palette + layout skeleton + popover extensions + colour-accented group headers + chevron rotation)core/tests.py: ~+380 lines (14 new adjustments tests + 3 type_slug tests)core/urls.py: +1 route- Total: ~+1,400 lines added, ~-100 replaced/removed.
(Original estimate: ~960 lines. Actual: +44% — mostly from the popover- checkbox filter rewrite, the bulk-delete cascade, and the cross-filter JS.)