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. #} +
+ Show as: +
+ Flat + By Type + By Worker +
+
+ {# --- 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 %} + + + + + {{ group.label }} + + {{ group.count }} row{{ group.count|pluralize }} · + {% if group.net_sum >= 0 %}+{% else %}−{% endif %}R {{ group.net_sum|money_abs }} net + + + + + + {% 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 + 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;