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:
parent
92036f7e4c
commit
27cdb46ec9
270
docs/plans/2026-04-23-executive-report-v2-design.md
Normal file
270
docs/plans/2026-04-23-executive-report-v2-design.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user