Report: Chapter IV — Team × Project Activity pivot

Final chapter of the executive redesign. Renders the
team_project_activity context as a pivot: rows=teams, columns=projects,
cell=COUNT(DISTINCT work-log dates). Zero cells show em-dashes in muted
grey (not '0') so non-zero cells stand out. Row totals, column totals,
and grand total on the bottom row.

Adds a tiny dictlookup template filter (format_tags.py) — Django
templates can't index a dict by a dynamic variable key, and the pivot
cell lookup is cells_by_project_id[col.id]. Defensive None + TypeError
guards so a malformed context can't 500 the page.

.table-total-row CSS: 2px top border + inset background for the footer
row so totals visually separate from the data rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 23:51:46 +02:00
parent 89c42d25a3
commit fe85c9d7fd
3 changed files with 76 additions and 0 deletions

View File

@ -333,6 +333,53 @@
</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>

View File

@ -25,3 +25,20 @@ def money(value):
# Python's :, format gives comma separators — swap commas for spaces
formatted = f"{num:,.2f}"
return formatted.replace(",", " ")
@register.filter
def dictlookup(d, key):
"""Look up a dict value by a dynamic key.
Plain-English: in Django templates, `{{ mydict.foo }}` only works when
`foo` is a literal key. For `mydict[col.id]` with a variable key you need
a filter this one. Returns None if the key is missing or the input
isn't a dict.
"""
if d is None:
return None
try:
return d.get(key)
except (AttributeError, TypeError):
return None

View File

@ -1721,3 +1721,15 @@ body, .card, .modal-content, .form-control, .form-select,
.report-numeric th {
font-variant-numeric: tabular-nums;
}
/* === Pivot-table footer totals (Chapter IV) === */
/*
Bold, slightly-lifted row at the bottom of the Team × Project pivot
that holds the column totals + grand total. The 2px top border
visually separates totals from the data rows; the inset background
is the same --bg-inset used by other "slightly raised" surfaces.
*/
.table-total-row td {
border-top: 2px solid var(--border-default) !important;
background: var(--bg-inset);
}