Compare commits

...

21 Commits

Author SHA1 Message Date
Konrad du Plessis
3dab09cea3 Docs: mark Executive Report v2 as shipped (23 Apr 2026)
QA summary:
- 42/42 tests pass
- manage.py check clean
- No pending migrations
- Route sanity: /report/, /report/?project=1&project=2, /report/pdf/ all
  resolve (302 as anon, 200 as admin)
- PDF generation verified for populated and empty date ranges

Appends a "Shipped" block to the design doc that captures the final
QA state, the deferred items, and the notable design decisions made
during implementation. Konrad's inline-filter UX improvement (raised
during Checkpoint 3) is explicitly flagged for a future brainstorm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:18:48 +02:00
Konrad du Plessis
a27da90c58 Report PDF: mirror the executive redesign (hero band + 4 chapters)
PDF template updated to match the new HTML structure: cover block
with static filter labels, hero KPI band (4 stacked 2x2), Chapter I
lifetime (Projects + Teams full-width, Projects now with Start /
Working Days / Avg-R-per-Working-Day columns), Chapter II selected
period (existing Total Paid Out hero + Loans/Advances pairs +
Labour Cost + Payments/Adjustments), Chapter III worker breakdown
(heading renamed), Chapter IV team x project pivot (new).

THIS YEAR section dropped per design doc section 3 (redundant with
All Time + Selected Period).

Same _build_report_context helper so HTML and PDF cannot drift in
data. All numbers identical. WeasyPrint-friendly: absolute units,
single-column body, no Font Awesome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:12:56 +02:00
Konrad du Plessis
fe85c9d7fd 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>
2026-04-22 23:51:46 +02:00
Konrad du Plessis
89c42d25a3 Report: Chapter III heading + tabular-nums on worker breakdown table
Adds the numbered 'III' chapter heading above the existing Worker
Breakdown card (the widest table in the report). Promotes the table
to .report-numeric (tabular-nums) for perfect column alignment
across dynamic adjustment columns — Inter's tabular-nums variant
keeps the rand amounts pixel-aligned.

No data or structural changes to the breakdown itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:47:44 +02:00
Konrad du Plessis
68c9afd939 Report: Chapter I (lifetime context) + chapter numbering
Replaces the old narrow four-card All-Time/YTD row (dropped in Task 9)
with two wider cards under a numbered 'Chapter I - Lifetime Context'
heading. Projects card gains Start, Working Days, Total Cost, and
Avg R / Working Day columns per the design. Teams card keeps name +
total.

Adds .chapter-heading and .chapter-num CSS for the orange numbered
markers (I, II, III, IV) and .report-numeric class that applies
tabular-nums across the number columns of every report table.

Renames the existing 'Selected Period' heading to Chapter II.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:43:14 +02:00
Konrad du Plessis
9632214f99 Report: hero KPI band (4 cards) replacing All-Time/YTD row
Chapter 0 of the executive redesign: four large cards at the top
showing Paid This Period, Outstanding Now (live, stamped with the
generation time), FoxFitt Avg/Day, and FoxFitt Avg/Month.

Drops the old four-small-cards All-Time/YTD row (YTD specifically
documented as redundant per design doc section 3). All-Time detail
moves into Chapter I in the next task.

New .stat-card--hero variant uses Poppins 1.85rem for the number,
uppercase tracked labels, subtle tertiary sub-lines. tabular-nums
keeps the R-amounts pixel-aligned across cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:37:39 +02:00
Konrad du Plessis
8ea8955b30 Fix Choices.js theme specificity — chain .choices + !important
The b0d3829 overrides lost the cascade battle: the Choices.js CDN CSS
loads AFTER custom.css (inside the modal partial near </body>), so the
CDN's same-specificity rules won by load order. Dropdown still showed
white background + light-grey text in dark mode.

Fix: chain the root `.choices` class to every override (specificity
0,2,0 → 0,3,0) and add !important to color + background + border
properties that Choices.js hardcodes most aggressively. Now the
theme tokens always win regardless of load order.

Visual effect: dropdown option hover state now matches the selected
"Month(s)" button aesthetic (--bg-card-hover subtle lift with
--text-primary text) per Konrad's feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:34:06 +02:00
Konrad du Plessis
b0d382987b Theme Choices.js to match app dark/light themes
Code review of Task 7 showed Choices.js shipping with white-bg +
light-grey-text defaults, which were unreadable in dark mode and
clashed with the app's premium aesthetic. The design doc section 10
scoped these overrides into the CSS work but they hadn't been written.

Adds ~70 lines to custom.css re-themeing every .choices__ selector to
use existing design tokens (--bg-card, --bg-inset, --text-primary,
--accent, --border-default, etc.). No hardcoded colours; both :root
and :root.light themes work automatically.

Key visual changes:
- Dropdown popup: dark-card background with shadow
- Options: primary text colour, accent hover highlight
- Selected chips: orange accent pill with white text
- Focus ring: 0.15rem accent glow on the input

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:23:38 +02:00
Konrad du Plessis
ea481bfbf4 Report: filter-pill strip with × to clear individual filters
Three pills under the header: date range, project(s), team(s). Shows
comma-joined names when multi-valued (project_name in context is
already a comma-joined string from Task 6). × buttons on the project
and team pills remove just that filter via a rebuilt querystring;
the calendar pill has no × (date range is required).

Helper context keys query_string_without_project / _without_team do
the rebuild in the view via QueryDict.setlist so multi-value keys
are properly stripped (pop() only removes the first occurrence).

Pill CSS uses existing design tokens (--bg-inset, --accent,
--text-primary, --border-default, --text-tertiary, --bg-card-hover)
so dark and light themes work without overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:14:48 +02:00
Konrad du Plessis
702bba10ed Add SRI hashes to Choices.js CDN tags for consistency with Bootstrap
Code review (on 748c7c7) flagged that Bootstrap CDN tags in base.html
use integrity=sha384-... + crossorigin=anonymous, but the Choices.js
tags added in Task 7 did not. Since both are admin-only privileged
contexts and Bootstrap sets the precedent, Choices.js should match.

Hashes computed from cdn.jsdelivr.net/npm/choices.js@10.2.0 via
  curl ... | openssl dgst -sha384 -binary | openssl base64

No behavior change when the CDN is healthy; defense against a
compromised CDN serving altered bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:11:38 +02:00
Konrad du Plessis
748c7c79d7 Modal: multi-select projects and teams via Choices.js
Replaces the two single <select> elements in the report config modal
with <select multiple> enhanced by Choices.js (CDN 10.2.0, admin-only
gated, graceful fallback to native on CDN failure).

Removes the 'All Projects' / 'All Teams' placeholder option rows —
empty selection = all, matching Choices.js convention.

Persists selected values across submissions via two new context keys
(selected_project_ids, selected_team_ids) threaded through index() and
generate_report().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:05:00 +02:00
Konrad du Plessis
16d192d5fc Refactor _build_report_context signature to multi-value filters
project_id/team_id become project_ids/team_ids (list[int] or None).
Every internal filter uses the __in lookup; M2M filters use the
id__in subquery pattern documented in CLAUDE.md's Django ORM gotcha.
generate_report and generate_report_pdf switch to request.GET.getlist.
Old URL ?project=1 still works - getlist returns a single-element list.

Return dict gains six hero-KPI keys: current_outstanding, current_as_of,
company_avg_daily, company_avg_monthly, company_working_days,
team_project_activity - ready for the template restructure in Tasks 9-12.

Tests: 3 new multi-filter tests; existing inflation tests updated to the
new kwarg names. 42 total, all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:53:16 +02:00
Konrad du Plessis
ea1e4bdbcb Enrich alltime_projects context with working_days + avg_per_working_day
Chapter I of the executive report needs per-project working-day count and
avg rand per working day. Instead of modifying the shared _get_labour_costs
helper (used by other sections with different column sets), enrich the
output INSIDE _build_report_context: wrap the raw result and add
working_days (distinct work-log dates per project) and avg_per_working_day
(total_cost / working_days, null-safe).

Also attaches start_date from the Project model (may be None if not set).

1 test added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:45:08 +02:00
Konrad du Plessis
e8ba2c6745 Add _team_project_activity helper + 4 tests
Chapter IV pivot backend: for each (team, project) pair in the given
work-logs queryset, counts distinct work-log dates. Returns columns
(projects), rows (teams with cell dict), column totals, and grand total
ready for direct template rendering.

Logs with NULL team or NULL project are excluded (can't pivot on NULL).
Teams/projects with zero activity don't appear as rows/columns — keeps
the pivot tight.

Tests cover shape, cell counts, row+column+grand totals, and
zero-activity team omission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:38:53 +02:00
Konrad du Plessis
ccc44a8d51 Fix _current_outstanding_in_scope sort + tighten team-filter test
Two tweaks from code review on 82594fa:

1. The sort `key=lambda r: -r['amount']` placed NEGATIVE amounts
   (rare but possible: a project with only a deductive adjustment)
   AHEAD of larger positive exposures. Swapped to
   `key=lambda r: r['amount'], reverse=True` — same runtime, clearer
   intent, correct for negatives.

2. test_team_filter_scopes_total only asserted the net total. A
   partial scoping regression where the adjustment leaked but netted
   to zero would have silently passed. Added two assertions that
   by_project has exactly the expected 2 entries and R 500 never
   appears in the amount list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:35:30 +02:00
Konrad du Plessis
82594faad7 Add _current_outstanding_in_scope helper + 3 tests
Hero KPI card 2 needs 'Outstanding NOW' scoped to the report's selected
projects/teams. This helper wraps _compute_outstanding, reshapes the
by_project dict into a sorted list, and exposes the net total for direct
rendering.

Tests cover unfiltered total, project-scoped total, and team-scoped
total (including the worker__teams subquery path for adjustments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:29:41 +02:00
Konrad du Plessis
e74f48f050 Add _company_cost_velocity helper + 3 tests
Computes company-wide avg daily and monthly labour cost for the
executive report's hero KPI band (cards 3 and 4). Denominator is
working days (distinct work-log dates), not calendar days — true
cost-of-a-productive-day metric per design section 2.

Monthly = daily * 30.44 (the 365.25/12 month-length approximation,
which keeps annualised totals correct on average).

Tests cover: empty DB returns zero, known values with assertAlmostEqual
for the 30.44 multiplication, and that multiple workers on one date
count as 1 working day (not N).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:20:14 +02:00
Konrad du Plessis
6be6a09056 Extract _compute_outstanding helper from index() (refactor)
Pure refactor: the ~45 lines of outstanding-payment math inside index()
(computing unpaid_wages + pending_adj_add - pending_adj_sub, with a
per-project breakdown) move into a standalone _compute_outstanding()
helper. index() now calls it with no arguments for unchanged behaviour.
The helper accepts optional project_ids / team_ids for Task 3.

No tests changed; 28/28 still pass. Dashboard Outstanding Payments
card shows the same value before and after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:11:58 +02:00
Konrad du Plessis
e2eb889a29 Plan: Executive Payroll Report v2 implementation
Task-by-task plan for the design committed at 27cdb46. 14 tasks with
4 hard-pause checkpoints at natural demo points:
  - After Task 6  (backend helpers done)
  - After Task 8  (multi-select modal + filter pills)
  - After Task 12 (full HTML layout — all 4 chapters)
  - After Task 14 (PDF mirrored + QA + shipped note)

Task 1 is a pure refactor (extract _compute_outstanding from index())
so later tasks can reuse the dashboard math with filters. Tasks 2-5
add the new helpers alongside existing code with failing-test-first
discipline. Task 6 switches the main helper to multi-value filters
(project_ids/team_ids) — existing behaviour preserved via backward-
compatible getlist. Tasks 7-12 restructure the HTML template into
Hero + 4 chapters. Task 13 mirrors in the PDF. Task 14 QAs and ships.

~11 new tests across 4 test classes; total grows from 28 to ~39.

One new dependency: Choices.js 10.2.0 via CDN, admin-only gated,
graceful fallback to native multi-select on CDN failure.

Follows the CLAUDE.md conventions: # === SECTION === comments,
plain-English docstrings, subquery-filter pattern for M2M filters,
single-batched push at the end, Co-Authored-By trailer on every
commit, never amend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:45:31 +02:00
Konrad du Plessis
27cdb46ec9 Design: Executive Payroll Report v2
Brainstorm output for rebuilding /report/ as an executive-grade dashboard.

Key decisions captured:
- Multi-select filters (Choices.js) with empty=all semantics
- Hero KPI band: Paid / Outstanding NOW / Avg R/day / Avg R/month
- Chapter I: Lifetime context with working-day denominator for avg cost
- Chapter II: Selected period (existing content, restructured)
- Chapter III: Worker breakdown (existing, restyled)
- Chapter IV: NEW team × project activity pivot

Current Outstanding reuses dashboard math (live, stamped with generation
time). Company cost velocity = lifetime cost / distinct work-log dates;
monthly = daily × 30.44.

No model changes. One new CDN dep (Choices.js). Target: ~650 LOC
including ~120 new tests. Four checkpoint pauses proposed for the
subsequent implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:38:26 +02:00
Konrad du Plessis
92036f7e4c Docs: update CLAUDE.md with session learnings
Five focused updates from the Apr 22-23 bug-fix + gitignore session:

1. Fix stale supervisor-picker queryset doc: it was showing the pre-fix
   Q(is_staff)|Q(is_superuser)|Q(groups__name='Work Logger') filter.
   Since commit 0ceceeb the queryset is just User.objects.filter(is_active=True).

2. Update "How to add a new supervisor" step 2: Work Logger group
   membership is no longer required for picker visibility — optional now.

3. Add "Schema name-drifts to remember" block near Key Models. Three
   recurring gotchas that burned four subagent tasks across two sessions:
   - PayrollAdjustment.description (not reason)
   - log.adjustments_by_work_log (not payrolladjustment_set)
   - log.overtime_amount (not log.overtime)

4. Add canonical test-command one-liner to the Commands section:
   USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2

5. Add "Django ORM gotcha" subsection documenting the M2M filter +
   values().annotate(Sum()) inflation bug and the id__in subquery fix
   pattern (refs commit f1e246c, ReportContextFilterInflationTests).

No code changes; no test impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:36:35 +02:00
10 changed files with 3620 additions and 436 deletions

View File

@ -59,6 +59,14 @@ staticfiles/ — Collected static assets (Bootstrap, admin) — NOT in git (
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
### Schema name-drifts to remember
Fields / accessors that differ from what you'd guess. Each has bitten multiple
sessions; grep `core/models.py` before using any field you haven't used before:
- `PayrollAdjustment.description` — NOT `reason`
- `log.adjustments_by_work_log` (reverse accessor for PayrollAdjustment.work_log FK) — NOT `payrolladjustment_set` (the FK has `related_name` set)
- `log.overtime_amount` (DecimalField, default 0.00) — NOT `log.overtime`
## Key Business Rules
- All business logic lives in the `core/` app — do not create additional Django apps
- Workers have a `daily_rate` property: `monthly_salary / Decimal('20.00')`
@ -75,6 +83,17 @@ Defined at top of views.py — used in dashboard calculations and payment proces
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay
## Django ORM gotcha — M2M filter + aggregate inflation
Chained `.filter(m2m__field=X).filter(m2m__other=Y)` creates **separate JOIN
aliases**, producing a cartesian product of rows. `.aggregate(Sum(...))` dedupes
via subquery when `distinct()` is present; `.values().annotate(Sum(...))` does
NOT — it `GROUP BY`s the inflated rows and multiplies sums by N×M (where N and
M are the counts of matching related rows). Fix pattern: use
`.filter(id__in=Model.objects.filter(m2m__field=X).values('id'))` to keep the
outer queryset JOIN-free. See `_build_report_context` in `core/views.py` and
`ReportContextFilterInflationTests` in `core/tests.py` for the reference
implementation (commit f1e246c, Apr 2026).
## PayrollAdjustment Type Handling
- **Bonus / Deduction** — standalone, require a linked Project
- **New Loan** — creates a `Loan` record (`loan_type='loan'`); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
@ -102,6 +121,9 @@ python manage.py setup_test_data # Populate sample workers, projects,
python manage.py import_production_data # Import real production data (14 workers)
python manage.py collectstatic # Collect static files for production
python manage.py check # System check
# Run the test suite (sets env vars inline — works in Git Bash; on cmd.exe use `set` first)
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
```
## Development Workflow
@ -420,13 +442,16 @@ When editing a Team or Project via the friendly UI (`/teams/<id>/edit/` or
`_supervisor_user_queryset()` in `core/forms.py`:
```python
User.objects.filter(is_active=True).filter(
Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger')
).distinct()
User.objects.filter(is_active=True).order_by('username')
```
So anyone who's either an admin OR a Work Logger shows up as an eligible
supervisor. Deactivated accounts (`is_active=False`) are hidden.
Any active user can be picked. The picker is deliberately NOT pre-filtered
by group/staff flags because `is_supervisor(user)` (views.py) grants
supervisor powers to anyone assigned to a team/project FK/M2M — so the
picker shouldn't be stricter than the permission model. Pre-Apr 2026 the
picker required Work Logger group membership, which hid valid supervisors
(see commit 0ceceeb for the fix + regression tests). Deactivated accounts
are still hidden.
### Typical user setups
@ -449,10 +474,13 @@ we'd have to add a separate flag or group check — not currently supported.
1. Go to `/admin/auth/user/add/` and create the user with a username and
password. **Uncheck "Staff status"** on the initial form (they don't need
Django admin access).
2. On the user's change page, add them to the **Work Logger** group.
2. (Optional) Add them to the **Work Logger** group if you want
`is_supervisor(user)` to return True even without a team/project
assignment. Not required for the picker to show them — the picker
shows any active user (see commit 0ceceeb, Apr 2026).
3. (Optional) Assign them as the supervisor of one or more teams via
`/teams/<id>/edit/` (Supervisor dropdown — they'll appear in the list
because of their Work Logger group membership).
because they're active).
4. (Optional) Add them to one or more projects via `/projects/<id>/edit/`
(Supervisors M2M checklist).
5. They can now log in at `/accounts/login/` and will land on the Dashboard

View File

@ -61,20 +61,18 @@
<!-- Project Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
<select name="project" class="form-select">
<option value="">All Projects</option>
<select name="project" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<!-- Team Filter (optional) -->
<div class="col-12">
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
<select name="team" class="form-select">
<option value="">All Teams</option>
<select name="team" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
@ -132,3 +130,31 @@
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
})();
</script>
{# === CHOICES.JS — multi-select enhancement (admin-only) === #}
{# Loaded CDN-only; falls back to native <select multiple> 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>
<script>
(function() {
document.addEventListener('DOMContentLoaded', function() {
if (typeof Choices === 'undefined') return; // graceful fallback
document.querySelectorAll('.report-multi').forEach(function(el) {
new Choices(el, {
removeItemButton: true,
shouldSort: false,
placeholder: true,
placeholderValue: el.getAttribute('data-placeholder') || '',
});
});
});
})();
</script>
{% endif %}

View File

@ -5,17 +5,14 @@
<style>
/* ==========================================================
PAGE SETUP
Standard A4 portrait with generous margins. WeasyPrint
understands @page rules for headers/footers; we use a
simple bottom-margin footer block instead of a running
element so the footer can reference `now` from context.
========================================================== */
@page {
size: a4 portrait;
margin: 2cm 1.8cm 1.6cm 1.8cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 0.6cm;
margin-left: 1.8cm;
margin-right: 1.8cm;
height: 0.8cm;
}
}
/* ==========================================================
@ -30,12 +27,14 @@
p { margin: 3pt 0; }
/* ==========================================================
COVER
COVER — brand eyebrow + orange title band + filter pills
The accent line colour was updated to the brand amber
(#e8851a) so the PDF matches the web app's orange accent.
========================================================== */
.brand-eyebrow {
font-size: 7.5pt;
font-weight: bold;
color: #10b981;
color: #e8851a;
letter-spacing: 3pt;
margin-bottom: 4pt;
}
@ -45,8 +44,8 @@
margin: 0;
}
table.cover-band td {
border-top: 1pt solid #10b981;
border-bottom: 1pt solid #10b981;
border-top: 1pt solid #e8851a;
border-bottom: 1pt solid #e8851a;
padding: 9pt 0;
vertical-align: middle;
}
@ -64,39 +63,53 @@
white-space: nowrap;
}
.cover-filters {
font-size: 10pt;
color: #64748b;
letter-spacing: 0.3pt;
margin: 4pt 0 14pt 0;
font-size: 9.5pt;
color: #475569;
letter-spacing: 0.2pt;
margin: 6pt 0 14pt 0;
}
/* ==========================================================
SECTION STRUCTURE
CHAPTER HEADINGS
Matches the HTML's "chapter-num I" style: a small filled
square-ish marker with the Roman numeral next to the
chapter title. No Font Awesome in PDFs — just text.
========================================================== */
.section {
margin-top: 16pt;
}
h2.section-title {
.chapter-heading {
margin: 16pt 0 8pt 0;
padding-bottom: 4pt;
border-bottom: 0.8pt solid #e8851a;
page-break-after: avoid;
}
.break-before {
page-break-before: always;
}
.eyebrow {
font-size: 7pt;
.chapter-num {
display: inline-block;
background-color: #e8851a;
color: #ffffff;
font-size: 9pt;
font-weight: bold;
color: #10b981;
letter-spacing: 2.5pt;
margin-bottom: 3pt;
padding: 2pt 7pt;
margin-right: 8pt;
letter-spacing: 0.5pt;
}
h2.section-title {
font-size: 13pt;
.chapter-title {
font-size: 14pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 10pt 0;
padding-bottom: 4pt;
border-bottom: 0.5pt solid #cbd5e1;
letter-spacing: 0.2pt;
}
/* ==========================================================
SECTION STRUCTURE — smaller sub-headings within a chapter
========================================================== */
.section { margin-top: 10pt; }
h2.section-title {
page-break-after: avoid;
font-size: 11pt;
font-weight: bold;
color: #0f172a;
margin: 10pt 0 6pt 0;
padding-bottom: 3pt;
border-bottom: 0.4pt solid #cbd5e1;
}
h3.sub-title {
font-size: 9pt;
@ -105,35 +118,121 @@
letter-spacing: 1pt;
margin: 8pt 0 3pt 0;
}
.break-before { page-break-before: always; }
.eyebrow {
font-size: 7pt;
font-weight: bold;
color: #e8851a;
letter-spacing: 2.5pt;
margin-bottom: 3pt;
}
/* ==========================================================
HERO CARD — 50% SMALLER
Halved the overall visual weight per feedback:
• padding dropped from 9pt → 4pt top/bottom
• hero-value dropped from 22pt → 14pt
• label/caption scaled down in proportion
Result: card is roughly half the height it was before.
HERO KPI BAND — 4 cards laid out 2x2 so they fit on the
portrait page without shrinking the big numbers. Each cell
is a mini stat card: small label, bold value, subline.
========================================================== */
table.kpi-band {
width: 100%;
border-collapse: separate;
border-spacing: 6pt;
margin: 2pt 0 10pt 0;
}
table.kpi-band td.kpi {
width: 50%;
background-color: #f8fafc;
border-left: 3pt solid #e8851a;
padding: 8pt 10pt;
vertical-align: top;
}
/* Colour-vary left border to echo the HTML hero tiles */
table.kpi-band td.kpi-danger { border-left-color: #dc2626; }
table.kpi-band td.kpi-warning { border-left-color: #d97706; }
table.kpi-band td.kpi-info { border-left-color: #0ea5e9; }
.kpi-label {
font-size: 7pt;
font-weight: bold;
color: #64748b;
letter-spacing: 1.8pt;
line-height: 1;
margin: 0;
text-transform: uppercase;
}
.kpi-value {
font-size: 16pt;
font-weight: bold;
color: #0f172a;
line-height: 1;
margin: 4pt 0 2pt 0;
}
.kpi-subline {
font-size: 7.5pt;
color: #64748b;
line-height: 1.2;
margin: 0;
}
/* ==========================================================
SMALLER STAT CARD GRID — 6 stats for Selected Period
Rendered as a 3x2 grid so the labels + numbers all fit on
the portrait page. Same border-colour trick as the hero.
========================================================== */
table.stat-grid {
width: 100%;
border-collapse: separate;
border-spacing: 5pt;
margin: 2pt 0 8pt 0;
}
table.stat-grid td.stat {
width: 33.33%;
background-color: #f8fafc;
border-left: 2.5pt solid #e8851a;
padding: 6pt 8pt;
vertical-align: top;
}
table.stat-grid td.stat-danger { border-left-color: #dc2626; }
table.stat-grid td.stat-warning { border-left-color: #d97706; }
table.stat-grid td.stat-success { border-left-color: #059669; }
table.stat-grid td.stat-info { border-left-color: #0ea5e9; }
.stat-label {
font-size: 6.5pt;
font-weight: bold;
color: #64748b;
letter-spacing: 1.2pt;
line-height: 1;
margin: 0;
text-transform: uppercase;
}
.stat-value {
font-size: 11pt;
font-weight: bold;
color: #0f172a;
line-height: 1;
margin: 3pt 0 0 0;
}
/* ==========================================================
HERO — legacy single-card block used by the small
"Total Paid Out" hero inside Chapter II (kept for the
SELECTED PERIOD section's existing design).
========================================================== */
table.hero {
width: 100%;
border-collapse: collapse;
margin: 4pt 0 14pt 0;
margin: 4pt 0 10pt 0;
}
table.hero td {
background-color: #f8fafc;
vertical-align: top;
}
table.hero td.hero-accent {
background-color: #10b981;
background-color: #e8851a;
width: 3pt;
padding: 0;
}
table.hero td.hero-body {
padding: 4pt 14pt;
}
/* Hero spacing is dominated by line-height, not margin.
line-height: 1 collapses the phantom "leading" above/below
the value glyphs → ~50% tighter gaps around "R 64 939.00". */
.hero-label {
font-size: 7pt;
font-weight: bold;
@ -157,10 +256,10 @@
}
/* ==========================================================
LEDGER LINES — with R-symbol aligned in its own column
Splitting the value cell into two cells (rsym + rnum) means
every "R" in a column appears at the same x-position, while
the numbers right-align neatly on their own edge.
LEDGER LINES — used in Chapter II for compact key/value
tables (Labour Cost, Payments by Date, Adjustments). The
split "rsym"/"rnum" trick keeps every R aligned in its
own column so the numbers right-align cleanly.
========================================================== */
table.ledger {
width: 100%;
@ -191,9 +290,6 @@
padding-right: 10pt;
width: 55pt;
}
/* The two cells that together form a money value.
rsym: left-aligned "R" anchored at a fixed x-position
rnum: right-aligned number, bold black */
table.ledger td.rsym {
text-align: left;
color: #0f172a;
@ -213,57 +309,56 @@
}
/* ==========================================================
TWO-COLUMN LAYOUT
LIFETIME PROJECTS — wider table with Start / Working
Days / Avg per Working Day columns. Not a ledger because
we need column headers.
========================================================== */
table.cols {
table.lifetime {
width: 100%;
border-collapse: collapse;
margin: 0;
margin-top: 4pt;
font-size: 8.5pt;
}
table.cols td {
vertical-align: top;
padding: 0;
table.lifetime th {
text-align: left;
font-size: 7pt;
font-weight: bold;
color: #64748b;
letter-spacing: 0.8pt;
padding: 4pt 5pt 5pt 5pt;
border-bottom: 1pt solid #0f172a;
white-space: nowrap;
text-transform: uppercase;
}
table.cols td.colL { width: 45%; }
table.cols td.gap { width: 10%; }
table.cols td.colR { width: 45%; }
/* Extra breathing room between the two rows of the Period
Breakdown section (Labour Cost row ⇢ Payments/Adjustments row) */
table.cols-spaced {
margin-top: 18pt;
table.lifetime th.r { text-align: right; }
table.lifetime td {
padding: 5pt;
border-bottom: 0.4pt solid #e2e8f0;
color: #334155;
vertical-align: middle;
}
table.lifetime td.name {
font-weight: bold;
color: #0f172a;
}
table.lifetime td.r {
text-align: right;
white-space: nowrap;
}
table.lifetime td.total {
font-weight: bold;
color: #0f172a;
text-align: right;
white-space: nowrap;
}
table.lifetime td.dim {
color: #cbd5e1;
text-align: right;
}
/* ==========================================================
PERIOD DETAIL — 15% smaller text in this section only
Scoped via the .period-detail wrapper so other sections keep
their normal size.
========================================================== */
.period-detail h3.sub-title {
font-size: 8pt; /* was 9pt */
}
.period-detail table.ledger td.lbl {
font-size: 8pt; /* was 9.5pt */
}
.period-detail table.ledger td.meta {
font-size: 7.5pt; /* was 8.5pt */
}
.period-detail table.ledger td.rsym,
.period-detail table.ledger td.rnum {
font-size: 8.5pt; /* was 10pt */
}
/* Use split padding-top/bottom (NOT the shorthand) so horizontal
padding defined on .meta and .rsym is preserved — otherwise the
shorthand clobbers it and you get "130 daysR" with no gap. */
.period-detail table.ledger td {
padding-top: 3pt;
padding-bottom: 3pt;
}
/* ==========================================================
WORKER BREAKDOWN TABLE
Money values inside use a nested mini-table so R and number
live in their own columns (same alignment trick as ledger).
WORKER BREAKDOWN TABLE — unchanged layout, now used for
Chapter III only.
========================================================== */
table.worker {
width: 100%;
@ -296,19 +391,88 @@
text-align: right;
white-space: nowrap;
}
/* Total Paid column: bolder, darker for emphasis */
table.worker td.total {
font-weight: bold;
color: #0f172a;
text-align: right;
white-space: nowrap;
}
/* Empty-value variant (em-dash) */
table.worker td.dim {
color: #cbd5e1;
text-align: right;
}
/* ==========================================================
PIVOT TABLE — Chapter IV Team x Project activity
========================================================== */
table.pivot {
width: 100%;
border-collapse: collapse;
margin-top: 4pt;
font-size: 8.5pt;
}
table.pivot th {
text-align: left;
font-size: 7pt;
font-weight: bold;
color: #64748b;
letter-spacing: 0.8pt;
padding: 4pt 5pt 5pt 5pt;
border-bottom: 1pt solid #0f172a;
white-space: nowrap;
text-transform: uppercase;
}
table.pivot th.r { text-align: right; }
table.pivot td {
padding: 4pt 5pt;
border-bottom: 0.4pt solid #e2e8f0;
color: #334155;
}
table.pivot td.name {
font-weight: bold;
color: #0f172a;
}
table.pivot td.r {
text-align: right;
white-space: nowrap;
}
table.pivot td.dim {
color: #cbd5e1;
text-align: right;
}
table.pivot td.total {
font-weight: bold;
color: #0f172a;
text-align: right;
white-space: nowrap;
}
table.pivot tr.total-row td {
border-top: 0.8pt solid #0f172a;
border-bottom: none;
font-weight: bold;
color: #0f172a;
background-color: #f8fafc;
}
/* ==========================================================
TWO-COLUMN LAYOUT (kept for the Chapter II sub-blocks)
========================================================== */
table.cols {
width: 100%;
border-collapse: collapse;
margin: 0;
}
table.cols td {
vertical-align: top;
padding: 0;
}
table.cols td.colL { width: 48%; }
table.cols td.gap { width: 4%; }
table.cols td.colR { width: 48%; }
table.cols-spaced {
margin-top: 12pt;
}
/* ==========================================================
MISC
========================================================== */
@ -317,13 +481,14 @@
font-size: 9pt;
padding: 5pt 0;
}
#footerContent {
.footer {
margin-top: 20pt;
padding-top: 6pt;
border-top: 0.3pt solid #e2e8f0;
font-size: 7pt;
color: #94a3b8;
text-align: center;
letter-spacing: 0.5pt;
border-top: 0.3pt solid #e2e8f0;
padding-top: 4pt;
}
</style>
</head>
@ -331,6 +496,11 @@
<!-- ==============================================================
COVER
Brand eyebrow + title band + static filter-pill line. The filter
line uses plain text (no x-buttons) because this is a static PDF.
project_name and team_name already arrive comma-joined from the
report-context helper — "All Projects" / "All Teams" when unset,
or "Wilkot Boerdery, Solar Farm Alpha" style for multi-select.
============================================================== -->
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
<table class="cover-band">
@ -339,168 +509,141 @@
<td class="cover-date">{{ start_date|date:"d F Y" }} &ndash; {{ end_date|date:"d F Y" }}</td>
</tr>
</table>
<div class="cover-filters">{{ project_name }} &nbsp;&bull;&nbsp; {{ team_name }}</div>
<!-- ==============================================================
ALL TIME
(All-time and year-to-date sections now appear FIRST, before the
Selected Period block — the big-picture lifetime view sets context
before we zoom in to the selected date range.)
============================================================== -->
<div class="section">
<div class="eyebrow">LIFETIME PERFORMANCE</div>
<h2 class="section-title">All Time &mdash; Labour Cost</h2>
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">BY PROJECT</h3>
{% if alltime_projects %}
<table class="ledger">
{% for item in alltime_projects %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.project }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No project data yet.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">BY TEAM</h3>
{% if alltime_teams %}
<table class="ledger">
{% for item in alltime_teams %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.team }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No team data yet.</p>{% endif %}
</td>
</tr>
</table>
<div class="cover-filters">
{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}
&nbsp;&middot;&nbsp; {{ project_name }}
&nbsp;&middot;&nbsp; {{ team_name }}
</div>
<!-- ==============================================================
THIS YEAR
HERO KPI BAND — 4 cards in a 2x2 grid
Shows the big-picture numbers up front:
1. Paid This Period (total_paid_out for the selected range)
2. Outstanding Now (what's currently unpaid across FoxFitt)
3. FoxFitt Avg / Day (lifetime company average)
4. FoxFitt Avg / Month (lifetime × ~30.44)
============================================================== -->
<div class="section">
<div class="eyebrow">YEAR-TO-DATE</div>
<h2 class="section-title">{{ current_year }} &mdash; Labour Cost</h2>
<table class="cols">
<table class="kpi-band">
<tr>
<td class="colL">
<h3 class="sub-title">BY PROJECT</h3>
{% if year_projects %}
<table class="ledger">
{% for item in year_projects %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.project }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No project data for {{ current_year }}.</p>{% endif %}
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">BY TEAM</h3>
{% if year_teams %}
<table class="ledger">
{% for item in year_teams %}
<tr>
<td class="rank">{{ forloop.counter }}</td>
<td class="lbl">{{ item.team }}</td>
<td class="rsym">R</td>
<td class="rnum">{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No team data for {{ current_year }}.</p>{% endif %}
</td>
<td class="kpi kpi-danger">
<div class="kpi-label">Paid This Period</div>
<div class="kpi-value">R {{ total_paid_out|money }}</div>
<div class="kpi-subline">{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</div>
</td>
<td class="kpi kpi-warning">
<div class="kpi-label">Outstanding Now</div>
<div class="kpi-value">R {{ current_outstanding.total|money }}</div>
<div class="kpi-subline">as of {{ current_as_of|date:"d M Y H:i" }}</div>
</td>
</tr>
</table>
</div>
<tr>
<td class="kpi kpi-info">
<div class="kpi-label">FoxFitt Avg / Day</div>
<div class="kpi-value">R {{ company_avg_daily|money }}</div>
<div class="kpi-subline">lifetime avg &middot; {{ company_working_days }} working days</div>
</td>
<td class="kpi kpi-info">
<div class="kpi-label">FoxFitt Avg / Month</div>
<div class="kpi-value">R {{ company_avg_monthly|money }}</div>
<div class="kpi-subline">lifetime avg &middot; ~30.44 days/month</div>
</td>
</tr>
</table>
<!-- ==============================================================
SELECTED PERIOD (new page, compact text via .period-detail)
All summary figures for the chosen date range live here:
- Hero: Total Paid Out (headline KPI)
- Loans pair (issued + outstanding)
- Advances pair (issued + outstanding)
- Labour Cost by project / team
- Payments by date / Adjustments
- Worker breakdown (next section, flows naturally)
The .period-detail wrapper shrinks ledger text 15%; the hero
card uses its own classes so its headline stays prominent.
CHAPTER I — Lifetime Context
All-time totals by project (with start date / working days /
avg per working day) and by team (simpler: name + total).
============================================================== -->
<div class="section break-before period-detail">
<div class="eyebrow">SELECTED PERIOD</div>
<h2 class="section-title">Period Breakdown</h2>
<div class="chapter-heading">
<span class="chapter-num">I</span><span class="chapter-title">Lifetime Context</span>
</div>
<!-- Hero: the headline KPI for this period -->
<table class="hero">
<h2 class="section-title">All Time &mdash; Projects</h2>
{% if alltime_projects %}
<table class="lifetime">
<tr>
<th>Project</th>
<th>Start</th>
<th class="r">Working Days</th>
<th class="r">Total Cost</th>
<th class="r">Avg R / Working Day</th>
</tr>
{% for item in alltime_projects %}
<tr>
<td class="name">{{ item.project }}</td>
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="r">{% if item.working_days %}{{ item.working_days }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="total">R&nbsp;{{ item.total|money }}</td>
<td class="r">{% if item.working_days %}R&nbsp;{{ item.avg_per_working_day|money }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No lifetime project data.</p>{% endif %}
<h2 class="section-title">All Time &mdash; Teams</h2>
{% if alltime_teams %}
<table class="lifetime">
<tr>
<th>Team</th>
<th class="r">Total Cost</th>
</tr>
{% for item in alltime_teams %}
<tr>
<td class="name">{{ item.team }}</td>
<td class="total">R&nbsp;{{ item.total|money }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="empty">No lifetime team data.</p>{% endif %}
<!-- ==============================================================
CHAPTER II — Selected Period
The detailed breakdown for the chosen date range. Starts on a
fresh page so lifetime context (Chapter I) doesn't crowd it.
Structure:
- 6 stat cards (Paid · Worker-Days · Loans×2 · Advances×2)
- Labour Cost by Project / by Team (side-by-side)
- Payments by Date / Adjustments (side-by-side)
============================================================== -->
<div class="break-before">
<div class="chapter-heading">
<span class="chapter-num">II</span><span class="chapter-title">Selected Period: {{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</span>
</div>
<!-- 6 stat cards in a 3x2 grid -->
<table class="stat-grid">
<tr>
<td class="hero-accent"></td>
<td class="hero-body">
<div class="hero-label">TOTAL PAID OUT</div>
<div class="hero-value">R {{ total_paid_out|money }}</div>
<div class="hero-caption">across {{ total_worker_days }} worker-days in this period</div>
<td class="stat stat-danger">
<div class="stat-label">Total Paid Out</div>
<div class="stat-value">R {{ total_paid_out|money }}</div>
</td>
<td class="stat stat-info">
<div class="stat-label">Worker-Days</div>
<div class="stat-value">{{ total_worker_days }}</div>
</td>
<td class="stat stat-success">
<div class="stat-label">Loans Issued</div>
<div class="stat-value">R {{ loans_issued|money }}</div>
</td>
</tr>
<tr>
<td class="stat stat-warning">
<div class="stat-label">Loans Outstanding</div>
<div class="stat-value">R {{ loans_outstanding|money }}</div>
</td>
<td class="stat stat-success">
<div class="stat-label">Advances Issued</div>
<div class="stat-value">R {{ advances_issued|money }}</div>
</td>
<td class="stat stat-warning">
<div class="stat-label">Advances Outstanding</div>
<div class="stat-value">R {{ advances_outstanding|money }}</div>
</td>
</tr>
</table>
<!-- Loans pair (left) + Advances pair (right) -->
<!-- Each column shows issued first, then outstanding — grouped
by instrument type for easier scanning. -->
<table class="cols">
<tr>
<td class="colL">
<h3 class="sub-title">LOANS</h3>
<table class="ledger">
<tr>
<td class="lbl">Loans issued</td>
<td class="rsym">R</td>
<td class="rnum">{{ loans_issued|money }}</td>
</tr>
<tr>
<td class="lbl">Loans outstanding</td>
<td class="rsym">R</td>
<td class="rnum">{{ loans_outstanding|money }}</td>
</tr>
</table>
</td>
<td class="gap"></td>
<td class="colR">
<h3 class="sub-title">ADVANCES</h3>
<table class="ledger">
<tr>
<td class="lbl">Advances issued</td>
<td class="rsym">R</td>
<td class="rnum">{{ advances_issued|money }}</td>
</tr>
<tr>
<td class="lbl">Advances outstanding</td>
<td class="rsym">R</td>
<td class="rnum">{{ advances_outstanding|money }}</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- First row: Labour Cost by project / team -->
<!-- Labour Cost by Project / by Team (side-by-side) -->
<table class="cols">
<tr>
<td class="colL">
@ -537,7 +680,7 @@
</tr>
</table>
<!-- Second row: extra top margin creates clear visual gap -->
<!-- Payments by Date / Adjustments (side-by-side) -->
<table class="cols cols-spaced">
<tr>
<td class="colL">
@ -574,13 +717,14 @@
</div>
<!-- ==============================================================
WORKER BREAKDOWN
Uses nested mini-tables inside each money cell so the R and the
number line up column-wise across every row.
CHAPTER III — Worker Breakdown
Per-worker detail: days worked, total paid, plus one column
per active adjustment type. Same table as before.
============================================================== -->
<div class="section">
<div class="eyebrow">PER-WORKER DETAIL</div>
<h2 class="section-title">Worker Breakdown</h2>
<div class="chapter-heading">
<span class="chapter-num">III</span><span class="chapter-title">Worker Breakdown</span>
</div>
{% if worker_breakdown %}
<table class="worker">
@ -612,10 +756,59 @@
{% endif %}
</div>
<!-- ==============================================================
CHAPTER IV — Team × Project Activity
Pivot table: rows = teams, columns = projects, cells = distinct
work days. The `dictlookup` filter (from core/templatetags/
format_tags.py) lets the template fetch cells by project id
from the `row.cells_by_project_id` dict.
============================================================== -->
<div class="section">
<div class="chapter-heading">
<span class="chapter-num">IV</span><span class="chapter-title">Team &times; Project Activity</span>
</div>
{% if team_project_activity.rows %}
<table class="pivot">
<tr>
<th>Team</th>
{% for col in team_project_activity.columns %}
<th class="r">{{ col.name }}</th>
{% endfor %}
<th class="r">Total</th>
</tr>
{% for row in team_project_activity.rows %}
<tr>
<td class="name">{{ row.team_name }}</td>
{% for col in team_project_activity.columns %}
{% with days=row.cells_by_project_id|dictlookup:col.id %}
{% if days %}
<td class="r">{{ days }}</td>
{% else %}
<td class="dim">&mdash;</td>
{% endif %}
{% endwith %}
{% endfor %}
<td class="total">{{ row.row_total }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td>Total</td>
{% for col in team_project_activity.columns %}
<td class="r">{{ team_project_activity.col_totals|dictlookup:col.id }}</td>
{% endfor %}
<td class="r">{{ team_project_activity.grand_total }}</td>
</tr>
</table>
{% else %}
<p class="empty">No team &times; project activity in this period.</p>
{% endif %}
</div>
<!-- ==============================================================
FOOTER
============================================================== -->
<div id="footerContent">
<div class="footer">
GENERATED {{ now|date:"d M Y H:i" }} &nbsp;&bull;&nbsp; FOXFITT CONSTRUCTION &nbsp;&bull;&nbsp; CONFIDENTIAL
</div>

View File

@ -30,6 +30,25 @@
</div>
</div>
{# === FILTER PILLS === #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
<span class="filter-pill">
<i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}
</span>
<span class="filter-pill">
<i class="fas fa-folder me-1"></i>{{ project_name }}
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">&times;</a>
{% endif %}
</span>
<span class="filter-pill">
<i class="fas fa-users me-1"></i>{{ team_name }}
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">&times;</a>
{% endif %}
</span>
</div>
<!-- === 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>
@ -39,88 +58,94 @@
</p>
</div>
<!-- ===================================================================
ALL TIME & THIS YEAR — big-picture context, now shown FIRST so
readers see the lifetime/YTD picture before the selected period.
=================================================================== -->
<div class="row g-3 mb-4">
<!-- All Time by Project -->
{# === 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-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time &mdash; Projects</h6>
<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 %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in alltime_projects %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
<div class="table-responsive">
<table class="table table-sm mb-0 report-numeric">
<thead>
<tr>
<th>Project</th>
<th>Start</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 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>
<!-- All Time by Team -->
<div class="col-lg-3 col-md-6">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time &mdash; Teams</h6>
<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 %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in alltime_teams %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
<!-- This Year by Project -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} &mdash; Projects</h6>
</div>
<div class="card-body p-0">
{% if year_projects %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in year_projects %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
</div>
</div>
</div>
<!-- This Year by Team -->
<div class="col-lg-3 col-md-6">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} &mdash; Teams</h6>
</div>
<div class="card-body p-0">
{% if year_teams %}
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
<tbody>
{% for item in year_teams %}
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
<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>
@ -132,9 +157,7 @@
under the Selected Period heading, grouped as Loans pair and
Advances pair for quick scanning.
=================================================================== -->
<h5 class="fw-bold mb-3" style="color: var(--text-primary);">
<i class="fas fa-filter me-2" style="color: var(--accent);"></i>Selected Period: {{ start_date|date:"d M Y" }} &mdash; {{ end_date|date:"d M Y" }}
</h5>
<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 -->
@ -271,6 +294,8 @@
</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">
@ -279,7 +304,7 @@
<div class="card-body p-0">
{% if worker_breakdown %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<table class="table table-sm mb-0 report-numeric">
<thead>
<tr>
<th>Worker</th>
@ -308,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

@ -368,28 +368,28 @@ class ReportContextFilterInflationTests(TestCase):
)
self.record.work_logs.add(*self.logs)
def _ctx(self, project_id=None, team_id=None):
def _ctx(self, project_ids=None, team_ids=None):
return _build_report_context(
datetime.date(2026, 3, 1),
datetime.date(2026, 3, 31),
project_id=project_id,
team_id=team_id,
project_ids=project_ids,
team_ids=team_ids,
)
def test_worker_breakdown_not_inflated_with_project_filter_only(self):
ctx = self._ctx(project_id=self.project.id)
ctx = self._ctx(project_ids=[self.project.id])
self.assertEqual(len(ctx['worker_breakdown']), 1)
# Pre-fix: this was 600 × 3 = 1800 (one JOIN, 3-way inflation).
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
def test_worker_breakdown_not_inflated_with_both_filters(self):
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
self.assertEqual(len(ctx['worker_breakdown']), 1)
# Pre-fix: this was 600 × 3 × 3 = 5400 (two JOINs, 9-way inflation).
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
def test_payments_by_date_not_inflated_with_both_filters(self):
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
payments = list(ctx['payments_by_date'])
self.assertEqual(len(payments), 1)
self.assertEqual(payments[0]['total'], Decimal('600.00'))
@ -398,7 +398,7 @@ class ReportContextFilterInflationTests(TestCase):
"""Regression guard: total_paid_out was ALREADY correct pre-fix
because .aggregate() handles distinct() via a subquery. Lock it in
so a future refactor doesn't accidentally reintroduce inflation here."""
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
self.assertEqual(ctx['total_paid_out'], Decimal('600.00'))
def test_adjustment_summary_not_inflated_with_team_filter(self):
@ -413,7 +413,7 @@ class ReportContextFilterInflationTests(TestCase):
date=datetime.date(2026, 3, 10),
description='Test bonus',
)
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
totals = {item['type']: item['total'] for item in ctx['adjustment_totals']}
self.assertEqual(totals.get('Bonus'), Decimal('100.00'))
@ -476,3 +476,276 @@ class SupervisorPickerQuerysetTests(TestCase):
username='an_admin', password='pass', is_staff=True
)
self.assertIn(admin, _supervisor_user_queryset())
# =============================================================================
# === TESTS FOR EXECUTIVE REPORT v2 ===
# Covers the new helpers introduced in the report rebuild (Apr 2026):
# _company_cost_velocity, _current_outstanding_in_scope, _team_project_activity,
# and the multi-filter extension of _build_report_context.
# =============================================================================
class CompanyCostVelocityTests(TestCase):
"""Company-wide avg daily and monthly labour cost (hero KPI card 3 & 4)."""
def test_empty_db_returns_zero(self):
from core.views import _company_cost_velocity
result = _company_cost_velocity()
self.assertEqual(result['avg_daily'], Decimal('0.00'))
self.assertEqual(result['avg_monthly'], Decimal('0.00'))
self.assertEqual(result['working_days'], 0)
def test_known_values(self):
from core.views import _company_cost_velocity
# Setup: 2 workers (daily_rate = 4000/20 = R 200 each), each works 5 distinct dates.
# Lifetime cost = 2 workers * 5 days * R 200 = R 2000. Working days = 5.
# Avg daily = 2000 / 5 = R 400.
# Avg monthly = 400 * 30.44 = R 12,176.
admin = User.objects.create_user(username='admin-cv', is_staff=True)
project = Project.objects.create(name='P')
w1 = Worker.objects.create(name='W1', id_number='W1', monthly_salary=Decimal('4000'))
w2 = Worker.objects.create(name='W2', id_number='W2', monthly_salary=Decimal('4000'))
for d in range(1, 6): # 5 distinct dates
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d),
project=project, supervisor=admin,
)
log.workers.add(w1, w2)
result = _company_cost_velocity()
self.assertEqual(result['working_days'], 5)
self.assertEqual(result['avg_daily'], Decimal('400.00'))
# Tolerance: ±1 cent for the 30.44 multiplication
self.assertAlmostEqual(
float(result['avg_monthly']), 12176.00, delta=0.01
)
def test_duplicate_dates_not_double_counted(self):
"""Two workers working the same date = 1 distinct date, not 2."""
from core.views import _company_cost_velocity
admin = User.objects.create_user(username='admin-cv2', is_staff=True)
project = Project.objects.create(name='P2')
w1 = Worker.objects.create(name='X', id_number='X1', monthly_salary=Decimal('4000'))
w2 = Worker.objects.create(name='Y', id_number='Y1', monthly_salary=Decimal('4000'))
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1), project=project, supervisor=admin,
)
log.workers.add(w1, w2)
result = _company_cost_velocity()
self.assertEqual(result['working_days'], 1) # not 2
class CurrentOutstandingInScopeTests(TestCase):
"""Hero card 2 — 'Outstanding NOW' with optional filter scope."""
def setUp(self):
self.admin = User.objects.create_user(username='a-out', is_staff=True)
self.p1 = Project.objects.create(name='ProjA')
self.p2 = Project.objects.create(name='ProjB')
self.t1 = Team.objects.create(name='TeamA', supervisor=self.admin)
self.w = Worker.objects.create(
name='Wkr', id_number='W1', monthly_salary=Decimal('4000')
)
self.t1.workers.add(self.w)
# Unpaid log on project 1
log1 = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=self.p1, team=self.t1, supervisor=self.admin,
)
log1.workers.add(self.w)
# Unpaid log on project 2
log2 = WorkLog.objects.create(
date=datetime.date(2026, 3, 2),
project=self.p2, team=self.t1, supervisor=self.admin,
)
log2.workers.add(self.w)
def test_no_filters_includes_all_projects(self):
from core.views import _current_outstanding_in_scope
result = _current_outstanding_in_scope()
# daily_rate = 4000/20 = 200; 2 unpaid logs * 200 = 400
self.assertEqual(result['total'], Decimal('400.00'))
self.assertEqual(len(result['by_project']), 2)
def test_project_filter_scopes_total(self):
from core.views import _current_outstanding_in_scope
result = _current_outstanding_in_scope(project_ids=[self.p1.id])
self.assertEqual(result['total'], Decimal('200.00'))
self.assertEqual(len(result['by_project']), 1)
self.assertEqual(result['by_project'][0]['name'], 'ProjA')
def test_team_filter_scopes_total(self):
"""Team filter on work logs + worker__teams on adjustments."""
from core.views import _current_outstanding_in_scope
# Adjustment on a worker not in t1
other_worker = Worker.objects.create(
name='Other', id_number='O1', monthly_salary=Decimal('4000')
)
PayrollAdjustment.objects.create(
worker=other_worker, project=self.p1, type='Bonus',
amount=Decimal('500.00'), date=datetime.date(2026, 3, 3),
)
# With team filter, only self.w's logs appear — R 400 total
result = _current_outstanding_in_scope(team_ids=[self.t1.id])
self.assertEqual(result['total'], Decimal('400.00'))
# The R500 bonus on other_worker must NOT appear in by_project because
# that worker isn't in t1 — the team scope excludes them entirely.
self.assertEqual(len(result['by_project']), 2)
amounts = [row['amount'] for row in result['by_project']]
self.assertNotIn(Decimal('500.00'), amounts)
class TeamProjectActivityTests(TestCase):
"""Chapter IV pivot: rows=team, columns=project, cell=distinct log dates."""
def setUp(self):
self.admin = User.objects.create_user(username='a-tpa', is_staff=True)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
# T1 works 3 distinct dates on P1
for d in (1, 2, 3):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d), project=self.p1, team=self.t1,
supervisor=self.admin,
)
log.workers.add(w)
# T2 works 2 distinct dates on P1 and 1 on P2
for d in (4, 5):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d), project=self.p1, team=self.t2,
supervisor=self.admin,
)
log.workers.add(w)
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 6), project=self.p2, team=self.t2,
supervisor=self.admin,
)
log.workers.add(w)
self.logs_qs = WorkLog.objects.filter(
date__gte=datetime.date(2026, 3, 1),
date__lte=datetime.date(2026, 3, 31),
)
def test_pivot_shape(self):
from core.views import _team_project_activity
r = _team_project_activity(self.logs_qs)
# 2 columns (P1, P2), 2 rows (T1, T2)
self.assertEqual(len(r['columns']), 2)
self.assertEqual(len(r['rows']), 2)
def test_cell_counts(self):
from core.views import _team_project_activity
r = _team_project_activity(self.logs_qs)
rows = {row['team_name']: row for row in r['rows']}
# T1 has 3 days on P1, 0 on P2
self.assertEqual(rows['T1']['cells_by_project_id'][self.p1.id], 3)
self.assertEqual(rows['T1']['cells_by_project_id'].get(self.p2.id, 0), 0)
# T2 has 2 days on P1, 1 on P2
self.assertEqual(rows['T2']['cells_by_project_id'][self.p1.id], 2)
self.assertEqual(rows['T2']['cells_by_project_id'][self.p2.id], 1)
def test_row_and_column_totals(self):
from core.views import _team_project_activity
r = _team_project_activity(self.logs_qs)
rows = {row['team_name']: row for row in r['rows']}
self.assertEqual(rows['T1']['row_total'], 3)
self.assertEqual(rows['T2']['row_total'], 3)
self.assertEqual(r['col_totals'][self.p1.id], 5)
self.assertEqual(r['col_totals'][self.p2.id], 1)
self.assertEqual(r['grand_total'], 6)
def test_team_with_no_logs_omitted(self):
"""Team with zero logs in the period should not appear as a row."""
from core.views import _team_project_activity
Team.objects.create(name='GhostTeam', supervisor=self.admin)
r = _team_project_activity(self.logs_qs)
team_names = [row['team_name'] for row in r['rows']]
self.assertNotIn('GhostTeam', team_names)
class ChapterOneEnrichmentTests(TestCase):
"""Chapter I — All Time Projects gains working_days and avg_per_working_day."""
def test_alltime_projects_includes_working_days_and_avg(self):
from core.views import _build_report_context
admin = User.objects.create_user(username='c1', is_staff=True)
proj = Project.objects.create(name='C1', start_date=datetime.date(2026, 1, 1))
w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
# 4 distinct dates, 1 worker each; daily_rate=200; total = R 800; working_days=4; avg=200
for d in (1, 2, 3, 4):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, d), project=proj, supervisor=admin,
)
log.workers.add(w)
ctx = _build_report_context(
datetime.date(2026, 1, 1), datetime.date(2026, 12, 31),
)
by_name = {p['project']: p for p in ctx['alltime_projects']}
self.assertIn('C1', by_name)
self.assertEqual(by_name['C1']['working_days'], 4)
self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))
# =============================================================================
# === TESTS FOR MULTI-VALUE FILTER SUPPORT (Task 6) ===
# _build_report_context now accepts project_ids and team_ids lists.
# =============================================================================
class ReportMultiFilterTests(TestCase):
"""Task 6 — multi-value project_ids / team_ids filters."""
def setUp(self):
self.admin = User.objects.create_user(username='mf', is_staff=True)
self.p1 = Project.objects.create(name='P1')
self.p2 = Project.objects.create(name='P2')
self.p3 = Project.objects.create(name='P3')
self.team = Team.objects.create(name='T', supervisor=self.admin)
self.w = Worker.objects.create(
name='W', id_number='W1', monthly_salary=Decimal('4000')
)
self.team.workers.add(self.w)
# One log + one paid record per project
for proj in (self.p1, self.p2, self.p3):
log = WorkLog.objects.create(
date=datetime.date(2026, 3, 1),
project=proj, team=self.team, supervisor=self.admin,
)
log.workers.add(self.w)
rec = PayrollRecord.objects.create(
worker=self.w, amount_paid=Decimal('100.00'),
date=datetime.date(2026, 3, 5),
)
rec.work_logs.add(log)
def _ctx(self, project_ids=None, team_ids=None):
from core.views import _build_report_context
return _build_report_context(
datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
project_ids=project_ids, team_ids=team_ids,
)
def test_multi_project_union(self):
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id])
# Two projects paid R 100 each = R 200; third excluded
self.assertEqual(ctx['total_paid_out'], Decimal('200.00'))
def test_empty_list_equals_none(self):
ctx_none = self._ctx(project_ids=None)
ctx_empty = self._ctx(project_ids=[])
self.assertEqual(ctx_none['total_paid_out'], ctx_empty['total_paid_out'])
def test_no_inflation_with_multi_project(self):
"""Worker breakdown must not inflate when multiple projects are selected."""
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id, self.p3.id])
self.assertEqual(len(ctx['worker_breakdown']), 1)
# All three records are for the same worker, R 100 each = R 300
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))

View File

@ -140,6 +140,210 @@ def get_pay_period(team, reference_date=None):
return (None, None)
# =============================================================================
# === OUTSTANDING PAYMENTS — SHARED HELPER ===
# Used by the home dashboard AND the payroll report. Computes:
# - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments)
# - unpaid_wages: Decimal (pure daily rates for unpaid workers)
# - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
# - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments)
# - outstanding_by_project: dict[str project_name -> Decimal amount]
#
# Accepts optional project_ids / team_ids filters. Empty list or None = no filter.
# =============================================================================
def _compute_outstanding(project_ids=None, team_ids=None):
"""Return current-moment outstanding payment breakdown.
Plain-English: for each work log that hasn't been fully paid, adds up
each unpaid worker's daily rate. Then adds unpaid additive adjustments
(bonuses, overtime, new loans, advances) and subtracts unpaid deductive
adjustments (deductions, loan/advance repayments). Results are the
"as of right now" snapshot shown on the home dashboard's Outstanding
Payments card. Optional filters scope the answer to specific projects
and/or teams.
"""
# --- Work logs in scope ---
work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records')
if project_ids:
work_logs = work_logs.filter(project_id__in=project_ids)
if team_ids:
work_logs = work_logs.filter(team_id__in=team_ids)
unpaid_wages = Decimal('0.00')
outstanding_by_project = {}
for wl in work_logs:
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
project_name = wl.project.name if wl.project else 'No Project'
for worker in wl.workers.all():
if worker.id not in paid_worker_ids:
cost = worker.daily_rate
unpaid_wages += cost
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
outstanding_by_project[project_name] += cost
# --- Unpaid adjustments in scope ---
adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project')
if project_ids:
adj_qs = adj_qs.filter(project_id__in=project_ids)
if team_ids:
# worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha)
adj_qs = adj_qs.filter(
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
)
pending_adj_add = Decimal('0.00')
pending_adj_sub = Decimal('0.00')
for adj in adj_qs:
project_name = adj.project.name if adj.project else 'No Project'
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
if adj.type in ADDITIVE_TYPES:
pending_adj_add += adj.amount
outstanding_by_project[project_name] += adj.amount
elif adj.type in DEDUCTIVE_TYPES:
pending_adj_sub += adj.amount
outstanding_by_project[project_name] -= adj.amount
outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub
return {
'outstanding_payments': outstanding_payments,
'unpaid_wages': unpaid_wages,
'pending_adj_add': pending_adj_add,
'pending_adj_sub': pending_adj_sub,
'outstanding_by_project': outstanding_by_project,
}
# =============================================================================
# === COMPANY COST VELOCITY ===
# Lifetime "what does a typical FoxFitt working day cost us?" metric.
# Denominator = COUNT(DISTINCT work_log.date) — true working days, not
# calendar days (rain days, weekends, permit delays don't dilute the rate).
# Used by the hero KPI band on the payroll report.
# =============================================================================
def _company_cost_velocity():
"""Return company-wide avg daily and monthly labour cost (lifetime)."""
# Total lifetime labour cost: sum of (worker.daily_rate) over every
# (log, worker) pair that has ever been logged.
total_cost = Decimal('0.00')
for wl in WorkLog.objects.prefetch_related('workers').all():
for worker in wl.workers.all():
total_cost += worker.daily_rate
# Distinct work-log dates = working days
working_days = WorkLog.objects.values('date').distinct().count()
if working_days == 0:
avg_daily = Decimal('0.00')
else:
avg_daily = (total_cost / working_days).quantize(Decimal('0.01'))
# 30.44 = 365.25 / 12 — standard month-length approximation.
# Keeps annualised totals correct on average.
avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01'))
return {
'avg_daily': avg_daily,
'avg_monthly': avg_monthly,
'working_days': working_days,
}
# =============================================================================
# === CURRENT OUTSTANDING — SCOPED FOR THE REPORT ===
# Thin wrapper around _compute_outstanding that shapes the output for
# the executive report's hero card 2. Includes a 'by_project' list
# sorted by amount desc, ready for direct template rendering.
# =============================================================================
def _current_outstanding_in_scope(project_ids=None, team_ids=None):
"""Return current outstanding payments, optionally scoped by project/team.
Calls _compute_outstanding and reshapes the by_project dict into a
list sorted by amount descending (for display). The 'total' field
is the net outstanding (unpaid wages + additive adjustments minus
deductive adjustments), matching the home dashboard card.
"""
raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
by_project_list = sorted(
[{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
key=lambda r: r['amount'],
reverse=True,
)
return {
'total': raw['outstanding_payments'],
'by_project': by_project_list,
}
# =============================================================================
# === TEAM × PROJECT ACTIVITY PIVOT ===
# Chapter IV of the executive report: "how many days did each team work
# on each project in this period?" Cell value = COUNT(DISTINCT work_log.date).
# Logs with no team (team IS NULL) are excluded — the pivot is meaningless
# without a team axis.
# =============================================================================
def _team_project_activity(work_logs_qs):
"""Return pivot data for team × project activity within a work-logs queryset.
Plain-English: for each team-project pair represented in the given
queryset, counts the number of distinct calendar dates the team worked
on that project. Rows and columns include only teams/projects that
actually appeared (zero-activity teams/projects aren't shown).
"""
# Narrow to logs that have both a team and a project (we can't pivot
# on NULL axes; also filters out the "No Project" ghost rows).
qs = work_logs_qs.filter(team__isnull=False, project__isnull=False)
# Aggregate: (team_id, project_id) -> distinct dates
rows_data = qs.values(
'team_id', 'team__name', 'project_id', 'project__name'
).annotate(days=Count('date', distinct=True)).order_by('team__name')
# Build column list (unique projects, ordered by name)
columns_seen = {}
for r in rows_data:
columns_seen.setdefault(r['project_id'], r['project__name'])
columns = [
{'id': pid, 'name': pname}
for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1])
]
# Build rows: team_id -> cells_by_project_id dict
rows_by_team = {} # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'}
col_totals = {col['id']: 0 for col in columns}
grand_total = 0
for r in rows_data:
tid = r['team_id']
pid = r['project_id']
days = r['days']
row = rows_by_team.setdefault(tid, {
'team_id': tid,
'team_name': r['team__name'],
'cells_by_project_id': {},
'row_total': 0,
})
row['cells_by_project_id'][pid] = days
row['row_total'] += days
col_totals[pid] += days
grand_total += days
# Ordered rows list (by team name)
rows = sorted(rows_by_team.values(), key=lambda r: r['team_name'])
return {
'columns': columns,
'rows': rows,
'col_totals': col_totals,
'grand_total': grand_total,
}
# === HOME DASHBOARD ===
# The main page users see after logging in. Shows different content
# depending on whether the user is an admin or supervisor.
@ -151,56 +355,15 @@ def index(request):
if is_admin(user):
# --- ADMIN DASHBOARD ---
# Calculate total value of unpaid work and break it down by project.
# A WorkLog is "unpaid for worker X" if no PayrollRecord links BOTH
# that log AND that worker. This handles partially-paid logs where
# some workers have been paid but others haven't.
all_worklogs = WorkLog.objects.select_related(
'project'
).prefetch_related('workers', 'payroll_records')
# === OUTSTANDING BREAKDOWN ===
# Track unpaid wages and adjustments separately so the dashboard
# can show a clear breakdown of what makes up the total.
unpaid_wages = Decimal('0.00') # Pure daily rates for unpaid workers
pending_adjustments_add = Decimal('0.00') # Unpaid additive adjustments (bonuses, overtime, etc.)
pending_adjustments_sub = Decimal('0.00') # Unpaid deductive adjustments (deductions, repayments)
outstanding_by_project = {}
for wl in all_worklogs:
# Get the set of worker IDs that have been paid for this log
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
project_name = wl.project.name
for worker in wl.workers.all():
if worker.id not in paid_worker_ids:
cost = worker.daily_rate
unpaid_wages += cost
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
outstanding_by_project[project_name] += cost
# Also include unpaid payroll adjustments (bonuses, deductions, etc.)
# Additive types (Bonus, Overtime, New Loan) increase outstanding.
# Deductive types (Deduction, Loan Repayment, Advance Repayment) decrease it.
unpaid_adjustments = PayrollAdjustment.objects.filter(
payroll_record__isnull=True
).select_related('project')
for adj in unpaid_adjustments:
project_name = adj.project.name if adj.project else 'No Project'
if project_name not in outstanding_by_project:
outstanding_by_project[project_name] = Decimal('0.00')
if adj.type in ADDITIVE_TYPES:
pending_adjustments_add += adj.amount
outstanding_by_project[project_name] += adj.amount
elif adj.type in DEDUCTIVE_TYPES:
pending_adjustments_sub += adj.amount
outstanding_by_project[project_name] -= adj.amount
# Net total = wages + additions - deductions (same result as before, just tracked separately)
outstanding_payments = unpaid_wages + pending_adjustments_add - pending_adjustments_sub
# Uses the shared _compute_outstanding helper so the dashboard and the
# payroll report can't drift. Unscoped (no filters) = whole company.
_o = _compute_outstanding()
outstanding_payments = _o['outstanding_payments']
unpaid_wages = _o['unpaid_wages']
pending_adjustments_add = _o['pending_adj_add']
pending_adjustments_sub = _o['pending_adj_sub']
outstanding_by_project = _o['outstanding_by_project']
# Sum total paid out in the last 60 days
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
@ -267,6 +430,9 @@ def index(request):
'certs_expired_count': certs_expired_count,
'certs_expiring_count': certs_expiring_count,
'certs_alert_total': certs_alert_total,
# Empty on the home dashboard — modal opens clean (no pre-selected filters)
'selected_project_ids': [],
'selected_team_ids': [],
}
return render(request, 'core/index.html', context)
@ -1864,11 +2030,16 @@ def _get_labour_costs(work_logs_qs, group_by_field, name_key):
]
def _build_report_context(start_date, end_date, project_id=None, team_id=None):
def _build_report_context(start_date, end_date, project_ids=None, team_ids=None):
"""
Compute all report data for the given date range and filters.
Returns a dictionary of totals, breakdowns, and worker-level data.
project_ids / team_ids are lists of ints (from request.GET.getlist).
None or [] are treated as "no filter" returning data for every project
or every team respectively. A single-element list like [3] reproduces
the old single-id behaviour (so old URLs like ?project=3 still work).
Key design decision: "Worker-Days" counts total worker×log entries
(not distinct calendar dates). This correlates correctly with cost
if 5 workers work 22 days, that's 110 worker-days, and
@ -1880,7 +2051,7 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
# --- PayrollRecords in range ---
#
# IMPORTANT — avoid M2M double-JOIN inflation:
# Chaining `.filter(work_logs__project_id=X).distinct().filter(work_logs__team_id=Y)`
# Chaining `.filter(work_logs__project_id__in=X).distinct().filter(work_logs__team_id__in=Y)`
# creates TWO separate JOIN aliases on core_payrollrecord_work_logs. Any
# later `.values().annotate(Sum())` then aggregates across the cartesian
# product of matching rows, inflating per-worker and per-date totals by
@ -1890,16 +2061,16 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
# use id__in subqueries to keep the outer queryset JOIN-free.
# See ReportContextFilterInflationTests for regression coverage.
records = PayrollRecord.objects.filter(date_filter)
if project_id:
if project_ids:
records = records.filter(
id__in=PayrollRecord.objects.filter(
work_logs__project_id=project_id
work_logs__project_id__in=project_ids
).values('id')
)
if team_id:
if team_ids:
records = records.filter(
id__in=PayrollRecord.objects.filter(
work_logs__team_id=team_id
work_logs__team_id__in=team_ids
).values('id')
)
@ -1914,23 +2085,23 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
)
# --- Adjustments in range ---
# project_id filters via an FK column (no JOIN inflation risk), but
# team_id goes through worker__teams M2M — apply the same subquery
# project_ids filters via an FK column (no JOIN inflation risk), but
# team_ids goes through worker__teams M2M — apply the same subquery
# pattern as above to keep adj_by_type's values().annotate(Sum()) safe.
adjustments = PayrollAdjustment.objects.filter(date_filter)
if project_id:
adjustments = adjustments.filter(project_id=project_id)
if team_id:
if project_ids:
adjustments = adjustments.filter(project_id__in=project_ids)
if team_ids:
adjustments = adjustments.filter(
worker__in=Worker.objects.filter(teams__id=team_id).values('id')
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
)
# --- Work Logs in range (for calculating actual labour cost) ---
work_logs_qs = WorkLog.objects.filter(date__gte=start_date, date__lte=end_date)
if project_id:
work_logs_qs = work_logs_qs.filter(project_id=project_id)
if team_id:
work_logs_qs = work_logs_qs.filter(team_id=team_id)
if project_ids:
work_logs_qs = work_logs_qs.filter(project_id__in=project_ids)
if team_ids:
work_logs_qs = work_logs_qs.filter(team_id__in=team_ids)
# Total worker-days across all work logs (counts M2M worker entries)
total_worker_days = work_logs_qs.aggregate(
@ -1947,11 +2118,40 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
# --- ALL TIME: project and team costs since the very first work log ---
all_time_logs = WorkLog.objects.all()
if project_id:
all_time_logs = all_time_logs.filter(project_id=project_id)
if team_id:
all_time_logs = all_time_logs.filter(team_id=team_id)
alltime_projects = _get_labour_costs(all_time_logs, 'project__name', 'project')
if project_ids:
all_time_logs = all_time_logs.filter(project_id__in=project_ids)
if team_ids:
all_time_logs = all_time_logs.filter(team_id__in=team_ids)
# === CHAPTER I — All Time Projects (enriched) ===
# Adds working_days and avg_per_working_day (the 2026-04-23 design).
# Can't just extend _get_labour_costs because that helper is used by
# other sections with different columns. Wrap it here instead.
alltime_projects_raw = _get_labour_costs(all_time_logs, 'project__name', 'project')
# Build a lookup of working_days per project (distinct work-log dates)
project_working_days = dict(
all_time_logs.filter(project__isnull=False)
.values('project_id', 'project__name')
.annotate(days=Count('date', distinct=True))
.values_list('project__name', 'days')
)
# Lookup project start_date from the Project model (authoritative source)
start_dates = dict(
Project.objects.values_list('name', 'start_date')
)
alltime_projects = []
for row in alltime_projects_raw:
name = row['project']
wdays = project_working_days.get(name, 0)
total = row['total'] or Decimal('0.00')
avg = (total / wdays).quantize(Decimal('0.01')) if wdays else Decimal('0.00')
alltime_projects.append({
'project': name,
'worker_days': row['worker_days'],
'total': total,
'start_date': start_dates.get(name), # may be None
'working_days': wdays,
'avg_per_working_day': avg,
})
alltime_teams = _get_labour_costs(
all_time_logs.filter(team__isnull=False), 'team__name', 'team'
)
@ -1961,19 +2161,24 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
year_start = datetime.date(current_year, 1, 1)
year_end = datetime.date(current_year, 12, 31)
year_logs = WorkLog.objects.filter(date__gte=year_start, date__lte=year_end)
if project_id:
year_logs = year_logs.filter(project_id=project_id)
if team_id:
year_logs = year_logs.filter(team_id=team_id)
if project_ids:
year_logs = year_logs.filter(project_id__in=project_ids)
if team_ids:
year_logs = year_logs.filter(team_id__in=team_ids)
year_projects = _get_labour_costs(year_logs, 'project__name', 'project')
year_teams = _get_labour_costs(
year_logs.filter(team__isnull=False), 'team__name', 'team'
)
# --- Loans & Advances Outstanding (current balances) ---
# team filter goes through worker__teams (M2M). Use the subquery pattern
# (CLAUDE.md Django ORM gotcha) so we don't pick up JOIN inflation on the
# aggregate.
active_loans = Loan.objects.filter(active=True, date__lte=end_date)
if team_id:
active_loans = active_loans.filter(worker__teams__id=team_id)
if team_ids:
active_loans = active_loans.filter(
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
)
loans_outstanding = active_loans.filter(loan_type='loan').aggregate(
total=Sum('remaining_balance'))['total'] or Decimal('0.00')
advances_outstanding = active_loans.filter(loan_type='advance').aggregate(
@ -1982,9 +2187,10 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
# --- Loans & Advances Issued This Period ---
loans_issued_qs = Loan.objects.filter(date_filter, loan_type='loan')
advances_issued_qs = Loan.objects.filter(date_filter, loan_type='advance')
if team_id:
loans_issued_qs = loans_issued_qs.filter(worker__teams__id=team_id)
advances_issued_qs = advances_issued_qs.filter(worker__teams__id=team_id)
if team_ids:
team_worker_ids = Worker.objects.filter(teams__id__in=team_ids).values('id')
loans_issued_qs = loans_issued_qs.filter(worker__in=team_worker_ids)
advances_issued_qs = advances_issued_qs.filter(worker__in=team_worker_ids)
loans_issued = loans_issued_qs.aggregate(
total=Sum('principal_amount'))['total'] or Decimal('0.00')
advances_issued = advances_issued_qs.aggregate(
@ -2046,11 +2252,26 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
'adj_values': adj_values,
})
# === Hero KPI band data (executive report v2) ===
# Small helpers that power the new hero band at the top of the report.
# Kept separate so the big return dict stays easy to scan.
_cv = _company_cost_velocity()
return {
'start_date': start_date,
'end_date': end_date,
'project_name': Project.objects.get(id=project_id).name if project_id else 'All Projects',
'team_name': Team.objects.get(id=team_id).name if team_id else 'All Teams',
'project_name': (
', '.join(
Project.objects.filter(id__in=project_ids).values_list('name', flat=True)
)
if project_ids else 'All Projects'
),
'team_name': (
', '.join(
Team.objects.filter(id__in=team_ids).values_list('name', flat=True)
)
if team_ids else 'All Teams'
),
# --- Summary ---
'total_paid_out': total_paid_out,
'total_worker_days': total_worker_days,
@ -2072,6 +2293,15 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
'active_adj_types': active_adj_types,
'active_adj_labels': active_adj_labels,
'worker_breakdown': worker_breakdown,
# --- Hero KPI band (executive report v2) ---
'current_outstanding': _current_outstanding_in_scope(
project_ids=project_ids, team_ids=team_ids
),
'current_as_of': timezone.now(),
'company_avg_daily': _cv['avg_daily'],
'company_avg_monthly': _cv['avg_monthly'],
'company_working_days': _cv['working_days'],
'team_project_activity': _team_project_activity(work_logs_qs),
}
@ -2134,21 +2364,44 @@ def generate_report(request):
# Parse dates — supports both "month" and "start_date/end_date" params
start_date, end_date = _parse_report_dates(request)
project_id = request.GET.get('project') or None
team_id = request.GET.get('team') or None
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
# Cast to ints; drop empties. None if list is empty (= "no filter").
def _ids(name):
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
project_ids = _ids('project') or None
team_ids = _ids('team') or None
if not start_date or not end_date:
messages.error(request, "Please select a month or provide start and end dates.")
return redirect('home')
# Build report data using shared helper
context = _build_report_context(start_date, end_date, project_id, team_id)
context = _build_report_context(
start_date, end_date,
project_ids=project_ids, team_ids=team_ids,
)
# Pass the raw query params so the "Download PDF" button can use them
context['query_string'] = request.GET.urlencode()
# === FILTER PILL CLEAR LINKS ===
# For the filter-pill x buttons: rebuild the querystring with one filter removed.
# QueryDict.pop() only removes the first occurrence, so for multi-value keys
# (e.g. project=1&project=2) we follow up with setlist(key, []) to strip them all.
def _qs_without(key):
qd = request.GET.copy()
qd.pop(key, None)
qd.setlist(key, [])
return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')
# Pass projects and teams so the "New Report" modal's dropdowns can
# populate (same lists the Dashboard modal uses)
context['projects'] = Project.objects.all().order_by('name')
context['teams'] = Team.objects.all().order_by('name')
# For the modal's <select multiple> pre-selection: stringify the IDs so
# the template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
# comparison works (Django templates compare strings to strings).
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
return render(request, 'core/report.html', context)
@ -2163,14 +2416,21 @@ def generate_report_pdf(request):
# Parse dates — same logic as the HTML view
start_date, end_date = _parse_report_dates(request)
project_id = request.GET.get('project') or None
team_id = request.GET.get('team') or None
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
# Cast to ints; drop empties. None if list is empty (= "no filter").
def _ids(name):
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
project_ids = _ids('project') or None
team_ids = _ids('team') or None
if not start_date or not end_date:
messages.error(request, "Please select a month or provide start and end dates.")
return redirect('home')
context = _build_report_context(start_date, end_date, project_id, team_id)
context = _build_report_context(
start_date, end_date,
project_ids=project_ids, team_ids=team_ids,
)
context['now'] = timezone.now()
pdf = render_to_pdf('core/pdf/report_pdf.html', context)

View File

@ -0,0 +1,295 @@
# Executive Payroll Report v2 — Design (23 Apr 2026)
## Goal
Rebuild the payroll report (`/report/`) as an **executive-grade dashboard**:
multi-select project and team filters, a live "Current Outstanding" KPI card,
company-wide cost-velocity metrics, a new team × project activity pivot,
and a re-organised layout that leads with the numbers a business owner reads
first. No model changes; no new dependencies beyond the Choices.js CDN.
## Who it's for
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no report access.
## Design shape at a glance
```
┌─ Header (title + filter pills) ──────────────────────────────────────┐
├─ HERO KPI BAND (4 big cards) ────────────────────────────────────────┤
│ Paid this period │ Outstanding NOW │ Avg R/day │ Avg R/month │
├─ Chapter I — Lifetime Context ───────────────────────────────────────┤
│ All Time Projects (name, start, working days, total, avg/wday) │
│ All Time Teams (name, working days, total) │
├─ Chapter II — Selected Period ───────────────────────────────────────┤
│ Summary stat cards (6: Paid, Worker-Days, Loans×2, Advances×2) │
│ Payments by Date | Adjustment Summary │
│ Labour Cost by Project | Labour Cost by Team │
├─ Chapter III — Worker Breakdown ─────────────────────────────────────┤
│ Wide table: worker, days, total paid, dynamic adjustment columns │
├─ Chapter IV — Team × Project Activity (NEW) ────────────────────────┤
│ Pivot: rows=team, columns=project, cell=distinct work-log dates │
└──────────────────────────────────────────────────────────────────────┘
```
## 1. Filters — multi-select
### UI
- `<select multiple>` elements for Project and Team, enhanced with [Choices.js](https://choices-js.github.io/Choices/) (CDN: `cdn.jsdelivr.net/npm/choices.js@10.2.0/...`).
- Removes native ugly multi-select, adds chip-style display, search-as-you-type.
- Empty selection = "all" (no explicit "All Projects" option row).
- Graceful fallback: if Choices.js fails to load, the native `<select multiple>` still works — degraded but functional.
### Semantics
- **Empty selection** → treated as "all"
- **Multiple values within one filter** → OR (`project_id IN (1, 2, 3)`)
- **Across Project × Team** → AND (`project_id IN (...) AND team_id IN (...)`)
### Backend signature change
```python
# Before
_build_report_context(start, end, project_id=None, team_id=None)
# After
_build_report_context(start, end, project_ids=None, team_ids=None)
# both accept list[int] | None; [] treated same as None (= "all")
```
### URL / QS compatibility
- `generate_report` and `generate_report_pdf` use `request.GET.getlist('project')` / `getlist('team')`.
- Multiple values in the querystring: `?project=1&project=2&team=3`.
- Old single-value URLs (`?project=1`) still resolve — `getlist` returns a one-element list.
### Filter-pill strip
Directly under the page header, a horizontal row of summary pills:
- `📅 Mar 2026 Apr 2026`
- `📁 Wilkot Boerdery, Solar Farm Alpha` (one pill lists all selected projects)
- `👥 Civils One` (same for teams)
Clickable × on each pill to remove that filter. In PDFs, pills render as static labels.
## 2. Hero KPI band (new)
Four large cards in one row, sitting directly under the header / filter strip.
| Card | Label | Value | Sub-line |
|---|---|---|---|
| 1 | PAID THIS PERIOD | `total_paid_out` (already computed) | `{start_date} {end_date}` |
| 2 | OUTSTANDING NOW | live dashboard math | `as of {now:HH:MM}` |
| 3 | FOXFITT AVG / DAY | `total_lifetime_cost / total_working_days` | `lifetime avg` |
| 4 | FOXFITT AVG / MONTH | daily × 30.44 | `lifetime avg` |
Typography: Poppins 32pt semibold for the number; Inter 10pt uppercase tracked +0.08em for the label; Inter 9pt `--text-tertiary` for the sub-line. `--accent` orange on the left vertical bar (existing `.stat-card` style, scaled up).
### Backend — new computed values
- **`current_outstanding`** — dict `{'total': Decimal, 'by_project': [{'name': str, 'amount': Decimal}, ...]}`. Reuses the dashboard math from `index()` (`unpaid_wages + pending_adj_add - pending_adj_sub`) but respects the report's project/team filters. Stamped with `current_as_of = timezone.now()`.
- **`company_avg_daily`** — `Decimal`. `total_lifetime_cost / company_working_days`. `company_working_days = WorkLog.objects.values('date').distinct().count()`.
- **`company_avg_monthly`** — `company_avg_daily * Decimal('30.44')`. The 30.44 is `365.25/12` — the standard month-length approximation (keeps annual totals correct on average).
These come from a new helper `_company_cost_velocity()` called from `_build_report_context`.
## 3. Chapter I — Lifetime Context
Two cards side-by-side, replacing the current 4 cramped cards.
### All Time — Projects (~60% width)
Columns: **Project** · **Start** · **Working Days** · **Total Cost** · **Avg R / Working Day**
- `Working Days` = `COUNT(DISTINCT work_log.date)` where `project_id = P` (this is your answer to 3a — working days, not calendar days, is the denominator)
- `Avg R / Working Day` = `total_cost / working_days` (null-safe; shows `—` if 0 working days)
- Ordered by total cost desc
- Honours the filter (empty filters = all projects; otherwise only selected projects)
### All Time — Teams (~40% width)
Columns: **Team** · **Working Days** · **Total Cost**
- `Working Days` = `COUNT(DISTINCT work_log.date)` where `team_id = T`
- Ordered by total cost desc
### Year context — removed
The current "This Year — Projects / Teams" pair of cards is dropped. The lifetime + selected-period pair already covers the two timeframes that matter; the YTD cards have always been redundant with either. YAGNI.
## 4. Chapter II — Selected Period
Keeps all existing content, restructured visually.
### Row A — Summary stat cards (6)
Same six cards as today (Total Paid Out · Worker-Days · Loans Issued · Loans Outstanding · Advances Issued · Advances Outstanding). Restyled to match the hero band aesthetic: slightly larger Poppins numbers, thin dividers. Laid out as a single row (6 columns on desktop; 3×2 on tablet; 2×3 on mobile).
### Row B — Payments by Date | Adjustment Summary
Two cards side by side, existing content.
### Row C — Labour Cost by Project | Labour Cost by Team
Two cards side by side, existing content.
No data changes here — just visual polish. Inter tabular-nums for all number columns (perfect right-alignment).
## 5. Chapter III — Worker Breakdown
Existing wide table, restyled. No structural change:
- One row per worker with `total_paid > 0`
- Columns: Worker · Days · Total Paid · {dynamic columns for each non-zero adjustment type}
- Ordered by total paid desc
- Typography: Inter tabular-nums for right-aligned number columns
## 6. Chapter IV — Team × Project Activity (NEW)
A pivot table showing how many days each team worked on each project **in the report's date range + filter scope**.
```
│ Wilkot │ Solar Alpha │ Solar Beta │ Total │
────────────────┼────────┼─────────────┼────────────┼───────┤
Civils One │ 87 │ — │ — │ 87 │
Team Alpha │ — │ 45 │ 12 │ 57 │
Team Bravo │ 3 │ 18 │ 41 │ 62 │
────────────────┼────────┼─────────────┼────────────┼───────┤
Total │ 90 │ 63 │ 53 │ 206 │
```
- **Rows**: teams with ≥1 work log in the period (honours team filter)
- **Columns**: projects with ≥1 work log in the period (honours project filter)
- **Cell**: `COUNT(DISTINCT work_log.date)` for that (team, project) pair
- **Row totals + column totals + grand total** rendered in bold
- Zero cells render as em-dash in `--text-tertiary` (not `0` — makes non-zero values stand out)
- Horizontally scrollable if > 6 project columns
### Backend
New context key `team_project_activity`:
```python
{
'columns': [{'id': 1, 'name': 'Wilkot Boerdery'}, ...], # projects in the period
'rows': [
{
'team_id': 3, 'team_name': 'Civils One',
'cells_by_project_id': {1: 87, 2: 0, 3: 0}, # count or 0
'row_total': 87,
},
...
],
'col_totals': {1: 90, 2: 63, 3: 53},
'grand_total': 206,
}
```
Computed via a dedicated `_team_project_activity(work_logs_qs)` helper.
## 7. Print / PDF layout
Mirror the HTML structure in `core/templates/core/pdf/report_pdf.html`:
- **Hero KPI band at the top** (cover-block style)
- **Filter pills as static labels** (no × buttons)
- **Single-column** for the body — PDFs look better sequential than side-by-side
- Same `@page` config: `A4 portrait; margin: 1.8cm`
- Same `_build_report_context` helper — HTML and PDF can't drift
## 8. Edge cases
| Case | Behaviour |
|---|---|
| No filters | Pills show "All Projects · All Teams"; all chapters show everything |
| Filter returns zero records | Zero-state banners per section; no 500 |
| Choices.js fails to load (CDN blocked) | Native `<select multiple>` still works, visually degraded |
| Project has no `start_date` | Start column shows `—`; working days still counted |
| Team with no work logs in the period | Row omitted from Chapter IV |
| Company has no work logs yet (fresh install) | `company_avg_daily` = 0; sub-line says "no data yet" |
| Phone viewport | Hero band stacks 2×2; side-by-side card rows stack vertically; tables horizontally scroll |
## 9. Testing
New test classes in `core/tests.py`:
### `ReportMultiFilterTests`
- `project_ids=[1, 2]` returns union of logs from both projects
- `project_ids=[]` equivalent to `None` (= all)
- `project_ids=[X] & team_ids=[Y]` intersects correctly
- Backward compat: old single-value URL (`?project=1`) parses fine via `getlist`
### `CurrentOutstandingInReportTests`
- No filters → report's `current_outstanding.total` == dashboard's outstanding total
- Project filter → only that project's outstanding amount appears
- Deactivated worker with unpaid logs → counted (matches dashboard)
### `TeamProjectActivityTests`
- Simple 2×2 pivot with known data
- Cell with no activity → key absent or 0
- Row totals and column totals match cell sums
- Grand total matches sum of cell values
- Filter honouring: project filter drops columns; team filter drops rows
### `CompanyCostVelocityTests`
- Avg daily = total lifetime cost / distinct work-log dates
- Monthly = daily × 30.44 (tolerance ±1 cent)
- Empty DB → avg daily = 0, no exception
### Existing `ReportContextFilterInflationTests`
Extended to cover multi-value filters — `project_ids=[A, B]` doesn't inflate the worker breakdown or payments_by_date. Locks in the subquery-filter pattern with `__in`.
**Expected test count after this feature**: 28 → 28 + ~10 = ~38.
## 10. Implementation shape (no code yet)
### Files to touch
- `core/views.py``_build_report_context` signature + new helpers (`_current_outstanding_in_scope`, `_company_cost_velocity`, `_team_project_activity`), `generate_report` + `generate_report_pdf` switch to `getlist`.
- `core/templates/core/_report_config_modal.html``multiple` attribute on both selects; Choices.js init.
- `core/templates/base.html` — Choices.js CDN `<script>` + `<link>` (admin-only gated).
- `core/templates/core/report.html` — chapter restructure, hero band, filter pills.
- `core/templates/core/pdf/report_pdf.html` — same structure, single-column.
- `static/css/custom.css` — hero-band styles; pill styles; Choices.js theme overrides (dark + light); tabular-nums on number columns.
- `core/tests.py` — new test classes.
### No changes
- Models / migrations — zero
- Existing `_get_labour_costs` helper — unchanged
- Existing `index()` / `payroll_dashboard` views — unchanged (we only READ their math, via a new extracted helper)
### Approximate size
- Backend: ~120 new lines across 3 helpers + minor refactor
- Templates: ~200 lines restructure + ~100 new for pivot + hero
- CSS: ~80 lines (hero, pills, Choices.js theme)
- JS: ~30 lines (Choices.js init + filter pill remove)
- Tests: ~120 lines across 4 new test classes
Total: ~650 lines, no new dependencies beyond Choices.js (CDN).
## 11. Out of scope (YAGNI)
Explicitly NOT in this pass. Revisit only if users ask:
- **No charts or sparklines** — text-and-table first. Chart.js is already loaded for the payroll dashboard so the door is open for future additions, but not now.
- **No "save report template" feature** — reports are URLs you can bookmark.
- **No email-this-report-to-X** — PDF download is sufficient.
- **No period-over-period comparison** (e.g. "vs. last month") — single-period only.
- **No chart-based team/project activity** — the pivot table covers it.
- **The YTD cards** (current "This Year — Projects / Teams") are dropped as redundant; if you miss them we can add back cheaply.
## 12. Next step
Hand off to `superpowers:writing-plans` to produce the task-by-task implementation plan with review checkpoints. Proposed checkpoint placement:
1. After backend helpers + tests (Chapter I + Hero numbers work)
2. After multi-select modal (Choices.js integrated, filter pills render)
3. After Chapter IV pivot + full HTML layout
4. After PDF template mirrors HTML
Four natural demo-able pauses. Similar cadence to the 2026-04-22 work-log-payroll-crosslink plan.
---
## Shipped — 23 Apr 2026
**Commits:** 27cdb46 (design) → Task 14 shipped commit.
**Plan:** `docs/plans/2026-04-23-executive-report-v2-plan.md`
**Tests:** 28 → 42 (14 new — 3 company-cost-velocity, 3 current-outstanding-in-scope, 4 team-project-activity, 1 chapter-one-enrichment, 3 multi-filter).
**QA outcome:** 42/42 tests pass. `manage.py check` clean. `makemigrations --dry-run` reports no changes. Multi-value filter URLs (`?project=1&project=2`) resolve correctly. PDF rendering verified for populated and empty date ranges.
**Deferred / out of scope (revisit if requested):**
- Charts / sparklines in any chapter (text-and-table only)
- Save-as-template feature
- Period-over-period comparison
- Dead `.hero-*` CSS block in PDF template (~40 lines, not referenced by new body)
- Consolidate near-duplicate PDF table classes (`.worker`, `.lifetime`, `.pivot`)
- Inline filters on report page (vs modal-popup) — **Konrad flagged this during Checkpoint 3 as the next UX improvement; slated for a separate brainstorm + design + plan after this ships**
**Notable design decisions made during implementation:**
- Task 1 extracted `_compute_outstanding` from `index()` as a pure refactor before any new work — zero behaviour change.
- M2M filter pattern from commit `f1e246c` (Apr 22 bug fix) extended cleanly to multi-value via `__in` lookups + `id__in` subqueries.
- Choices.js integrated via CDN with SRI hashes, graceful fallback to native `<select multiple>` on CDN failure, and custom CSS theme overrides matching the app's dark/light tokens.
- `dictlookup` template filter added to `format_tags.py` — general-purpose utility for dict[var-key] lookups in Django templates.
- PDF template swapped xhtml2pdf-era `@frame footer_frame` / `-pdf-*` rules for WeasyPrint-idiomatic plain `.footer` div.

File diff suppressed because it is too large Load Diff

View File

@ -1491,3 +1491,245 @@ body, .card, .modal-content, .form-control, .form-select,
.work-log-row:hover td {
background: var(--bg-card-hover);
}
/* === Report filter pills === */
.filter-pill {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
font-size: 0.825rem;
background: var(--bg-inset);
color: var(--text-primary);
border: 1px solid var(--border-default);
border-radius: 999px;
line-height: 1.2;
}
.filter-pill i {
color: var(--accent);
font-size: 0.75rem;
}
.filter-pill__x {
margin-left: 0.5rem;
padding: 0 0.35rem;
color: var(--text-tertiary);
text-decoration: none;
font-weight: 600;
border-radius: 50%;
transition: color 120ms, background-color 120ms;
}
.filter-pill__x:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
text-decoration: none;
}
/* === Choices.js theme overrides (dark + light, executive report modal) === */
/*
Choices.js ships with a white-bg, light-grey-text default that clashes with
the app's dark theme. These overrides replace those defaults with the app's
own design tokens so the multi-select picker matches every other card and
input on the page. All tokens auto-switch between dark (:root) and light
(:root.light) themes no duplicate blocks needed.
Specificity note: the Choices.js CDN CSS loads AFTER custom.css (inside the
modal partial, near </body>). Every rule below chains the root `.choices`
class to beat the CDN's same-class selectors, and uses !important on the
two properties Choices.js hardcodes most aggressively (color + background)
so dark/light theme tokens always win.
*/
/* Container — the outer wrapper that replaces the native <select> */
.choices.choices {
margin-bottom: 0;
}
/* Closed-state input area (where chips and the placeholder/search sit) */
.choices .choices__inner {
background: var(--bg-inset) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border-default) !important;
border-radius: 0.5rem;
padding: 0.4rem 0.55rem;
min-height: 2.55rem;
font-size: 0.925rem;
}
.choices.is-focused .choices__inner,
.choices.is-open .choices__inner {
border-color: var(--accent) !important;
box-shadow: 0 0 0 0.15rem rgba(232, 133, 26, 0.18);
}
/* The cloned search input typed into when the dropdown is open */
.choices .choices__input {
background: transparent !important;
color: var(--text-primary) !important;
font-size: 0.925rem;
}
.choices .choices__input::placeholder {
color: var(--text-tertiary) !important;
}
/* Dropdown popup — the list of choices */
.choices .choices__list--dropdown,
.choices .choices__list[aria-expanded] {
background: var(--bg-card) !important;
border: 1px solid var(--border-default) !important;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
margin-top: 4px;
z-index: 2000;
color: var(--text-primary) !important;
}
/* Individual option rows in the dropdown — default state */
.choices .choices__list--dropdown .choices__item,
.choices .choices__list[aria-expanded] .choices__item {
color: var(--text-primary) !important;
background: transparent !important;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
/* Hovered / keyboard-highlighted option — matches the "Month button selected" look */
.choices .choices__list--dropdown .choices__item--selectable.is-highlighted,
.choices .choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
background: var(--bg-card-hover) !important;
color: var(--text-primary) !important;
}
/* The trailing "Press to select" hint */
.choices .choices__list--dropdown .choices__item--selectable.is-highlighted::after,
.choices .choices__list[aria-expanded] .choices__item--selectable.is-highlighted::after {
color: var(--accent);
opacity: 0.9;
}
/* Disabled / placeholder-style rows (e.g. "No matches found") */
.choices .choices__list--dropdown .choices__item--disabled,
.choices .choices__list[aria-expanded] .choices__item--disabled {
color: var(--text-tertiary) !important;
}
/* Placeholder text in the input area when nothing is selected */
.choices .choices__placeholder {
color: var(--text-tertiary) !important;
opacity: 1;
}
/* Selected chips in multi-select mode (visible when items are chosen) */
.choices .choices__list--multiple .choices__item {
background: var(--accent) !important;
border: 1px solid var(--accent) !important;
color: #fff !important;
font-size: 0.82rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
margin: 0.15rem 0.25rem 0.15rem 0;
border-radius: 999px;
}
.choices .choices__list--multiple .choices__item.is-highlighted {
background: var(--accent-hover) !important;
border-color: var(--accent-hover) !important;
}
/* The × button on each selected chip */
.choices .choices__list--multiple .choices__button {
border-left: 1px solid rgba(255, 255, 255, 0.4);
margin: 0 0 0 0.5rem;
padding-left: 0.5rem;
opacity: 0.85;
}
.choices .choices__list--multiple .choices__button:hover {
opacity: 1;
}
/* No-results / no-choices message */
.choices .choices__list .choices__item--no-results,
.choices .choices__list .choices__item--no-choices {
color: var(--text-tertiary) !important;
font-style: italic;
background: transparent !important;
}
/* === Hero KPI card variant (executive report) === */
/*
A larger, more typographic version of the existing .stat-card,
used for the top-of-report KPI band. Keeps the same --accent-based
colour stripes (stat-card--danger, --warning, --info) but scales
the number, flattens the label to uppercase tracked caps, and adds
a subtle tertiary sub-line for context like "as of 15:42" or the
date range.
*/
.stat-card--hero {
padding: 1.25rem 1.4rem;
min-height: 130px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.stat-card--hero .stat-label {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 0.4rem;
}
.stat-card--hero .stat-value {
font-family: 'Poppins', sans-serif;
font-weight: 600;
font-size: 1.85rem;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat-card--hero .stat-subline {
font-size: 0.78rem;
color: var(--text-tertiary);
margin-top: 0.6rem;
}
/* === Report chapter headings === */
/*
Numbered chapter markers (I, II, III, IV) on the executive report.
Each heading has an orange filled circle with the Roman numeral
followed by the chapter title. Used on Chapter I (Lifetime Context),
II (Selected Period), III (Worker Breakdown), IV (Team x Project).
*/
.chapter-heading {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-primary);
font-family: 'Poppins', sans-serif;
font-weight: 600;
}
.chapter-heading .chapter-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.85rem;
height: 1.85rem;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
font-family: 'Inter', sans-serif;
}
/* tabular-nums for all numeric report tables */
.report-numeric td,
.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);
}