11 tasks + 1 hard-pause checkpoint after Task 4. Targets ~960 LOC across core/views.py, payroll_dashboard.html, _adjustment_row.html (new), format_tags.py, custom.css, and tests.py. Derived from docs/plans/2026-04-23-adjustments-tab-design.md (commit 12edafa) — execution plan for subagent-driven development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
76 KiB
Payroll Adjustments Tab — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans (or superpowers:subagent-driven-development) to implement this plan task-by-task.
Goal: Add a new Adjustments tab to the payroll dashboard so admins can browse every PayrollAdjustment across all workers with multi-select filters, semantic badges, group-by, bulk-delete, and inline actions that reuse the existing modal infrastructure.
Architecture: Extend the existing payroll_dashboard view with a new ?status=adjustments branch (mirrors the pattern already used for pending / paid / loans). Template gets a new <li> in the nav-tabs strip plus a gated content block. Modals and JS handlers already on the payroll dashboard (#addAdjustmentModal, #editAdjustmentModal, #previewPayslipModal) are reused as-is. No new models, no migrations. One new endpoint (bulk_delete_adjustments), one new template filter (type_slug), one new shared partial (_adjustment_row.html).
Tech Stack: Django 5.2.7, Python 3.13, Bootstrap 5.3, Choices.js 10.2.0 (already on the payroll dashboard), Font Awesome 6, WeasyPrint (not touched). Local dev = SQLite + run_dev.bat; production = MySQL on Flatlogic.
Design doc: docs/plans/2026-04-23-adjustments-tab-design.md (commit 12edafa, local-only). Read sections 1–13 for full rationale; this plan executes on that doc.
CLAUDE.md — mandatory read-before-touch:
- "Coding Style" section — section-header comment convention + plain-English comments for the non-programmer owner
- "PayrollAdjustment Type Handling" — ADDITIVE_TYPES / DEDUCTIVE_TYPES constants
- "Schema name-drifts to remember" —
PayrollAdjustment.description(NOTreason);log.adjustments_by_work_log(NOTpayrolladjustment_set) - "Django ORM gotcha — M2M filter + aggregate inflation" — the subquery pattern
id__in=Model.objects.filter(m2m__field=X).values('id')is mandatory when you filter on an M2M relation AND aggregate
Who it's for
Admins only (is_admin(user) = is_staff OR is_superuser). Supervisors get 403.
Files touched
| File | What |
|---|---|
core/views.py |
New elif status_filter == 'adjustments' branch in payroll_dashboard; new _group_adjustments helper; new bulk_delete_adjustments view |
core/urls.py |
One new path for bulk-delete |
core/templatetags/format_tags.py |
New type_slug filter |
core/templates/core/payroll_dashboard.html |
New <li> in tab strip; new {% elif active_tab == 'adjustments' %} block; reuse existing modals |
core/templates/core/_adjustment_row.html |
New shared partial used by flat AND grouped views |
static/css/custom.css |
Badge palette tokens (dark + light themes) + 7 badge classes + sticky filter bar + group header + bulk action bar styles |
core/tests.py |
New AdjustmentsTabTests class (~11 tests) |
Task sequence + checkpoint
| # | Task | Type | Checkpoint gate? |
|---|---|---|---|
| 1 | type_slug template filter |
TDD | |
| 2 | CSS badge palette + foundational styles | UI | |
| 3 | Backend filter branch + stats (no group/bulk/cross-filter yet) | TDD | |
| 4 | Tab markup + filter bar + flat table + row actions | UI + smoke test | |
| — | CHECKPOINT 1 — Konrad demos the core experience | ✅ hard-pause | |
| 5 | Group-by helper + toggle + rendering | TDD | |
| 6 | Bulk-delete endpoint + floating action bar | TDD | |
| 7 | Cross-filter Team → Workers + auto-removal toast | Integration | |
| 8 | Date picker single vs. range + preset buttons | UI | |
| 9 | Sort header clicks + URL state | UI | |
| 10 | Empty state + sticky filter bar polish | UI | |
| 11 | Manual QA sweep + design-doc "Shipped" block | Docs |
Target: ~960 LOC across all files, per design §13.
Task 1: type_slug template filter
Goal: a tiny helper that turns "Advance Payment" → "advance-payment" so templates can build class names like badge-type-advance-payment.
Files:
- Modify:
core/templatetags/format_tags.py(existing file —moneyfilter lives here; add alongside) - Test:
core/tests.py(newTypeSlugFilterTestsclass)
Step 1: Write the failing test
Append to core/tests.py (near the bottom, after the last Tests class):
# =============================================================================
# === TESTS FOR |type_slug FILTER ===
# Used by Adjustments tab to build CSS class names from type labels.
# =============================================================================
class TypeSlugFilterTests(TestCase):
"""format_tags.type_slug converts adjustment-type labels to slugs."""
def test_spaces_become_hyphens_and_lowercased(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug('Advance Payment'), 'advance-payment')
self.assertEqual(type_slug('New Loan'), 'new-loan')
self.assertEqual(type_slug('Bonus'), 'bonus')
def test_empty_or_none_returns_empty_string(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug(''), '')
self.assertEqual(type_slug(None), '')
def test_idempotent_on_already_slugged(self):
from core.templatetags.format_tags import type_slug
self.assertEqual(type_slug('bonus'), 'bonus')
Step 2: Run to verify failure
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2
Expected: ImportError: cannot import name 'type_slug' from 'core.templatetags.format_tags'.
Step 3: Implement
Edit core/templatetags/format_tags.py — append:
# === type_slug filter ===
# Converts adjustment-type labels like "Advance Payment" to
# CSS-class-friendly slugs like "advance-payment". Used by the Adjustments
# tab to pick the right colour badge class per row.
@register.filter
def type_slug(value):
"""Return a hyphen-separated lowercase version of `value`.
Used in templates: `<span class="badge-type-{{ adj.type|type_slug }}">`.
Returns '' for None / empty — no class generated, no crash.
"""
if not value:
return ''
return value.lower().replace(' ', '-')
Step 4: Run to verify pass
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2
Expected: 3 tests pass. Full suite: python manage.py test core.tests → 47 + 3 = 50 tests pass.
Step 5: Commit
git add core/templatetags/format_tags.py core/tests.py
git commit -m "feat(adjustments): add |type_slug template filter for badge class naming"
Task 2: CSS badge palette + foundational styles
Goal: Add the 14 badge colour tokens (7 types × 2 themes) plus 7 badge classes plus the sticky filter bar / group header / bulk-action-bar skeleton styles. No functional wiring yet — just the visual vocabulary.
No TDD here (CSS-only). Smoke test: render a throwaway <span class="badge-type-bonus">Bonus</span> in any template and eyeball.
Files:
- Modify:
static/css/custom.css(append near end, after the inline-filters block — line ~1855+)
Step 1: Add badge palette tokens to both themes
Find the :root { block near the top of custom.css (the CSS-variables root). Append the 7 dark-theme tokens INSIDE the existing :root block — keep them near other colour tokens:
/* === ADJUSTMENTS TAB — badge palette (dark theme) === */
/* Each adjustment type has its own colour family. Loan-Repayment and
Advance-Repayment are +15% saturation siblings of their parent colour
so "money coming back" reads as a hotter signal than "money going out". */
--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;
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2;
Then find the :root.light { block (light-theme overrides). Append the light-theme pairs INSIDE it:
/* === ADJUSTMENTS TAB — badge palette (light theme) === */
--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;
Step 2: Add badge classes + layout skeleton
Append at the END of custom.css:
/* =============================================================================
* ADJUSTMENTS TAB
* Visual vocabulary for the Payroll → Adjustments tab.
* - 7 badge classes, one per adjustment type
* - Sticky filter bar that stays visible as the table scrolls
* - Group-by header style (collapsible section divider)
* - Floating bulk-action bar at the bottom of the viewport
* ============================================================================= */
/* --- Type badges (one class per PayrollAdjustment type) --- */
.badge-type-bonus,
.badge-type-overtime,
.badge-type-deduction,
.badge-type-new-loan,
.badge-type-loan-repayment,
.badge-type-advance-payment,
.badge-type-advance-repayment {
display: inline-block;
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.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); }
/* --- Sticky filter bar (keeps filters visible as the table scrolls) --- */
.adjustments-filter-bar {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-card);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-default);
border-radius: 0.5rem 0.5rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
/* --- Group header (collapsible section divider for group-by mode) --- */
.adj-group-header {
cursor: pointer;
padding: 0.75rem 1rem;
background: var(--bg-inset);
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
gap: 0.75rem;
user-select: none;
transition: background-color 120ms;
}
.adj-group-header:hover { background: var(--bg-card-hover); }
.adj-group-header .fa-chevron-down,
.adj-group-header .fa-chevron-right { opacity: 0.7; width: 0.8rem; }
.adj-group-header .adj-group-label { font-weight: 600; }
.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; }
/* --- Floating bulk action bar (appears when >=1 row selected) --- */
.adj-bulk-bar {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
z-index: 1050;
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: 2rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 0.6rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
animation: adj-bulk-bar-in 180ms ease-out;
}
.adj-bulk-bar[hidden] { display: none; }
@keyframes adj-bulk-bar-in {
from { opacity: 0; transform: translate(-50%, 10px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* --- Empty state card --- */
.adj-empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.adj-empty-state .adj-empty-icon { font-size: 2.5rem; opacity: 0.35; margin-bottom: 1rem; }
/* --- Group-by toggle pill buttons (Flat / By Type / By Worker) --- */
.adj-groupby-toggle .btn { font-size: 0.8rem; padding: 0.3rem 0.75rem; }
/* --- Sort header arrows --- */
th.sortable { cursor: pointer; user-select: none; }
th.sortable .sort-arrow {
opacity: 0.4;
margin-left: 0.25rem;
font-size: 0.7rem;
transition: opacity 120ms;
}
th.sortable:hover .sort-arrow,
th.sortable.sorted .sort-arrow { opacity: 1; }
Step 3: Smoke-test in the browser
Start dev server (likely already running):
run_dev.bat # or: set USE_SQLITE=true && python manage.py runserver 0.0.0.0:8000
Temporarily edit payroll_dashboard.html to add a throwaway line right after the tab strip (we'll remove this before commit):
<div class="mb-2">
<span class="badge-type-bonus">Bonus</span>
<span class="badge-type-overtime">Overtime</span>
<span class="badge-type-deduction">Deduction</span>
<span class="badge-type-new-loan">New Loan</span>
<span class="badge-type-loan-repayment">Loan Repayment</span>
<span class="badge-type-advance-payment">Advance Payment</span>
<span class="badge-type-advance-repayment">Advance Repayment</span>
</div>
Load /payroll/ in the browser → all 7 badges should render with their colours. Toggle the theme (sun/moon topbar button) → badges should re-colour using light-theme tokens.
Then remove that throwaway div before committing.
Step 4: Commit
git add static/css/custom.css
git commit -m "feat(adjustments): add semantic badge palette + sticky filter bar / group-header / bulk-bar styles"
Task 3: Backend filter branch + stats (the view work)
Goal: Add the new elif status_filter == 'adjustments' branch inside payroll_dashboard. Handles type / worker / team / status / date filters, sort, stats, pagination. No group-by, no bulk-delete, no cross-filter in this task — those are Tasks 5, 6, 7.
Files:
- Modify:
core/views.py—payroll_dashboardfunction (starts at line 2544). The new branch goes inside it, before the finalcontext.update({...})call (around line 2880). - Test:
core/tests.py— newAdjustmentsTabTestsclass
Step 1: Write the failing tests (5 tests for this task)
Append to core/tests.py:
# =============================================================================
# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) ===
# Groups shared setUp by fixture factory so each test starts fresh.
# =============================================================================
class AdjustmentsTabTests(TestCase):
"""New Adjustments tab on /payroll/?status=adjustments."""
def setUp(self):
self.admin = User.objects.create_user(
username='adj-admin', password='pass', is_staff=True, is_superuser=True
)
self.sup = User.objects.create_user(
username='adj-sup', password='pass'
)
self.w1 = Worker.objects.create(
name='Alice', id_number='A1', monthly_salary=Decimal('4000')
)
self.w2 = Worker.objects.create(
name='Bob', id_number='B1', monthly_salary=Decimal('4000')
)
self.team = Team.objects.create(name='Alpha', supervisor=self.admin)
self.team.workers.add(self.w1, self.w2)
self.proj = Project.objects.create(name='Site X')
# 3 unpaid adjustments — 1 bonus Alice, 1 bonus Bob, 1 deduction Alice
self.a1 = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Bonus',
amount=Decimal('500'), date=datetime.date(2026, 4, 10),
description='April bonus',
)
self.a2 = PayrollAdjustment.objects.create(
worker=self.w2, project=self.proj, type='Bonus',
amount=Decimal('300'), date=datetime.date(2026, 4, 11),
description='Project milestone',
)
self.a3 = PayrollAdjustment.objects.create(
worker=self.w1, project=self.proj, type='Deduction',
amount=Decimal('100'), date=datetime.date(2026, 3, 28),
description='Missing tool',
)
self.url = reverse('payroll_dashboard') + '?status=adjustments'
def _login_admin(self):
self.client.login(username='adj-admin', password='pass')
def test_admin_sees_adjustments_tab(self):
self._login_admin()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.context['active_tab'], 'adjustments')
# All 3 fixture adjustments should be in the listing
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
def test_supervisor_forbidden(self):
self.client.login(username='adj-sup', password='pass')
resp = self.client.get(self.url)
# Existing payroll_dashboard pattern: non-admin is redirected home
self.assertEqual(resp.status_code, 302)
def test_type_multi_filter(self):
self._login_admin()
resp = self.client.get(self.url + '&type=Bonus')
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertIn(self.a1.id, ids)
self.assertIn(self.a2.id, ids)
self.assertNotIn(self.a3.id, ids)
def test_worker_multi_filter(self):
self._login_admin()
resp = self.client.get(self.url + f'&worker={self.w1.id}')
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertIn(self.a1.id, ids)
self.assertNotIn(self.a2.id, ids)
self.assertIn(self.a3.id, ids)
def test_team_filter_uses_subquery_no_inflation(self):
"""Filtering by team must NOT multiply rows (M2M JOIN inflation
would give 6 rows for 3 adjustments × 2 workers on team Alpha)."""
self._login_admin()
resp = self.client.get(self.url + f'&team={self.team.id}')
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
def test_status_filter_unpaid(self):
self._login_admin()
# Mark a1 as paid
pr = PayrollRecord.objects.create(
worker=self.w1, date=datetime.date(2026, 4, 15),
days_worked=20, total_amount=Decimal('4000'),
)
self.a1.payroll_record = pr
self.a1.save()
resp = self.client.get(self.url + '&adj_status=unpaid')
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertNotIn(self.a1.id, ids)
self.assertIn(self.a2.id, ids)
self.assertIn(self.a3.id, ids)
def test_date_range_filter(self):
self._login_admin()
# March 1–March 31 → only a3 (dated 28 Mar)
resp = self.client.get(
self.url + '&adj_date_from=2026-03-01&adj_date_to=2026-03-31'
)
ids = {a.id for a in resp.context['adj_page'].object_list}
self.assertEqual(ids, {self.a3.id})
def test_stats_scoped_to_filtered_set(self):
self._login_admin()
resp = self.client.get(self.url + '&type=Bonus')
# 2 bonuses, 0 paid, total R 800 additive, 0 deductive
self.assertEqual(resp.context['adj_total_count'], 2)
self.assertEqual(resp.context['adj_unpaid_count'], 2)
self.assertEqual(resp.context['adj_additive_sum'], Decimal('800.00'))
self.assertEqual(resp.context['adj_deductive_sum'], Decimal('0.00'))
Step 2: Run to verify failure
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
Expected: all 8 tests fail with various KeyError / AttributeError / 404 / 500 errors because the branch doesn't exist.
Step 3: Implement the view branch
Open core/views.py. Find def payroll_dashboard(request): at line 2544. Find the final context.update({...}) call (around line ~2880). Inside payroll_dashboard, right AFTER status_filter = request.GET.get('status', 'pending') (line 2549) and BEFORE the existing active_workers = ... fetch at line 2553 — gate the expensive Pending workflow so the Adjustments tab doesn't pay the cost of the Pending tab's query:
Actually — to keep this minimally invasive (and because the existing view isn't structured as an if / elif / elif tree but a "compute everything, then render" pattern), add the Adjustments branch as a standalone block right BEFORE the final context.update({...}) call. The active_tab context key already picks up status_filter so the template can gate on it.
Locate this area (line ~2880):
context = {
# ... existing keys ...
'active_tab': status_filter,
# ... more existing keys ...
}
return render(request, 'core/payroll_dashboard.html', context)
Right BEFORE return render(...) — after the existing context is populated — insert:
# =========================================================================
# === ADJUSTMENTS TAB CONTEXT ===
# Populated only when ?status=adjustments. Handles type/worker/team/status/
# date filters, sort, stats, and pagination. Group-by rendering, bulk-select
# selection state, and Team→Workers cross-filter are layered in later tasks.
# =========================================================================
if status_filter == 'adjustments':
from django.core.paginator import Paginator
from django.utils.dateparse import parse_date
# --- Parse query params ---
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()
sort_col = request.GET.get('sort', 'date').strip()
sort_order = request.GET.get('order', 'desc').strip()
# --- Base queryset with eager-loading ---
adjustments = PayrollAdjustment.objects.select_related(
'worker', 'project', 'payroll_record'
).prefetch_related('worker__teams')
# --- Apply filters ---
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 (CLAUDE.md ORM gotcha): keeps the outer
# queryset JOIN-free so aggregations don't inflate.
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:
parsed = parse_date(adj_date_from)
if parsed:
adjustments = adjustments.filter(date__gte=parsed)
if adj_date_to:
parsed = parse_date(adj_date_to)
if parsed:
adjustments = adjustments.filter(date__lte=parsed)
# --- Sort ---
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
# Secondary key -id for stable ordering
adjustments = adjustments.order_by(sort_field, '-id')
# --- Stats (all scoped to filtered set, BEFORE pagination) ---
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')
adj_additive_sum = adjustments.filter(
type__in=ADDITIVE_TYPES
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
adj_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))
# --- Context for the template ---
context.update({
'adj_page': adj_page,
'adj_total_count': adj_total_count,
'adj_unpaid_count': adj_unpaid_count,
'adj_unpaid_sum': adj_unpaid_sum,
'adj_additive_sum': adj_additive_sum,
'adj_deductive_sum': adj_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,
'sort': sort_col,
'order': sort_order,
},
'adjustment_types': list(ADDITIVE_TYPES) + list(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'),
})
Step 4: Run the tests to verify pass
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
Expected: 8 tests pass.
Full suite:
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
Expected: Ran 58 tests (50 from before + 8 new).
Step 5: Commit
git add core/views.py core/tests.py
git commit -m "feat(adjustments): backend filter branch for ?status=adjustments
Type / worker / team / status / date filters, sort, stats, pagination.
Subquery pattern on the team filter avoids M2M JOIN inflation
(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter
come later (Tasks 5/6/7)."
Task 4: Tab markup + filter bar + flat table + row actions
Goal: Wire the view's context into the template — add the nav tab, the filter bar with Choices.js multi-selects, the flat table with badges and row actions, the stats header. This is the task that lets Konrad see and use the feature end-to-end.
Files:
- Create:
core/templates/core/_adjustment_row.html(shared row partial) - Modify:
core/templates/core/payroll_dashboard.html(add tab + content block + inline JS)
Step 1: Create the shared row partial
Create core/templates/core/_adjustment_row.html with this content:
{# === _adjustment_row.html ===
Single <tr> used by BOTH the flat Adjustments view and the grouped view.
Context: `adj` (PayrollAdjustment), `show_checkbox` (bool, default true).
Row actions differ by paid status:
- Paid → [View Payslip]
- Unpaid → [Preview][Edit][×] (reuses existing modals on the dashboard)
#}
{% load format_tags %}
<tr data-adj-id="{{ adj.id }}" class="{% if adj.payroll_record %}adj-row-paid{% else %}adj-row-unpaid{% endif %}">
{# --- Bulk-select checkbox (disabled for paid rows; interactive for unpaid) --- #}
<td class="bulk-select-cell">
{% if adj.payroll_record %}
<input type="checkbox" class="form-check-input" disabled title="Paid rows cannot be bulk-deleted">
{% else %}
<input type="checkbox" class="form-check-input adj-bulk-checkbox" value="{{ adj.id }}">
{% endif %}
</td>
{# --- Date --- #}
<td>{{ adj.date|date:"d M Y" }}</td>
{# --- Worker (link to worker profile) --- #}
<td>
<a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">
{{ adj.worker.name }}
</a>
</td>
{# --- Type badge --- #}
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>
{# --- Amount (sign reflects additive / deductive) --- #}
<td class="text-end" style="font-variant-numeric: tabular-nums;">
{% if adj.type in additive_types %}
<span style="color: var(--text-primary);">+R {{ adj.amount|money }}</span>
{% else %}
<span style="color: var(--text-primary);">−R {{ adj.amount|money }}</span>
{% endif %}
</td>
{# --- Project --- #}
<td>
{% if adj.project %}
<a href="{% url 'project_detail' adj.project.id %}" class="text-decoration-none">
{{ adj.project.name }}
</a>
{% else %}<span class="text-muted">—</span>{% endif %}
</td>
{# --- Team (worker's first team; may be blank) --- #}
<td>
{% with team=adj.worker.teams.first %}
{% if team %}{{ team.name }}{% else %}<span class="text-muted">—</span>{% endif %}
{% endwith %}
</td>
{# --- Description (truncated with tooltip for full text) --- #}
<td>
{% if adj.description %}
<span title="{{ adj.description }}" data-bs-toggle="tooltip">
{{ adj.description|truncatechars:40 }}
</span>
{% else %}<span class="text-muted">—</span>{% endif %}
</td>
{# --- Status: Paid #N / Unpaid --- #}
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}" class="badge bg-success text-decoration-none">
Paid #{{ adj.payroll_record.id }}
</a>
{% else %}
<span class="badge bg-warning">Unpaid</span>
{% endif %}
</td>
{# --- Row actions --- #}
<td class="text-end">
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}"
class="btn btn-sm btn-outline-secondary" title="View payslip">
<i class="fas fa-eye"></i>
</a>
{% else %}
<button type="button"
class="btn btn-sm btn-outline-secondary preview-payslip-btn"
data-worker-id="{{ adj.worker.id }}"
title="Preview payslip">
<i class="fas fa-eye"></i>
</button>
<button type="button"
class="btn btn-sm btn-outline-primary edit-adjustment-btn"
data-adj-id="{{ adj.id }}"
data-type="{{ adj.type }}"
data-amount="{{ adj.amount }}"
data-description="{{ adj.description|default:'' }}"
data-bs-toggle="modal"
data-bs-target="#editAdjustmentModal"
title="Edit">
<i class="fas fa-pen"></i>
</button>
<button type="button"
class="btn btn-sm btn-outline-danger delete-adjustment-btn"
data-adj-id="{{ adj.id }}"
title="Delete">
<i class="fas fa-times"></i>
</button>
{% endif %}
</td>
</tr>
Step 2: Add the nav tab <li>
Edit core/templates/core/payroll_dashboard.html. Find the tab strip at line 252. Add a new <li> inside <ul class="nav nav-tabs">, RIGHT AFTER the "Loans & Advances" tab (current last child, around line 267):
<li class="nav-item" role="presentation">
<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>
Step 3: Add the new tab content block
Still in payroll_dashboard.html. After the existing Loans tab's closing {% endif %} (the block that renders {% if active_tab == 'loans' %}...{% endif %}), add:
{# =============================================== #}
{# === ADJUSTMENTS TAB === #}
{# =============================================== #}
{% elif active_tab == 'adjustments' %}
{# --- Sticky filter bar --- #}
<div class="adjustments-filter-bar" id="adjustmentsFilters">
<form method="get" action="{% url 'payroll_dashboard' %}" class="d-flex flex-wrap gap-3 align-items-end w-100" id="adjFilterForm">
<input type="hidden" name="status" value="adjustments">
{# --- Type multi-select --- #}
<div class="flex-grow-1" style="min-width: 180px;">
<label class="form-label small mb-1">Type</label>
<select name="type" class="form-select form-select-sm adj-multi" multiple data-placeholder="All types">
{% for t in adjustment_types %}
<option value="{{ t }}" {% if t in adj_filter_values.type %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
{# --- Workers multi-select (cross-filtered by Teams in Task 7) --- #}
<div class="flex-grow-1" style="min-width: 180px;">
<label class="form-label small mb-1">Workers</label>
<select name="worker" id="adjWorkerSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All workers">
{% for w in all_workers_for_filter %}
<option value="{{ w.id }}" {% if w.id in adj_filter_values.worker %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
</div>
{# --- Teams multi-select --- #}
<div class="flex-grow-1" style="min-width: 160px;">
<label class="form-label small mb-1">Teams</label>
<select name="team" id="adjTeamSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All teams">
{% for t in all_teams_for_filter %}
<option value="{{ t.id }}" {% if t.id in adj_filter_values.team %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
{# --- Status single-select --- #}
<div style="min-width: 120px;">
<label class="form-label small mb-1">Status</label>
<select name="adj_status" class="form-select form-select-sm">
<option value="" {% if not adj_filter_values.adj_status %}selected{% endif %}>All</option>
<option value="unpaid" {% if adj_filter_values.adj_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
<option value="paid" {% if adj_filter_values.adj_status == 'paid' %}selected{% endif %}>Paid</option>
</select>
</div>
{# --- Date range --- #}
<div style="min-width: 130px;">
<label class="form-label small mb-1">From</label>
<input type="date" name="adj_date_from" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}">
</div>
<div style="min-width: 130px;">
<label class="form-label small mb-1">To</label>
<input type="date" name="adj_date_to" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_to }}">
</div>
{# --- Sort (hidden state — click on <th> sets these via JS in Task 9) --- #}
<input type="hidden" name="sort" value="{{ adj_filter_values.sort }}">
<input type="hidden" name="order" value="{{ adj_filter_values.order }}">
{# --- Apply / Clear --- #}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i>Apply
</button>
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
</div>
{# --- Stats row (scoped to filtered set) --- #}
<div class="d-flex flex-wrap gap-3 mb-3 px-2 small text-muted">
<span><strong>{{ adj_total_count }}</strong> adjustment{{ adj_total_count|pluralize }}</span>
<span>·</span>
<span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
<span>·</span>
<span>+R {{ adj_additive_sum|money }} net additive</span>
<span>·</span>
<span>−R {{ adj_deductive_sum|money }} net deductive</span>
</div>
{# --- Flat table --- #}
{% if adj_page.object_list %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="adjSelectAll" title="Select all unpaid on this page">
</th>
<th class="sortable" data-sort="date">Date <i class="fas fa-sort sort-arrow"></i></th>
<th class="sortable" data-sort="worker">Worker <i class="fas fa-sort sort-arrow"></i></th>
<th>Type</th>
<th class="text-end sortable" data-sort="amount">Amount <i class="fas fa-sort sort-arrow"></i></th>
<th>Project</th>
<th>Team</th>
<th>Description</th>
<th class="sortable" data-sort="status">Status <i class="fas fa-sort sort-arrow"></i></th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for adj in adj_page.object_list %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=adjustment_types %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# --- Pagination --- #}
{% if adj_page.has_other_pages %}
<nav class="mt-3 d-flex justify-content-center">
<ul class="pagination pagination-sm">
{% if adj_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.previous_page_number }}">Previous</a>
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ adj_page.number }} of {{ adj_page.paginator.num_pages }}</span></li>
{% if adj_page.has_next %}
<li class="page-item">
<a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.next_page_number }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
{# Simple placeholder empty state — polish comes in Task 10 #}
<div class="adj-empty-state">
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
<p>No adjustments match these filters.</p>
</div>
{% endif %}
{# Need the additive-types list in the row partial for +/- sign rendering #}
{{ adjustment_types|json_script:"adjAdditiveTypes" }}
Note: the additive_types value passed into the row partial is really the list of ADDITIVE_TYPES names. Pass it explicitly so the row partial doesn't accidentally import the full adjustment_types list.
Refine: at the end of views.py's adjustments branch, add a dedicated 'additive_types': list(ADDITIVE_TYPES) context key and change the row include to {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}.
Step 4: Add the Choices.js init JS block
Still in payroll_dashboard.html. Near the bottom of the file — find the existing big <script> block that inits the payroll dashboard JS. Inside that block (so it runs on DOMContentLoaded with everything else), add:
// === ADJUSTMENTS TAB — Choices.js multi-selects ===
// Three multi-select filters (Type / Workers / Teams). Lazy-init only when
// the user is actually on the Adjustments tab (Choices.js is expensive).
if (document.getElementById('adjustmentsFilters')) {
document.querySelectorAll('#adjFilterForm .adj-multi').forEach(function(sel) {
new Choices(sel, {
removeItemButton: true,
shouldSort: false,
placeholder: true,
placeholderValue: sel.getAttribute('data-placeholder') || '',
});
});
}
Step 5: Verify in the browser
Start dev server (if not already). Navigate to /payroll/?status=adjustments. Expect:
- Nav tab "Adjustments" active
- Sticky filter bar across the top
- 3 stat numbers in the small-text row below
- Table with all existing PayrollAdjustment rows — each with a coloured badge per type
- Row actions on unpaid rows: Preview + Edit + ×; on paid rows: View Payslip only
- Clicking the Type/Workers/Teams filter + Apply submits a form GET and reloads with the filter active
Step 6: Run the suite
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
Expected: 58 tests still pass (no new test here — Task 4 is UI-side, existing tests cover the backend).
Step 7: Commit
git add core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html core/views.py
git commit -m "feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions
Reuses existing modals (#editAdjustmentModal, #previewPayslipModal, delete
confirm flow) — zero new JS for row actions. Choices.js lazy-inits only
when the tab is active. Stats row scoped to filter set. Subquery pattern
on team filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come
in Tasks 5/6/7."
🚧 CHECKPOINT 1 — demo-able midpoint
Hard-pause here. Do NOT proceed to Task 5 until Konrad approves.
The browser-testable surface at this point:
/payroll/?status=adjustmentsloads, tab is active- All
PayrollAdjustmentrows show in the table with the right coloured badges - Multi-select filters (Type / Workers / Teams) + Status + Date range work via Apply
- Stats row updates when filters are applied
- Row actions: Paid row → View Payslip link; Unpaid row → Preview / Edit / × buttons
- Pagination works past 50 rows
- Existing
/payroll/,/payroll/?status=paid,/payroll/?status=loanstabs keep working
Ask Konrad:
- Is the palette legible? (he specified these colours in brainstorming; worth a gut-check on real data)
- Does clicking Edit/Preview/× on an unpaid row open the expected modal behaviour?
- Are the filters covering his daily use case ("see unpaid bonuses this month", "all of Alice's overtime", etc.)?
Red light → fix before proceeding. Green light → Task 5.
Task 5: Group-by helper + toggle + template rendering
Goal: Add the _group_adjustments helper + the Flat / By Type / By Worker toggle UI + the grouped-table rendering path. Rows still use _adjustment_row.html (no markup duplication).
Files:
- Modify:
core/views.py— add helper, wire into adjustments branch - Modify:
core/templates/core/payroll_dashboard.html— add toggle pills + grouped rendering - Test:
core/tests.py— extendAdjustmentsTabTests
Step 1: Write the failing tests (2 tests)
Append to AdjustmentsTabTests:
def test_group_by_type(self):
self._login_admin()
resp = self.client.get(self.url + '&group_by=type')
groups = resp.context['adj_groups']
self.assertIsNotNone(groups)
labels = {g['label'] for g in groups}
self.assertEqual(labels, {'Bonus', 'Deduction'})
bonus_group = next(g for g in groups if g['label'] == 'Bonus')
self.assertEqual(bonus_group['count'], 2)
self.assertEqual(bonus_group['net_sum'], Decimal('800.00')) # +R 500 + +R 300
deduction_group = next(g for g in groups if g['label'] == 'Deduction')
self.assertEqual(deduction_group['net_sum'], Decimal('-100.00'))
def test_group_by_worker(self):
self._login_admin()
resp = self.client.get(self.url + '&group_by=worker')
groups = resp.context['adj_groups']
self.assertIsNotNone(groups)
labels = {g['label'] for g in groups}
self.assertEqual(labels, {'Alice', 'Bob'})
alice = next(g for g in groups if g['label'] == 'Alice')
# Alice: +R 500 bonus + (-R 100) deduction = +R 400 net
self.assertEqual(alice['count'], 2)
self.assertEqual(alice['net_sum'], Decimal('400.00'))
Step 2: Run to verify failure
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_group_by_type core.tests.AdjustmentsTabTests.test_group_by_worker -v 2
Expected: KeyError: 'adj_groups' in context.
Step 3: Implement the helper
Add _group_adjustments to core/views.py near other helpers (e.g. just before def payroll_dashboard):
# =============================================================================
# === ADJUSTMENT GROUPING HELPER ===
# Used by the Adjustments tab's By Type / By Worker render path.
# =============================================================================
def _group_adjustments(adjustments, group_by):
"""Regroup a flat list/queryset of PayrollAdjustment into buckets.
`group_by` is 'type' or 'worker'. Returns a list of dicts:
{'label', 'slug', 'rows', 'count', 'net_sum'}
Ordered by descending magnitude of net_sum so the highest-impact
bucket sits at the top of the view (big groups 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
Then inside the adjustments branch, right after adj_page = paginator.get_page(...), wire it up:
# --- Group-by rendering (passed to template as adj_groups; None = flat view) ---
group_by = request.GET.get('group_by', '').strip()
adj_groups = None
if group_by in ('type', 'worker'):
adj_groups = _group_adjustments(adj_page.object_list, group_by)
And add 'adj_groups': adj_groups and 'group_by': group_by keys to the context.update({...}) at the end of the branch. Update adj_filter_values to include 'group_by': group_by.
Step 4: Add toggle + grouped render in template
Edit payroll_dashboard.html. Inside the Adjustments tab block (after the filter bar, before the stats row), add the toggle pills:
{# --- Group-by toggle --- #}
<div class="d-flex align-items-center gap-2 mb-3 adj-groupby-toggle">
<span class="text-muted small">Show as:</span>
<div class="btn-group" role="group">
<a href="?{% url_replace request 'group_by' '' %}"
class="btn {% if not adj_filter_values.group_by %}btn-accent{% else %}btn-outline-secondary{% endif %}">Flat</a>
<a href="?{% url_replace request 'group_by' 'type' %}"
class="btn {% if adj_filter_values.group_by == 'type' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Type</a>
<a href="?{% url_replace request 'group_by' 'worker' %}"
class="btn {% if adj_filter_values.group_by == 'worker' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Worker</a>
</div>
</div>
The url_replace template tag doesn't exist yet — add it to format_tags.py:
# === url_replace tag ===
# Returns the current request's querystring with one key replaced.
# Used by filter-toggle links like "?status=adjustments&group_by=type".
@register.simple_tag
def url_replace(request, key, value):
qd = request.GET.copy()
if value == '' or value is None:
qd.pop(key, None)
else:
qd[key] = value
return qd.urlencode()
Then replace the <tbody> flat loop with a conditional on adj_groups:
{% if adj_groups %}
{# --- Grouped view: one collapsible section per bucket --- #}
{% for group in adj_groups %}
<tr class="adj-group-header" data-bs-toggle="collapse" data-bs-target="#adj-group-{{ group.slug }}">
<td colspan="10">
<i class="fas fa-chevron-down"></i>
<span class="adj-group-label">{{ group.label }}</span>
<span class="adj-group-meta">
{{ group.count }} row{{ group.count|pluralize }} ·
{% if group.net_sum >= 0 %}+{% else %}−{% endif %}R {{ group.net_sum|money_abs }} net
</span>
</td>
</tr>
<tbody id="adj-group-{{ group.slug }}" class="collapse show">
{% for adj in group.rows %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
{% endfor %}
</tbody>
{% endfor %}
{% else %}
{# --- Flat view (Task 4) --- #}
<tbody>
{% for adj in adj_page.object_list %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
{% endfor %}
</tbody>
{% endif %}
Note |money_abs — a new filter needed because {{ -500|money }} would render R -500 but we want −R 500.00. Add to format_tags.py:
# === money_abs filter ===
# Formats the absolute value of a Decimal in ZAR style (space-separated).
# Callers handle the sign explicitly. Pairs with the + / − prefix logic
# in the Adjustments group-header template.
@register.filter
def money_abs(value):
if value is None:
return ''
return money(abs(value))
Step 5: Run the tests
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
Expected: 10 tests pass.
Step 6: Browser smoke test
Navigate /payroll/?status=adjustments&group_by=type → headers "Bonus" / "Deduction" with row counts + net sums; rows under each. Click a header → section collapses. Click again → expands. Same for ?group_by=worker. Back to Flat via the toggle pill.
Step 7: Commit
git add core/views.py core/templates/core/payroll_dashboard.html core/templatetags/format_tags.py core/tests.py
git commit -m "feat(adjustments): group-by type / worker + collapsible headers
Adds _group_adjustments helper, |money_abs + url_replace template
helpers. Groups ordered by |net_sum| desc so biggest impact is at
the top. Shares the _adjustment_row.html partial — no row markup
duplication."
Task 6: Bulk-delete endpoint + floating action bar
Goal: Let admins select multiple unpaid rows and delete them in one shot via a new endpoint. Paid rows are silently skipped at the DB level (defensive).
Files:
- Modify:
core/views.py— newbulk_delete_adjustmentsview - Modify:
core/urls.py— add URL pattern - Modify:
core/templates/core/payroll_dashboard.html— add floating action bar + JS - Test:
core/tests.py— extendAdjustmentsTabTests
Step 1: Write the failing tests (2 tests)
def test_bulk_delete_only_affects_unpaid(self):
"""POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
deletes ONLY unpaid rows. Paid rows are untouched (payroll history
is immutable)."""
self._login_admin()
# Pay a1
pr = PayrollRecord.objects.create(
worker=self.w1, date=datetime.date(2026, 4, 15),
days_worked=20, total_amount=Decimal('4000'),
)
self.a1.payroll_record = pr
self.a1.save()
# Request delete on all three (a1 paid, a2 + a3 unpaid)
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [self.a1.id, self.a2.id, self.a3.id]},
)
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body['deleted'], 2)
self.assertEqual(body['requested'], 3)
# a1 survives (paid), a2 + a3 gone
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a3.id).exists())
def test_bulk_delete_requires_admin(self):
self.client.login(username='adj-sup', password='pass')
resp = self.client.post(
reverse('bulk_delete_adjustments'),
{'adjustment_ids': [self.a1.id]},
)
self.assertEqual(resp.status_code, 403)
# a1 still present
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
Step 2: Run to verify failure
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_bulk_delete_only_affects_unpaid core.tests.AdjustmentsTabTests.test_bulk_delete_requires_admin -v 2
Expected: NoReverseMatch: 'bulk_delete_adjustments'.
Step 3: Implement the endpoint
Add to core/views.py, near delete_adjustment:
# =============================================================================
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
# Only unpaid adjustments are deleted; paid rows survive (payroll
# history is immutable and the existing edit_adjustment view also
# blocks edits on paid rows).
# =============================================================================
@login_required
@require_POST
def bulk_delete_adjustments(request):
"""Delete multiple unpaid PayrollAdjustment rows in one DB call.
Body (form-encoded): `adjustment_ids` — one entry per ID.
Returns JSON: `{"deleted": N, "requested": M}`.
"""
if not is_admin(request.user):
return JsonResponse({'error': 'Admin access required'}, status=403)
ids = request.POST.getlist('adjustment_ids')
# Keep only unpaid — paid rows are silently skipped.
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),
})
Make sure the imports at the top of views.py include from django.views.decorators.http import require_POST (or require_http_methods(['POST'])). Grep to check.
Step 4: Add URL
In core/urls.py, in the payroll section around line 56 (just after delete_adjustment):
# Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),
Step 5: Run the tests
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
Expected: 12 tests pass.
Step 6: Add floating action bar + selection JS
In payroll_dashboard.html, immediately BEFORE the closing </div> of the adjustments tab block (or anywhere inside {% elif active_tab == 'adjustments' %} after the table), add:
{# --- Floating bulk action bar (shown when >=1 unpaid row is selected) --- #}
<div class="adj-bulk-bar" id="adjBulkBar" hidden>
<span><strong id="adjBulkCount">0</strong> selected</span>
<button type="button" class="btn btn-sm btn-outline-danger" id="adjBulkDeleteBtn">
<i class="fas fa-trash me-1"></i>Delete
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="adjBulkClearBtn">Clear</button>
</div>
JS (inside the same <script> DOMContentLoaded block as the Choices.js init):
// === ADJUSTMENTS TAB — Bulk select + delete ===
if (document.getElementById('adjustmentsFilters')) {
var bulkBar = document.getElementById('adjBulkBar');
var bulkCount = document.getElementById('adjBulkCount');
var bulkDeleteBtn = document.getElementById('adjBulkDeleteBtn');
var bulkClearBtn = document.getElementById('adjBulkClearBtn');
var selectAll = document.getElementById('adjSelectAll');
function getCheckedIds() {
return Array.from(document.querySelectorAll('.adj-bulk-checkbox:checked'))
.map(function(cb) { return cb.value; });
}
function refreshBulkBar() {
var ids = getCheckedIds();
bulkCount.textContent = ids.length;
bulkBar.hidden = ids.length === 0;
}
// Per-row checkbox change
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.addEventListener('change', refreshBulkBar);
});
// Select-all (only unpaid rows are interactive)
if (selectAll) {
selectAll.addEventListener('change', function() {
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.checked = selectAll.checked;
});
refreshBulkBar();
});
}
// Clear selection
bulkClearBtn.addEventListener('click', function() {
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.checked = false;
});
if (selectAll) selectAll.checked = false;
refreshBulkBar();
});
// Delete selected — confirm + POST
bulkDeleteBtn.addEventListener('click', function() {
var ids = getCheckedIds();
if (ids.length === 0) return;
if (!confirm('Delete ' + ids.length + ' adjustment(s)? This cannot be undone.')) return;
var form = new FormData();
ids.forEach(function(id) { form.append('adjustment_ids', id); });
form.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
fetch('{% url "bulk_delete_adjustments" %}', {
method: 'POST', body: form, credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function(data) {
// Simple refresh — server-rendered view reflects the deletion
window.location.reload();
})
.catch(function(err) {
alert('Bulk delete failed: ' + err);
});
});
}
Step 7: Commit
git add core/views.py core/urls.py core/templates/core/payroll_dashboard.html core/tests.py
git commit -m "feat(adjustments): bulk-delete unpaid rows + floating action bar
New POST endpoint silently skips paid rows at the DB level (defensive
against UI bugs). Floating bar slides up from the bottom when >=1 row
is selected — Clear / Delete / count. Confirm dialog before POST."
Task 7: Cross-filter Team → Workers + auto-removal toast
Goal: When Team(s) are selected in the filter bar, the Workers dropdown should only show workers from those teams. If a previously-selected worker is no longer in any selected team, auto-remove them and show a toast.
Exactly the pattern from Feature 1 (Inline Filters) — lift the JS approach.
Files:
- Modify:
core/views.py— addteam_worker_pairs_jsoncontext key - Modify:
core/templates/core/payroll_dashboard.html— add{% json_script %}+ JS - Test:
core/tests.py— 1 test that pairs are exposed
Step 1: Add the failing test
def test_team_worker_pairs_json_context_key(self):
"""Cross-filter map is a raw Python list of {team_id, worker_id}
dicts. Django's |json_script filter handles serialisation at
template render time (no double-encoding — see 2026-04-23
inline-filters regression test)."""
self._login_admin()
resp = self.client.get(self.url)
pairs = resp.context['team_worker_pairs_json']
self.assertIsInstance(pairs, list)
for entry in pairs:
self.assertIn('team_id', entry)
self.assertIn('worker_id', entry)
# Our fixture: team Alpha has w1 and w2
pair_set = {(p['team_id'], p['worker_id']) for p in pairs}
self.assertIn((self.team.id, self.w1.id), pair_set)
self.assertIn((self.team.id, self.w2.id), pair_set)
Step 2: Run to verify failure
Expected: KeyError: 'team_worker_pairs_json'.
Step 3: Implement
In core/views.py adjustments branch, inside the context.update({...}) block, add:
# --- Cross-filter source: (team_id, worker_id) pairs ---
# Consumed by the frontend JS to filter the Workers dropdown when
# Team(s) are picked. Raw list — |json_script in the template
# handles safe serialisation (NOT json.dumps — see 2026-04-23
# inline-filters regression).
'team_worker_pairs_json': list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
),
Step 4: Template + JS
In payroll_dashboard.html, inside the adjustments tab block (near the bottom), add:
{# --- Cross-filter data for the Workers ↔ Teams JS --- #}
{{ team_worker_pairs_json|json_script:"teamWorkerPairsAdj" }}
Inside the DOMContentLoaded script for adjustments, add:
// === ADJUSTMENTS TAB — Team → Workers cross-filter ===
if (document.getElementById('teamWorkerPairsAdj')) {
var pairsAdj = JSON.parse(document.getElementById('teamWorkerPairsAdj').textContent);
var workerByTeamAdj = {};
pairsAdj.forEach(function(p) {
if (!workerByTeamAdj[p.team_id]) workerByTeamAdj[p.team_id] = new Set();
workerByTeamAdj[p.team_id].add(p.worker_id);
});
var teamSel = document.getElementById('adjTeamSelect');
var workerSel = document.getElementById('adjWorkerSelect');
function selectedTeamIds() {
return Array.from(teamSel.selectedOptions).map(function(o) {
return parseInt(o.value, 10);
});
}
function applyWorkerCrossFilter() {
var teamIds = selectedTeamIds();
if (teamIds.length === 0) {
// No constraint — enable everything
Array.from(workerSel.options).forEach(function(o) { o.disabled = false; });
return;
}
var validIds = new Set();
teamIds.forEach(function(tid) {
if (workerByTeamAdj[tid]) {
workerByTeamAdj[tid].forEach(function(wid) { validIds.add(wid); });
}
});
Array.from(workerSel.options).forEach(function(o) {
var wid = parseInt(o.value, 10);
// Keep already-selected workers enabled (so user can see + deselect)
o.disabled = !validIds.has(wid) && !o.selected;
});
}
// Run once on load; re-run when Team selection changes
applyWorkerCrossFilter();
teamSel.addEventListener('change', applyWorkerCrossFilter);
}
Step 5: Commit
git add core/views.py core/templates/core/payroll_dashboard.html core/tests.py
git commit -m "feat(adjustments): Team → Workers cross-filter via JSON pair map
Mirrors Feature 1's inline-filters cross-filter pattern. Workers
dropdown options disable when Team(s) are selected and the worker
isn't in any of them. Already-selected workers stay enabled so
the user can still deselect them."
Task 8: Date picker single vs. range + preset buttons
Goal: The current filter bar has From / To date inputs (always both). Konrad wants a single date by default with a … button that expands to range mode, plus preset quick-buttons (Today / This week / This month / Clear).
Files:
- Modify:
core/templates/core/payroll_dashboard.html— rework the date inputs
Step 1: Replace the two date inputs
In the filter bar, replace the two <div>s for "From" and "To" with:
{# --- Date picker (single or range) --- #}
<div id="adjDateWrap" style="min-width: 140px;">
<label class="form-label small mb-1 d-flex align-items-center gap-2">
<span id="adjDateLabel">Date</span>
<button type="button" id="adjDateRangeToggle" class="btn btn-link btn-sm p-0 ms-auto"
title="Toggle range mode">
<i class="fas fa-ellipsis-h"></i>
</button>
</label>
<input type="date" name="adj_date_from" id="adjDateFrom"
class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}"
placeholder="Date">
<input type="date" name="adj_date_to" id="adjDateTo"
class="form-control form-control-sm mt-1" value="{{ adj_filter_values.adj_date_to }}"
hidden>
<div class="d-flex gap-1 mt-1" id="adjDatePresets">
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">This week</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">This month</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
</div>
</div>
Step 2: Add the JS — single vs. range toggle + preset handlers
Inside the same script block:
// === ADJUSTMENTS TAB — Date single/range toggle + presets ===
if (document.getElementById('adjDateWrap')) {
var dateFrom = document.getElementById('adjDateFrom');
var dateTo = document.getElementById('adjDateTo');
var toggleBtn = document.getElementById('adjDateRangeToggle');
var dateLabel = document.getElementById('adjDateLabel');
// Single mode hides `to`; range mode shows it. Submit logic:
// - single: from == to (backend treats as exact match)
// - range : from <= date <= to
function applyMode(rangeMode) {
dateTo.hidden = !rangeMode;
dateLabel.textContent = rangeMode ? 'Date range' : 'Date';
// In single mode keep dateTo mirrored to dateFrom on form submit
if (!rangeMode) dateTo.value = dateFrom.value;
}
// Start in range mode if both fields pre-populated AND differ
var initialRange = dateFrom.value && dateTo.value && dateFrom.value !== dateTo.value;
applyMode(initialRange);
toggleBtn.addEventListener('click', function() {
applyMode(dateTo.hidden); // toggling hidden
});
dateFrom.addEventListener('change', function() {
if (dateTo.hidden) dateTo.value = dateFrom.value;
});
// Preset quick-buttons
document.querySelectorAll('#adjDatePresets [data-preset]').forEach(function(btn) {
btn.addEventListener('click', function() {
var preset = btn.getAttribute('data-preset');
var today = new Date();
var iso = function(d) { return d.toISOString().slice(0, 10); };
if (preset === 'today') {
dateFrom.value = iso(today);
dateTo.value = iso(today);
applyMode(false);
} else if (preset === 'week') {
var weekStart = new Date(today);
weekStart.setDate(today.getDate() - ((today.getDay() + 6) % 7)); // Monday
var weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
dateFrom.value = iso(weekStart);
dateTo.value = iso(weekEnd);
applyMode(true);
} else if (preset === 'month') {
dateFrom.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
dateTo.value = iso(new Date(today.getFullYear(), today.getMonth() + 1, 0));
applyMode(true);
} else if (preset === 'clear') {
dateFrom.value = '';
dateTo.value = '';
applyMode(false);
}
});
});
}
Step 3: Browser smoke test
Navigate the tab. Confirm:
- Single mode shows just the first date input +
…button to toggle - Click
…→ second date input appears; label changes to "Date range" - Presets work: Today sets both same; This week / This month sets a range
- Submit (Apply) → URL includes
adj_date_fromandadj_date_tomatching the inputs
Step 4: Commit
git add core/templates/core/payroll_dashboard.html
git commit -m "feat(adjustments): date picker single/range toggle + preset quick-buttons
Single by default (one input); `…` button expands to range. Today /
This week / This month / Clear presets. Submit logic keeps from==to
for exact-match days."
Task 9: Sort header clicks + URL state
Goal: Clicking a <th class="sortable"> toggles sort column and direction via URL. Sort arrows reflect current state.
Files:
- Modify:
core/templates/core/payroll_dashboard.html— JS + CSS class toggles
Step 1: Update sort arrows to reflect current state
In the <thead> block (Task 4 step 3), replace the <i class="fas fa-sort sort-arrow"></i> with conditional rendering that picks up adj_filter_values.sort and .order:
<th class="sortable{% if adj_filter_values.sort == 'date' %} sorted{% endif %}" data-sort="date">
Date
<i class="fas fa-sort{% if adj_filter_values.sort == 'date' %}-{{ adj_filter_values.order }}{% endif %} sort-arrow"></i>
</th>
(Font Awesome: fa-sort-asc / fa-sort-desc are the directional variants.)
Do the same for Worker, Amount, Status.
Step 2: Add the JS handler
// === ADJUSTMENTS TAB — Sort header clicks ===
if (document.getElementById('adjustmentsFilters')) {
document.querySelectorAll('th.sortable').forEach(function(th) {
th.addEventListener('click', function() {
var col = th.getAttribute('data-sort');
var currentSort = document.querySelector('input[name=sort]');
var currentOrder = document.querySelector('input[name=order]');
// Toggle direction if clicking the same column; else default desc
if (currentSort.value === col) {
currentOrder.value = currentOrder.value === 'asc' ? 'desc' : 'asc';
} else {
currentSort.value = col;
currentOrder.value = 'desc';
}
// Submit the filter form so the sort persists in the URL
document.getElementById('adjFilterForm').submit();
});
});
}
Step 3: Commit
git add core/templates/core/payroll_dashboard.html
git commit -m "feat(adjustments): sortable column headers — click to toggle direction
4 sortable columns (Date / Worker / Amount / Status). Click cycles
desc → asc → desc. URL state via hidden sort/order inputs so the
sort is bookmarkable and survives pagination."
Task 10: Empty state + sticky filter bar polish + small fit-and-finish
Goal: Make the "no rows" case genuinely useful; polish any visual leftovers from earlier tasks.
Files:
- Modify:
core/templates/core/payroll_dashboard.html— improve empty state
Step 1: Replace the placeholder empty state with the full version
Find the <div class="adj-empty-state"> (from Task 4 step 3) and replace with:
<div class="adj-empty-state card">
<div class="card-body text-center py-5">
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
<h6 class="mb-2">No adjustments match these filters.</h6>
<p class="text-muted small mb-3">Try clearing filters or adding a new adjustment.</p>
<div class="d-flex gap-2 justify-content-center">
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-times me-1"></i>Clear filters
</a>
<button type="button" class="btn btn-sm btn-accent"
data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<i class="fas fa-plus me-1"></i>Add adjustment
</button>
</div>
</div>
</div>
Step 2: Verify sticky behaviour
Scroll the table long enough that the stats row + filter bar would normally scroll out of view. The filter bar should stay pinned to the top of the viewport. If not, adjust .adjustments-filter-bar { position: sticky; top: 0; ... } in custom.css — the sticky context ancestor may need a position: relative and no overflow: hidden.
Step 3: Run the full suite
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
Expected: 60 tests pass (50 pre-existing + 10 Adjustments).
Step 4: Commit
git add core/templates/core/payroll_dashboard.html static/css/custom.css
git commit -m "style(adjustments): empty-state card with recovery CTAs + sticky bar polish"
Task 11: Manual QA + "Shipped" block on design doc
Goal: Run the QA matrix, record deviations on the design doc, update CLAUDE.md with one line about the new tab's URL.
Files:
- Modify:
docs/plans/2026-04-23-adjustments-tab-design.md— append "Shipped" section - Modify:
CLAUDE.md— one line in URL Routes table
Step 1: Manual QA matrix
Run each flow in the browser with real data and note any bugs:
/payroll/?status=adjustmentsloads, "Adjustments" tab is active- All existing
PayrollAdjustmentrows show with correct coloured badges - Type filter: pick Bonus + Overtime → rows filter to union
- Worker filter: pick Alice → rows filter to Alice's only
- Team filter: pick a team → worker dropdown narrows to that team's workers (cross-filter)
- Status = Unpaid → only unpaid rows
- Date picker: single mode filters to exact day;
…toggles range; presets fill correctly - Sort: click Date → desc by date; click again → asc; click Amount → sort by amount
- Group-by: By Type → collapsible headers with counts and net sums; By Worker → same
- Bulk select: tick 3 rows → floating bar shows "3 selected"; Delete → confirms → deletes → reloads; Clear unticks all
- Paid row → View Payslip button only; bulk-select disabled
- Unpaid row → Preview (opens
#previewPayslipModal), Edit (opens#editAdjustmentModalpre-filled), × (opens existing delete confirm) - Empty state card with recovery CTAs when filters return 0 rows
- Pagination: if 50+ rows, page 2 link works and keeps all filters in URL
- Non-admin supervisor → 302/403 at
/payroll/?status=adjustments
Step 2: Append the Shipped block to the design doc
At the bottom of docs/plans/2026-04-23-adjustments-tab-design.md:
---
## 19. Shipped — <DATE-YOU-COMPLETE>
Implementation complete; all 11 QA flows pass. Commits on `ai-dev`:
| # | Commit | Scope |
|---|--------|-------|
| 1 | `<sha>` | `type_slug` filter + tests |
| 2 | `<sha>` | CSS badge palette + foundational styles |
| 3 | `<sha>` | Backend filter branch + stats |
| 4 | `<sha>` | Tab + filter bar + flat table + row actions |
| 5 | `<sha>` | Group-by helper + toggle |
| 6 | `<sha>` | Bulk-delete endpoint + floating bar |
| 7 | `<sha>` | Team → Workers cross-filter |
| 8 | `<sha>` | Date single/range + presets |
| 9 | `<sha>` | Sort header clicks + URL state |
| 10 | `<sha>` | Empty state + polish |
| 11 | `<sha>` | Docs + QA |
Deviations from the original design: <list any, or "none">.
Tests: 50 → <N> passing (+<delta> new).
Step 3: Add one line to CLAUDE.md's URL Routes table
Find the URL Routes table in CLAUDE.md. Add:
| `/payroll/?status=adjustments` | `payroll_dashboard` | Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by; bulk-delete unpaid) |
| `/payroll/adjustments/bulk-delete/` | `bulk_delete_adjustments` | Admin: POST-only; delete multiple unpaid adjustments in one DB call |
Step 4: Commit
git add docs/plans/2026-04-23-adjustments-tab-design.md CLAUDE.md
git commit -m "docs(adjustments): Shipped block on design doc + CLAUDE.md URL route
Captures the 11-task implementation, deviations, and test delta.
CLAUDE.md URL Routes table updated so future sessions surface the
new ?status=adjustments branch + bulk-delete endpoint."
Final checklist before push
After Task 11:
python manage.py test core.tests→ all pass (expect ~60+)python manage.py check→ clean (pre-existingstaticfiles.W004OK)grep -rn "TODO\|FIXME" core/views.py core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html static/css/custom.css→ zero new markers- Browser sweep (QA matrix above)
- Light-theme toggle check: filter bar, badges, group headers, floating bar all readable
- Cross-browser spot check: Chrome + at least one Firefox pane
Then dispatch superpowers:requesting-code-review for a full-feature review subagent pass. Address any issues. Then single batched push of all commits (17 inline-filters + 11 adjustments + 3 pre-existing design docs/plan + any fix-up commits) to origin/ai-dev.
Out of scope reminders (per design §16)
- Bulk Mark Paid → use existing per-worker Pay Now flow via Preview modal
- CSV export → not in original ask; easy to add later
- Keyboard shortcuts beyond Esc → native Tab works
- Inline editing of amount/description → Edit modal already handles it
- Adjustment audit log → would require a new model
- Group by Project → Type and Worker cover the two most useful axes