38686-vm/core/templates/core/report.html
Konrad du Plessis f6975bfb2f feat(report): 'Last Activity' column in All Time Projects table
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>
2026-04-23 13:30:56 +02:00

839 lines
40 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; 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">&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 === #}
<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>
</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 &middot; {{ 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 &middot; ~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 &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</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 &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');
}
// --- 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 %}