Konrad's Checkpoint-1 feedback: 'Inside the all time projects table, can we have a column with the last transaction date for a project? It will make it easier to find data for projects. It is nice to have the filter, but you can still skip around looking for when the last transaction was.' Added a 'last_activity' entry to each alltime_projects row in _build_report_context — computed as max(WorkLog.date) grouped by project name (respects the same project_ids/team_ids filters already applied to all_time_logs). Rendered in both the on-screen report (report.html) and the PDF (report_pdf.html) as a new 'Last Activity' column sitting between 'Start' and 'Working Days'. Existing ChapterOneEnrichmentTests extended with a last_activity assertion locking in the 'most recent log date' semantics. No other tests touched. 47/47 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
839 lines
40 KiB
HTML
839 lines
40 KiB
HTML
{% 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" }} — {{ end_date|date:"d M Y" }}
|
||
| {{ project_name }} | {{ 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" }} – {{ 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; opacity: 0.75;">
|
||
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">×</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">×</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 — Payroll Report</h2>
|
||
<p class="text-center mb-0" style="font-size: 0.9rem;">
|
||
{{ start_date|date:"d M Y" }} — {{ end_date|date:"d M Y" }}
|
||
| {{ project_name }} | {{ team_name }}
|
||
</p>
|
||
</div>
|
||
|
||
{# === HERO KPI BAND === #}
|
||
<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>
|
||
</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>
|
||
</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-value">R {{ company_avg_daily|money }}</div>
|
||
<div class="stat-subline">lifetime avg · {{ company_working_days }} working days</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-value">R {{ company_avg_monthly|money }}</div>
|
||
<div class="stat-subline">lifetime avg · ~30.44 days/month</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 — 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">—</span>{% endif %}</td>
|
||
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span class="text-muted">—</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">—</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 — 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" }} – {{ 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</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>
|
||
{% for item in adjustment_totals %}
|
||
<tr><td>{{ item.label }}</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 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">Total Cost</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">Total Cost</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>
|
||
{% for label in active_adj_labels %}
|
||
<th class="text-end d-none d-md-table-cell" style="font-size: 0.75rem;">{{ 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>
|
||
{% for val in w.adj_values %}
|
||
<td class="text-end d-none d-md-table-cell" style="font-size: 0.8rem;">{% if val %}R {{ val|money }}{% else %}-{% endif %}</td>
|
||
{% endfor %}
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% 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 × 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 × 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">—</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 × 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');
|
||
}
|
||
|
||
// --- 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 %}
|