diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html
index df331a1..a253aa2 100644
--- a/core/templates/core/payroll_dashboard.html
+++ b/core/templates/core/payroll_dashboard.html
@@ -739,6 +739,22 @@
+ {# --- Group-by toggle: Flat / By Type / By Worker --- #}
+ {# Clicking a pill sets the ?group_by= querystring; url_replace preserves #}
+ {# all other filters (type, worker, team, status, dates) so the view #}
+ {# re-renders the SAME filtered set, just re-bucketed. #}
+
+
{# --- Stats row (scoped to the currently-filtered set) --- #}
{{ adj_total_count }} adjustment{{ adj_total_count|pluralize }}
@@ -776,11 +792,42 @@
Actions |
-
- {% for adj in adj_page.object_list %}
- {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
+ {% if adj_groups %}
+ {# Grouped view: one per group with a clickable #}
+ {# header row. Each group's is a Bootstrap collapse #}
+ {# target so clicking the header hides or shows its rows. #}
+ {# Default state: every group expanded (class="collapse show"). #}
+ {% for group in adj_groups %}
+
+
+
+
+ {% for adj in group.rows %}
+ {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
+ {% endfor %}
+
{% endfor %}
-
+ {% else %}
+ {# Flat view (default when no group_by selected) — unchanged from Task 4. #}
+
+ {% for adj in adj_page.object_list %}
+ {% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
+ {% endfor %}
+
+ {% endif %}
diff --git a/core/templatetags/format_tags.py b/core/templatetags/format_tags.py
index b22bf54..95dd1e5 100644
--- a/core/templatetags/format_tags.py
+++ b/core/templatetags/format_tags.py
@@ -82,3 +82,26 @@ def url_replace(request, key, value):
else:
qd[key] = str(value)
return qd.urlencode()
+
+
+# === money_abs filter ===
+# Formats the ABSOLUTE value of a Decimal in ZAR style. Callers handle
+# the sign explicitly via template logic (see the Adjustments-tab group
+# header which renders "+R 800.00" for a positive net_sum and
+# "-R 100.00" for a negative one). Pairs with the existing `money`
+# filter and avoids rendering "R -100.00" which reads as a minus
+# squished against the R.
+@register.filter
+def money_abs(value):
+ """Format the absolute value of a number in ZAR money style.
+
+ Plain-English: same as `money`, but always returns the positive
+ version of the number. The caller is responsible for emitting its
+ own `+` or `-` sign in the template. Useful when the sign needs to
+ appear to the LEFT of the `R` (e.g. `+R 800.00`) rather than
+ attached to the number (which would render `R -800.00` and read
+ confusingly).
+ """
+ if value is None:
+ return ''
+ return money(abs(value))
diff --git a/core/tests.py b/core/tests.py
index 98b84aa..8a89f23 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -1069,3 +1069,28 @@ class AdjustmentsTabTests(TestCase):
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'))
+
+ 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'))
diff --git a/core/views.py b/core/views.py
index 6699cc5..29a5777 100644
--- a/core/views.py
+++ b/core/views.py
@@ -2533,6 +2533,54 @@ def toggle_active(request, model_name, item_id):
return JsonResponse({'error': 'Item not found'}, status=404)
+# =============================================================================
+# === ADJUSTMENT GROUPING HELPER ===
+# Used by the Adjustments tab's By Type / By Worker render path.
+# Plain-English: takes a flat list of PayrollAdjustment rows and regroups
+# them into buckets keyed by adjustment type or by worker. The result is
+# a list of group-dicts the template can iterate, each carrying a label,
+# CSS-friendly slug, the list of rows in the bucket, a count, and the
+# net signed sum of amounts (additives count +, deductives count -).
+# =============================================================================
+
+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
+
+
# =============================================================================
# === PAYROLL DASHBOARD ===
# The main payroll page. Shows per-worker breakdown of what's owed,
@@ -2997,9 +3045,19 @@ def payroll_dashboard(request):
paginator = Paginator(adjustments, 50)
adj_page = paginator.get_page(request.GET.get('page', 1))
+ # --- Group-by rendering (optional; None = flat view) ---
+ # When the user clicks the "By Type" or "By Worker" toggle above
+ # the table, we re-bucket the already-paginated rows. Empty/missing
+ # means the template falls back to the original flat list.
+ 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)
+
# --- Everything the Adjustments tab template will need ---
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,
@@ -3014,6 +3072,7 @@ def payroll_dashboard(request):
'adj_date_to': adj_date_to,
'sort': sort_col,
'order': sort_order,
+ 'group_by': group_by,
},
# Flat list of type labels for the Adjustments tab filter dropdown.
# Stored under a separate key so we don't clobber the existing
diff --git a/static/css/custom.css b/static/css/custom.css
index 451d37b..1cedb1b 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1974,6 +1974,18 @@ body, .card, .modal-content, .form-control, .form-select,
.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; }
+/* --- By-Type group headers: 4px left-accent picks up the type's signature
+ badge colour so grouped rows visually echo the badges below.
+ Uses a [data-type="..."] attribute on the