38686-vm/docs/plans/2026-04-23-executive-report-v2-design.md
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

296 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.