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:
Konrad du Plessis 2026-05-15 02:04:55 +02:00
parent 3ef6db71c9
commit 4186603bcb
2 changed files with 51 additions and 11 deletions

View File

@ -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" }} &ndash; {{ end_date|date:"d M Y" }}</div>
<div class="stat-subline">
{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }} &middot; 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' %}
&middot; 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 &middot; {{ company_working_days }} working days</div>
<div class="stat-subline">lifetime &middot; all crews &middot; 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 &middot; ~30.44 days/month</div>
<div class="stat-subline">lifetime &middot; ~30.44 days/month &middot; 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 &mdash;
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>

View File

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