38686-vm/core/templates/core/report.html
Konrad du Plessis 1d224bc01b fix(templates): convert 8 broken multi-line {# #} comments + clarify cryptic sublines
CLAUDE.md gotcha #1 strikes again — the dashboard audit pass added
7 multi-line {# ... #} comment blocks across index.html, report.html,
and pdf/report_pdf.html. All rendered as literal text on the live
pages (Konrad screenshotted them). Also caught an old one in
admin/base_site.html that was technically broken syntax but
non-rendering (outside any block). All 8 converted to
{% comment %}{% endcomment %}.

CLAUDE.md updated:
- Bumped the bit-us count (4 → confirmed 4 + 5 + 7 across three
  features). Added a grep-one-liner sanity check that finds broken
  multi-line {# blocks across all templates so future passes can
  spot-check before committing.

Cryptic hero-card sublines on /report/ clarified (Konrad asked
what they mean):
- "as of 08:13" → "Live total at 08:13 today · for <scope>" with
  hover tooltip explaining the snapshot semantics.
- "Company Avg / Working Day" / "/ Month" labels renamed to
  "Avg Labour Cost / Working Day" / "/ Month". Sublines simplified
  to "Lifetime average across all crews" / "Daily figure × 30.44
  days". Both gain hover tooltips that explain the math and the
  "current pay rates" basis.

Pure template + docs change. 173/173 tests still passing
(no test changes — these are cosmetic fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:23:20 +02:00

923 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% load static %}
{% load format_tags %}
{% block title %}Payroll Report | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4">
<!-- === REPORT HEADER === -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center mb-4 d-print-none">
<div>
<h1 class="page-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Payroll Report</h1>
<p class="mb-0" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
&nbsp;|&nbsp; {{ project_name }} &nbsp;|&nbsp; {{ team_name }}
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
{# "New Report" removed — the inline filter pills below ARE the #}
{# new-report interface now. Date/project/team pills edit in-place. #}
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
<i class="fas fa-download me-1"></i>Download PDF
</a>
<a href="{% url 'home' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Dashboard
</a>
</div>
</div>
{# === FILTER PILLS (interactive — pill-as-dropdown) === #}
{# Each pill is a clickable button that opens an inline popover with the #}
{# relevant editor. The Apply button appears only when at least one pill #}
{# has uncommitted changes. See the JS module lower in this file. #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none" id="filter-pill-strip">
{# --- Date pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-date"
data-filter="date"
aria-expanded="false"
aria-controls="popover-date">
<i class="fas fa-calendar me-1"></i>
<span class="filter-pill__label">{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="popover-date" role="dialog" aria-label="Edit date range" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Date Selection</label>
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeMonth" value="month" checked>
<label class="btn btn-outline-secondary btn-sm" for="popDateModeMonth">
<i class="fas fa-calendar-alt me-1"></i>Month(s)
</label>
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeCustom" value="custom">
<label class="btn btn-outline-secondary btn-sm" for="popDateModeCustom">
<i class="fas fa-calendar-week me-1"></i>Custom Dates
</label>
</div>
{# --- Month-mode pickers --- #}
{# Visual order follows English reading: "From (optional) ... Until". #}
{# "Until" is the anchor — always filled, defaults to the URL's #}
{# to_month or the current YYYY-MM when there's no filter. #}
{# "From" is optional — blank means a single-month report and the #}
{# JS submits from_month = to_month. #}
<div class="row g-2" id="popoverMonthFields">
<div class="col-6">
<label class="form-label small">
From
<span class="text-muted" style="font-size: 0.6rem; letter-spacing: 0;">(optional)</span>
</label>
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
<div class="form-text small"
style="font-size: 0.72rem; color: var(--text-tertiary);">
Leave blank for a single month
</div>
</div>
<div class="col-6">
{# Tooltip on the info icon avoids the inline hint #}
{# wrapping to two lines inside a narrow column. #}
{# Global Bootstrap tooltip initialiser (base.html) #}
{# picks up data-bs-toggle="tooltip" automatically. #}
<label class="form-label small">
Until
<i class="fas fa-info-circle ms-1"
data-bs-toggle="tooltip"
title="Single month select"
style="color: var(--text-tertiary); font-size: 0.85em; cursor: help;"></i>
</label>
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
</div>
</div>
<div class="row g-2 d-none" id="popoverCustomFields">
<div class="col-6">
<label class="form-label small">Start</label>
<input type="date" id="popoverStartDate" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">End</label>
<input type="date" id="popoverEndDate" class="form-control form-control-sm">
</div>
</div>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Projects pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-projects"
data-filter="projects"
aria-expanded="false"
aria-controls="popover-projects">
<i class="fas fa-folder me-1"></i>
<span class="filter-pill__label">{{ project_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}"
class="filter-pill__x"
aria-label="Clear project filter"
title="Clear project filter">&times;</a>
{% endif %}
<div class="filter-popover" id="popover-projects" role="dialog" aria-label="Edit projects" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Projects</label>
<select id="popoverProjects" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
{% for p in projects %}
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# --- Teams pill --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable"
id="filter-pill-teams"
data-filter="teams"
aria-expanded="false"
aria-controls="popover-teams">
<i class="fas fa-users me-1"></i>
<span class="filter-pill__label">{{ team_name }}</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}"
class="filter-pill__x"
aria-label="Clear team filter"
title="Clear team filter">&times;</a>
{% endif %}
<div class="filter-popover" id="popover-teams" role="dialog" aria-label="Edit teams" hidden>
<div class="filter-popover__body">
<label class="form-label fw-semibold small">Teams</label>
<select id="popoverTeams" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
{% for t in teams %}
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="filter-popover__footer">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
</div>
{# No global Apply button — each popover's OK commits + reloads directly. #}
</div>
{# --- Cross-filter data for the JS module --- #}
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
{# --- Expose current URL filter state so the cross-filter can disable #}
{# dropdown options that are invalid given the OTHER pill's selection. #}
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
<!-- === PRINT HEADER === -->
<div class="d-none d-print-block mb-4">
<h2 class="text-center fw-bold mb-1">FoxFitt Construction &mdash; Payroll Report</h2>
<p class="text-center mb-0" style="font-size: 0.9rem;">
{{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
&nbsp;|&nbsp; {{ project_name }} &nbsp;|&nbsp; {{ team_name }}
</p>
</div>
{# === HERO KPI BAND === #}
{% comment %}
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.
{% endcomment %}
<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" }} &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"
data-bs-toggle="tooltip"
title="Live total: unpaid wages plus any pending payroll adjustments. Refresh the page to see the latest.">
Live total at {{ current_as_of|date:"H:i" }} today
{% if project_name != 'All Projects' or team_name != 'All Teams' %}
&middot; for {{ 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">Avg Labour Cost / Working Day</div>
<div class="stat-value">R {{ company_avg_daily|money }}</div>
<div class="stat-subline"
data-bs-toggle="tooltip"
title="Sum of daily rates across all crews on every day FoxFitt has logged work, divided by the count of those days. Uses each worker's CURRENT salary — a recent raise will retroactively change this figure.">
Lifetime average across all crews
</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">Avg Labour Cost / Month</div>
<div class="stat-value">R {{ company_avg_monthly|money }}</div>
<div class="stat-subline"
data-bs-toggle="tooltip"
title="The daily figure to the left multiplied by 30.44 (the average number of days in a month, i.e. 365.25 ÷ 12). Same lifetime + current-pay-rates basis.">
Daily figure &times; 30.44 days
</div>
</div>
</div>
</div>
{# === CHAPTER I — Lifetime Context === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">I</span>Lifetime Context</h5>
<div class="row g-3 mb-4">
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>All Time &mdash; Projects</h6>
</div>
<div class="card-body p-0">
{% if alltime_projects %}
<div class="table-responsive">
<table class="table table-sm mb-0 report-numeric">
<thead>
<tr>
<th>Project</th>
<th>Start</th>
<th>Last Activity</th>
<th class="text-end">Working Days</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Avg R / Working Day</th>
</tr>
</thead>
<tbody>
{% for item in alltime_projects %}
<tr>
<td class="fw-medium">{{ item.project }}</td>
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
<td class="text-end">{{ item.working_days|default:"—" }}</td>
<td class="text-end fw-semibold">R {{ item.total|money }}</td>
<td class="text-end">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No lifetime project data.</p>{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-users me-2" style="color: var(--accent);"></i>All Time &mdash; Teams</h6>
</div>
<div class="card-body p-0">
{% if alltime_teams %}
<div class="table-responsive">
<table class="table table-sm mb-0 report-numeric">
<thead><tr><th>Team</th><th class="text-end">Total Cost</th></tr></thead>
<tbody>
{% for item in alltime_teams %}
<tr><td>{{ item.team }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No lifetime team data.</p>{% endif %}
</div>
</div>
</div>
</div>
<!-- ===================================================================
SELECTED PERIOD — detailed breakdown
Summary cards (totals for the chosen date range) now live here
under the Selected Period heading, grouped as Loans pair and
Advances pair for quick scanning.
=================================================================== -->
<h5 class="chapter-heading mb-3"><span class="chapter-num">II</span>Selected Period: {{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</h5>
<!-- === SUMMARY CARDS — scoped to the selected period === -->
<!-- Order: Total Paid Out, Worker-Days, Loans pair, Advances pair -->
<div class="row g-3 mb-4">
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--danger h-100 p-3">
<div class="stat-label">
Total Paid Out{% if total_paid_filter_caveat %}<span data-bs-toggle="tooltip" title="Includes the FULL amount of every payment whose work-log touches the selected projects/teams. Payments that span both in-scope and out-of-scope projects are over-counted here. The Labour Cost by Project table below is the right place for strict per-project totals.">&nbsp;<i class="fas fa-asterisk" style="font-size: 0.55rem; color: var(--accent);"></i></span>{% endif %}
</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ total_paid_out|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--info h-100 p-3">
<div class="stat-label">Worker-Days</div>
<div class="stat-value" style="font-size: 1.1rem;">{{ total_worker_days }}</div>
</div>
</div>
<!-- Loans pair (issued, then outstanding) -->
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="stat-label">Loans Issued</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ loans_issued|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="stat-label">Loans Outstanding</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ loans_outstanding|money }}</div>
</div>
</div>
<!-- Advances pair (issued, then outstanding) -->
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="stat-label">Advances Issued</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ advances_issued|money }}</div>
</div>
</div>
<div class="col-lg-2 col-md-4 col-6">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="stat-label">Advances Outstanding</div>
<div class="stat-value" style="font-size: 1.1rem;">R {{ advances_outstanding|money }}</div>
</div>
</div>
</div>
<!-- Payments by Date + Adjustment Summary -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-calendar-day me-2" style="color: var(--accent);"></i>Payments by Date</h6>
</div>
<div class="card-body p-0">
{% if payments_by_date %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th class="text-end">Amount Paid</th></tr></thead>
<tbody>
{% for item in payments_by_date %}
<tr><td>{{ item.date|date:"d M Y" }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No payments in this period.</p>{% endif %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-sliders-h me-2" style="color: var(--accent);"></i>Adjustment Summary</h6>
</div>
<div class="card-body p-0">
{% if adjustment_totals %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Category</th><th class="text-end">Total</th></tr></thead>
<tbody>
{% comment %}
Sign-aware rendering: deductive types show as red "-R ...";
additive types stay in default colour with no sign. Finding 4.
{% endcomment %}
{% for item in adjustment_totals %}
<tr>
<td>{{ item.label }}</td>
<td class="text-end fw-semibold {% if item.is_deductive %}text-danger{% endif %}">
{% if item.is_deductive %}&minus;{% endif %}R {{ item.total|money_abs }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No adjustments in this period.</p>{% endif %}
</div>
</div>
</div>
</div>
<!-- Labour Cost by Project + by Team (selected period) -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Labour Cost by Project</h6>
</div>
<div class="card-body p-0">
{% 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" 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>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No project cost data.</p>{% endif %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Labour Cost by Team</h6>
</div>
<div class="card-body p-0">
{% 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" 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>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No team cost data.</p>{% endif %}
</div>
</div>
</div>
</div>
{# === CHAPTER III — Worker Breakdown === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">III</span>Worker Breakdown</h5>
<!-- Worker Breakdown -->
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-user-friends me-2" style="color: var(--accent);"></i>Worker Breakdown</h6>
</div>
<div class="card-body p-0">
{% if worker_breakdown %}
<div class="table-responsive">
<table class="table table-sm mb-0 report-numeric">
<thead>
<tr>
<th>Worker</th>
<th class="text-end">Days</th>
<th class="text-end">Total Paid</th>
{% comment %}
Sign-aware headers: deductive types render in muted red
so the negative sign on rows below is unmistakable. Finding 4.
{% endcomment %}
{% for h in active_adj_headers %}
<th class="text-end d-none d-md-table-cell {% if h.is_deductive %}text-danger{% endif %}" style="font-size: 0.75rem;">{{ h.label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for w in worker_breakdown %}
<tr>
<td class="fw-medium">{{ w.name }}</td>
<td class="text-end">{{ w.days }}</td>
<td class="text-end fw-semibold">R {{ w.total_paid|money }}</td>
{% comment %}
Each val is now an "amount" Decimal plus an "is_deductive" bool
— render the amount in red with a leading minus sign for deductive types.
{% endcomment %}
{% for val in w.adj_values %}
<td class="text-end d-none d-md-table-cell {% if val.is_deductive and val.amount %}text-danger{% endif %}" style="font-size: 0.8rem;">
{% if val.amount %}
{% if val.is_deductive %}&minus;{% endif %}R {{ val.amount|money_abs }}
{% else %}-{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %}
Footnote — explains why Days and Total Paid can read as
"not matching" within a single period (different timing).
{% endcomment %}
<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>
{# === CHAPTER IV — Team × Project Activity === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">IV</span>Team &times; Project Activity</h5>
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold"><i class="fas fa-th me-2" style="color: var(--accent);"></i>Distinct Work Days per Team &times; Project</h6>
</div>
<div class="card-body p-0">
{% if team_project_activity.rows %}
<div class="table-responsive">
<table class="table table-sm mb-0 report-numeric">
<thead>
<tr>
<th>Team</th>
{% for col in team_project_activity.columns %}
<th class="text-end">{{ col.name }}</th>
{% endfor %}
<th class="text-end fw-bold">Total</th>
</tr>
</thead>
<tbody>
{% for row in team_project_activity.rows %}
<tr>
<td class="fw-medium">{{ row.team_name }}</td>
{% for col in team_project_activity.columns %}
<td class="text-end">
{% with days=row.cells_by_project_id|dictlookup:col.id %}
{% if days %}{{ days }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}
{% endwith %}
</td>
{% endfor %}
<td class="text-end fw-semibold">{{ row.row_total }}</td>
</tr>
{% endfor %}
<tr class="table-total-row">
<td class="fw-bold">Total</td>
{% for col in team_project_activity.columns %}
<td class="text-end fw-bold">{{ team_project_activity.col_totals|dictlookup:col.id }}</td>
{% endfor %}
<td class="text-end fw-bold">{{ team_project_activity.grand_total }}</td>
</tr>
</tbody>
</table>
</div>
{% else %}<p class="text-muted text-center py-3 mb-0">No team &times; project activity in this period.</p>{% endif %}
</div>
</div>
<!-- Bottom Action Bar -->
<div class="d-flex justify-content-between align-items-center d-print-none">
<a href="{% url 'home' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back to Dashboard</a>
<div class="d-flex gap-2">
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent"><i class="fas fa-download me-1"></i>Download PDF</a>
</div>
</div>
</div>
{# === CHOICES.JS CDN — admin-only === #}
{# The pill popovers enhance their <select multiple> elements with #}
{# Choices.js. Falls back to a native multi-select if the CDN fails. #}
{% if user.is_staff or user.is_superuser %}
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
crossorigin="anonymous"
defer></script>
{% endif %}
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
{# Scoped IIFE; runs once on DOMContentLoaded. #}
{# #}
{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #}
{# (keeping other filters intact) and navigates → full SSR page reload. #}
{# Cancel just closes the popover. No "dirty" state, no global Apply. #}
{# #}
{# XSS-safe: textContent only; we never write user strings via innerHTML. #}
{% if user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Bail gracefully if Choices.js failed to load ---
if (typeof Choices === 'undefined') {
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
return;
}
// === CONTEXT from json_script tags ===
var pairsEl = document.getElementById('projectTeamPairs');
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
function toIntArr(s) {
return (JSON.parse(s || '[]') || [])
.map(function(v) { return parseInt(v, 10); })
.filter(function(n) { return !isNaN(n); });
}
// URL state used only for cross-filter option-disabling on popover open.
// (No pending-state diffing because OK always submits immediately.)
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
// --- Cross-filter lookup indices ---
var projectToTeams = {}, teamToProjects = {};
pairs.forEach(function(pair) {
var pid = pair.project_id, tid = pair.team_id;
if (!projectToTeams[pid]) projectToTeams[pid] = new Set();
if (!teamToProjects[tid]) teamToProjects[tid] = new Set();
projectToTeams[pid].add(tid);
teamToProjects[tid].add(pid);
});
// --- Choices.js instances (lazy init on first open) ---
var projectsChoices = null, teamsChoices = null;
// --- DOM refs ---
var pills = {
date: document.getElementById('filter-pill-date'),
projects: document.getElementById('filter-pill-projects'),
teams: document.getElementById('filter-pill-teams'),
};
var popovers = {
date: document.getElementById('popover-date'),
projects: document.getElementById('popover-projects'),
teams: document.getElementById('popover-teams'),
};
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// === INITIALISE DATE INPUTS from URL ===
var qs = new URLSearchParams(window.location.search);
var urlFromMonth = qs.get('from_month') || '';
var urlToMonth = qs.get('to_month') || '';
var urlStartDate = qs.get('start_date') || '';
var urlEndDate = qs.get('end_date') || '';
// "Until" is the anchor — always filled. Falls back to current month
// (YYYY-MM) when the URL has no to_month (e.g. no filters yet).
function currentYearMonth() {
var d = new Date();
var m = String(d.getMonth() + 1).padStart(2, '0');
return d.getFullYear() + '-' + m;
}
toMonthInput.value = urlToMonth || currentYearMonth();
// "From" is optional: blank signals single-month mode. Range URLs
// (from != to) fill it; single-month URLs (from == to) leave it blank.
fromMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlFromMonth;
startDateInput.value = urlStartDate;
endDateInput.value = urlEndDate;
var initialDateMode = (urlStartDate || urlEndDate) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
// === POPOVER OPEN / CLOSE ===
function closeAllPopovers(except) {
Object.keys(popovers).forEach(function(key) {
if (key !== except) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
}
});
}
function openPopover(key) {
closeAllPopovers(key);
popovers[key].hidden = false;
pills[key].setAttribute('aria-expanded', 'true');
// Lazy-init Choices.js the first time each multi-select is shown
if (key === 'projects' && !projectsChoices) {
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (key === 'teams' && !teamsChoices) {
teamsChoices = new Choices(document.getElementById('popoverTeams'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
// Cross-filter: disable options that are invalid given the OTHER pill's
// current URL selection. (e.g., open Teams with Project X in URL → hide
// teams that never logged on Project X.)
if (key === 'projects') applyCrossFilter('projects');
if (key === 'teams') applyCrossFilter('teams');
// Auto-open the Choices.js dropdown so options are visible
// immediately — the pill click means "show me the list." Without
// this, the user has to click the search input first, which feels
// like an extra step. Deferred one tick so it runs AFTER any
// cross-filter destroy/recreate above has settled.
if (key === 'projects' && projectsChoices) {
setTimeout(function() { projectsChoices.showDropdown(true); }, 0);
}
if (key === 'teams' && teamsChoices) {
setTimeout(function() { teamsChoices.showDropdown(true); }, 0);
}
}
// --- Pill click: toggle popover ---
Object.keys(pills).forEach(function(key) {
pills[key].addEventListener('click', function(ev) {
ev.stopPropagation();
var isOpen = !popovers[key].hidden;
if (isOpen) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
} else {
openPopover(key);
}
});
});
// Click outside the pill group closes all popovers
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
});
// Esc closes popovers
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllPopovers();
});
// === POPOVER OK / CANCEL HANDLERS ===
// OK submits immediately via navigation. Cancel just closes the popover;
// any Choices.js changes the user made are reset to URL state so the next
// open starts fresh.
document.querySelectorAll('.filter-popover').forEach(function(pop) {
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') submitDateFilter();
else if (pop.id === 'popover-projects') submitProjectsFilter();
else if (pop.id === 'popover-teams') submitTeamsFilter();
// Navigation happens inside submit functions — nothing else to do.
});
cancelBtn.addEventListener('click', function() {
// Reset Choices.js widgets to URL state in case the user had
// selected something. Date inputs reset on the next open via
// URL reload (no in-page state to revert otherwise).
if (pop.id === 'popover-projects' && projectsChoices) {
rebuildChoicesSelection(projectsChoices, urlProjects);
}
if (pop.id === 'popover-teams' && teamsChoices) {
rebuildChoicesSelection(teamsChoices, urlTeams);
}
closeAllPopovers();
});
});
// === DATE MODE TOGGLE inside the date popover ===
document.getElementById('popDateModeMonth').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.remove('d-none');
document.getElementById('popoverCustomFields').classList.add('d-none');
});
document.getElementById('popDateModeCustom').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.add('d-none');
document.getElementById('popoverCustomFields').classList.remove('d-none');
});
// === SUBMIT HANDLERS ===
// Each rebuilds the URL using the current popover's inputs (keeping the
// other filters intact) and navigates → full SSR page reload. Matches
// the original modal's contract; the report re-renders server-side.
function submitDateFilter() {
var params = new URLSearchParams(window.location.search);
// Clear all date-family params (current + legacy modal-form params)
params.delete('from_month');
params.delete('to_month');
params.delete('start_date');
params.delete('end_date');
params.delete('date_mode');
params.delete('search_terms');
var isCustom = document.getElementById('popDateModeCustom').checked;
if (isCustom) {
if (startDateInput.value) params.set('start_date', startDateInput.value);
if (endDateInput.value) params.set('end_date', endDateInput.value);
} else {
// Month mode: "Until" is the anchor (always required; defaults
// to current month in the picker); "From" is optional (blank =
// single-month, so use Until for both ends).
var to = toMonthInput.value || currentYearMonth();
var from = fromMonthInput.value || to;
if (from) params.set('from_month', from);
if (to) params.set('to_month', to);
}
navigateTo(params);
}
function submitProjectsFilter() {
if (!projectsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('project');
projectsChoices.getValue(true).forEach(function(id) {
params.append('project', id);
});
navigateTo(params);
}
function submitTeamsFilter() {
if (!teamsChoices) { closeAllPopovers(); return; }
var params = new URLSearchParams(window.location.search);
params.delete('team');
teamsChoices.getValue(true).forEach(function(id) {
params.append('team', id);
});
navigateTo(params);
}
function navigateTo(params) {
window.location = window.location.pathname + '?' + params.toString();
}
// === CROSS-FILTER ===
// Read-time only: when a popover opens, disable options that are invalid
// given the OTHER pill's current URL selection. Since each OK submits to
// URL directly, we don't need runtime auto-removal or pending-state sync.
function applyCrossFilter(justOpened) {
if (justOpened === 'projects' && projectsChoices) {
if (urlTeams.length === 0) return; // no constraint
var validPids = new Set();
urlTeams.forEach(function(tid) {
if (teamToProjects[tid]) {
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
}
});
var sel = document.getElementById('popoverProjects');
Array.from(sel.options).forEach(function(opt) {
var pid = parseInt(opt.value, 10);
// Always leave currently-URL-selected items enabled so they
// remain visible as removable chips — user can unpick them.
opt.disabled = !validPids.has(pid) && !urlProjects.includes(pid);
});
projectsChoices.destroy();
projectsChoices = new Choices(sel, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (justOpened === 'teams' && teamsChoices) {
if (urlProjects.length === 0) return;
var validTids = new Set();
urlProjects.forEach(function(pid) {
if (projectToTeams[pid]) {
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
}
});
var selT = document.getElementById('popoverTeams');
Array.from(selT.options).forEach(function(opt) {
var tid = parseInt(opt.value, 10);
opt.disabled = !validTids.has(tid) && !urlTeams.includes(tid);
});
teamsChoices.destroy();
teamsChoices = new Choices(selT, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
}
// --- Helper: rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
}
});
</script>
{% endif %}
{% endblock %}