38686-vm/docs/plans/2026-04-23-adjustments-tab-design.md
Konrad du Plessis 269d86259a docs(adjustments): Shipped block on design doc + CLAUDE.md URL routes
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.
2026-04-23 19:26:46 +02:00

32 KiB
Raw Permalink Blame History

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:

/* 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 from Team.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 #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:

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

@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=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.pytype_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.


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

  1. 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-popover CSS vocabulary. Implemented in 4f15e4b.

  2. 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 (e088192 fixed it) and then AGAIN in the Fix-A worker cell (4c1cdb6 fixed 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.

  3. Bulk-delete cascade gap. The original Task 6 spec's reference implementation (PayrollAdjustment.objects.filter(...).delete()) silently orphaned linked Loan rows and priced_workers M2M entries when bulk-deleting adjustments of type "New Loan", "Advance Payment", or "Overtime". The single-row delete_adjustment view 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 in 4c3e90f.

  4. 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 in b59eb31; tiny tab-activation helper in projects/detail.html picks up the URL hash.

  5. 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.getElementById returns only the first match, so the modal's handler silently bound to the table checkbox. Renamed the table's to #adjTableSelectAll in 5f2e6d8.

Tests

Added 14 tests in AdjustmentsTabTests:

  • test_admin_sees_adjustments_tab — 200 + active_tab set
  • test_supervisor_forbidden — non-admin redirected
  • test_type_multi_filter — union on multi-value param (uses adj_total_count)
  • test_worker_multi_filter — worker filter
  • test_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 filter
  • test_date_range_filter — date__gte/lte
  • test_stats_scoped_to_filtered_set — counts + sums respect filter
  • test_group_by_type — buckets + net_sum + descending-magnitude ordering
  • test_group_by_worker — buckets by worker_id
  • test_bulk_delete_only_affects_unpaid — paid row survives
  • test_bulk_delete_requires_admin — 403 for supervisors
  • test_bulk_delete_cascades_new_loan — Loan + unpaid repayments gone too
  • test_bulk_delete_skips_loan_with_paid_repayments — refuses, reports reason
  • test_team_worker_pairs_json_context_key — raw Python list shape (not double-encoded)

Also extended existing tests:

  • test_group_by_type gained a descending-magnitude ordering assertion
  • TypeSlugFilterTests has 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 lines
  • core/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.)