feat(adjustments): group-by type / worker + collapsible headers

Adds _group_adjustments helper that buckets a flat queryset by type or
by worker_id, with signed net_sum (+ for additive, - for deductive)
and descending-magnitude ordering so the biggest-impact bucket sits
at the top.

Template branches on adj_groups: grouped view renders one <tbody>
per group with a Bootstrap-collapse-driven header row, wrapping
include of _adjustment_row.html for the actual rows (no duplication).
Flat view is the default when group_by is empty.

By Type headers get a 4px left-border accent in the matching badge
colour so grouped rows visually echo the badges below them.
Attribute-selector based ([data-type=Bonus] etc.) so the
CSS stays self-descriptive without per-type class explosion.

Adds |money_abs template filter for signed render ('-R 100.00' in
the template becomes money_abs(-100) -> '100.00' after the caller
emits its own sign; avoids 'R -100.00' which reads wrong).

Two new tests lock in the bucket structure + net_sum signing for
both axes. Tests 58 -> 60. url_replace template tag already shipped
in the CP1 pagination fix - reused here for the toggle hrefs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 18:34:07 +02:00
parent 4c1cdb6210
commit 0862805623
5 changed files with 170 additions and 4 deletions

View File

@ -739,6 +739,22 @@
</form>
</div>
{# --- 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. #}
<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" aria-label="Group rows by">
<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>
{# --- Stats row (scoped to the currently-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>
@ -776,11 +792,42 @@
<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=additive_types %}
{% if adj_groups %}
{# Grouped view: one <tbody> per group with a clickable #}
{# header row. Each group's <tbody> 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 %}
<tbody>
<tr class="adj-group-header"
{% if adj_filter_values.group_by == 'type' %}data-type="{{ group.label }}"{% endif %}
data-bs-toggle="collapse"
data-bs-target="#adj-group-{{ group.slug }}"
aria-expanded="true" aria-controls="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 }} &middot;
{% if group.net_sum >= 0 %}+{% else %}&minus;{% endif %}R {{ group.net_sum|money_abs }} net
</span>
</td>
</tr>
</tbody>
<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 %}
</tbody>
{% else %}
{# Flat view (default when no group_by selected) — unchanged from 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 %}
</table>
</div>
</div>

View File

@ -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))

View File

@ -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'))

View File

@ -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

View File

@ -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 <tr class="adj-group-header">
so the selector is self-descriptive (no per-type class explosion). --- */
.adj-group-header[data-type="Bonus"] { border-left: 4px solid var(--badge-bonus-bg); }
.adj-group-header[data-type="Overtime"] { border-left: 4px solid var(--badge-overtime-bg); }
.adj-group-header[data-type="Deduction"] { border-left: 4px solid var(--badge-deduction-bg); }
.adj-group-header[data-type="New Loan"] { border-left: 4px solid var(--badge-loan-bg); }
.adj-group-header[data-type="Loan Repayment"] { border-left: 4px solid var(--badge-loan-rep-bg); }
.adj-group-header[data-type="Advance Payment"] { border-left: 4px solid var(--badge-advance-bg); }
.adj-group-header[data-type="Advance Repayment"] { border-left: 4px solid var(--badge-advance-rep-bg); }
/* --- Floating bulk action bar (appears when >=1 row selected) --- */
.adj-bulk-bar {
position: fixed;