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>
This commit is contained in:
Konrad du Plessis 2026-04-22 21:38:26 +02:00
parent 92036f7e4c
commit 27cdb46ec9

View File

@ -0,0 +1,270 @@
# 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.