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:
parent
4c1cdb6210
commit
0862805623
@ -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 }} ·
|
||||
{% if group.net_sum >= 0 %}+{% else %}−{% 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>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user