ux(report,dashboard): clearer labels for paid/outstanding/avg
Pure-template label cleanups on /report/ — no math changes, just clearer wording for the non-developer reader. Plus one consistency fix on the payroll dashboard. - "Outstanding Now" hero card now shows a "scoped to ..." subline when project/team filters are active (so it's not read as a company-wide figure when it's actually scoped). Finding 5. - "Paid This Period" hero card subline adds "includes adjustments" to head off confusion vs the day-rate-only Labour Cost tables. Finding 10. - "FoxFitt Avg / Day" + "FoxFitt Avg / Month" renamed to "Company Avg / Working Day" / "Company Avg / Month", with a subline that calls out the "at current pay rates" caveat (a worker's daily_rate is computed live from monthly_salary, so retroactive raises inflate historical totals). Findings 2 + 15. - "Labour Cost by Project" + "Labour Cost by Team" tables: header renamed to "Day-Rate Cost" with a tooltip clarifying it excludes adjustments. Finding 10. - Worker Breakdown table: footnote explaining that "Days" and "Total Paid" can disagree within a single period when a worker is paid for previous-period work. Finding 9. - Payroll dashboard chart data: dropped the `worker__active=True` pre-filter on the per-worker breakdown queries so the SQL matches `recent_payments_total` (which has no active filter). The outer loop still iterates active workers only — this is a SQL-side consistency fix, not a behaviour change. Finding 18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ef6db71c9
commit
4186603bcb
@ -199,33 +199,43 @@
|
||||
</div>
|
||||
|
||||
{# === HERO KPI BAND === #}
|
||||
{# Sub-labels intentionally call out current-pay-rate basis (Finding 2)
|
||||
and the active filter scope (Finding 5) so the KPIs aren't read as
|
||||
apples-to-apples when filters are on. #}
|
||||
<div class="row g-3 mb-4 hero-kpi-row">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--danger stat-card--hero h-100">
|
||||
<div class="stat-label">Paid This Period</div>
|
||||
<div class="stat-value">R {{ total_paid_out|money }}</div>
|
||||
<div class="stat-subline">{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</div>
|
||||
<div class="stat-subline">
|
||||
{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }} · includes adjustments
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--warning stat-card--hero h-100">
|
||||
<div class="stat-label">Outstanding Now</div>
|
||||
<div class="stat-value">R {{ current_outstanding.total|money }}</div>
|
||||
<div class="stat-subline">as of {{ current_as_of|date:"H:i" }}</div>
|
||||
<div class="stat-subline">
|
||||
as of {{ current_as_of|date:"H:i" }}
|
||||
{% if project_name != 'All Projects' or team_name != 'All Teams' %}
|
||||
· scoped to {{ project_name }}{% if team_name != 'All Teams' %} / {{ team_name }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--info stat-card--hero h-100">
|
||||
<div class="stat-label">FoxFitt Avg / Day</div>
|
||||
<div class="stat-label">Company Avg / Working Day</div>
|
||||
<div class="stat-value">R {{ company_avg_daily|money }}</div>
|
||||
<div class="stat-subline">lifetime avg · {{ company_working_days }} working days</div>
|
||||
<div class="stat-subline">lifetime · all crews · at current pay rates</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--info stat-card--hero h-100">
|
||||
<div class="stat-label">FoxFitt Avg / Month</div>
|
||||
<div class="stat-label">Company Avg / Month</div>
|
||||
<div class="stat-value">R {{ company_avg_monthly|money }}</div>
|
||||
<div class="stat-subline">lifetime avg · ~30.44 days/month</div>
|
||||
<div class="stat-subline">lifetime · ~30.44 days/month · at current pay rates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -401,7 +411,15 @@
|
||||
{% if cost_per_project %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Project</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th class="text-end">Worker-Days</th>
|
||||
<th class="text-end" data-bs-toggle="tooltip" title="Sum of daily rates only — does NOT include adjustments (bonuses, overtime, deductions). See Adjustment Summary for those.">
|
||||
Day-Rate Cost <i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75rem;"></i>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in cost_per_project %}
|
||||
<tr><td>{{ item.project }}</td><td class="text-end">{{ item.worker_days }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
|
||||
@ -422,7 +440,15 @@
|
||||
{% if cost_per_team %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Team</th><th class="text-end">Worker-Days</th><th class="text-end">Total Cost</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th class="text-end">Worker-Days</th>
|
||||
<th class="text-end" data-bs-toggle="tooltip" title="Sum of daily rates only — does NOT include adjustments (bonuses, overtime, deductions). See Adjustment Summary for those.">
|
||||
Day-Rate Cost <i class="fas fa-info-circle text-muted ms-1" style="font-size: 0.75rem;"></i>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in cost_per_team %}
|
||||
<tr><td>{{ item.team }}</td><td class="text-end">{{ item.worker_days }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
|
||||
@ -471,6 +497,14 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{# Footnote — explains why Days and Total Paid can read as
|
||||
"not matching" within a single period (different timing). #}
|
||||
<p class="text-muted px-3 py-2 mb-0" style="font-size: 0.75rem; border-top: 1px solid var(--border-subtle);">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
<strong>Days</strong> reflect work logged in this period.
|
||||
<strong>Total Paid</strong> reflects payments received in this period —
|
||||
they may not match if a worker was paid in this period for previous-period work.
|
||||
</p>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No worker payment data for this period.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3308,18 +3308,25 @@ def payroll_dashboard(request):
|
||||
})
|
||||
|
||||
# === CHART DATA: Per-Worker Monthly Breakdown ===
|
||||
# Pre-compute payment breakdown for each active worker over the last 6 months.
|
||||
# Pre-compute payment breakdown for each worker over the last 6 months.
|
||||
# This powers the "By Worker" toggle on the Monthly Payroll Totals chart.
|
||||
# Only ~14 workers x 6 months = tiny dataset, so we embed it all as JSON
|
||||
# and switching between workers is instant (no server round-trips).
|
||||
#
|
||||
# SCOPE: no `worker__active=True` filter — historical payments belong
|
||||
# to the worker they were made to, even if that worker has since been
|
||||
# deactivated. This matches `recent_payments_total` above (which also
|
||||
# has no active-filter). The outer loop later iterates `active_workers`
|
||||
# only, so deactivated-worker rows in the lookup dicts are simply
|
||||
# ignored — but the SQL stays consistent across all payroll-dashboard
|
||||
# stats (Finding 18, May 2026).
|
||||
#
|
||||
# `six_months_ago_date` is already defined above (hoisted next to the
|
||||
# date-window setup) and reused here.
|
||||
|
||||
# Query 1: Total amount paid per worker per month.
|
||||
# Uses database-level grouping — one query for ALL workers at once.
|
||||
worker_monthly_paid_qs = PayrollRecord.objects.filter(
|
||||
worker__active=True,
|
||||
date__gte=six_months_ago_date,
|
||||
).values(
|
||||
'worker_id',
|
||||
@ -3338,7 +3345,6 @@ def payroll_dashboard(request):
|
||||
# so it lines up with when the payment actually happened.
|
||||
worker_monthly_adj_qs = PayrollAdjustment.objects.filter(
|
||||
payroll_record__isnull=False,
|
||||
worker__active=True,
|
||||
payroll_record__date__gte=six_months_ago_date,
|
||||
).values(
|
||||
'worker_id',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user