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>
14 KiB
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 (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
# 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_reportandgenerate_report_pdfuserequest.GET.getlist('project')/getlist('team').- Multiple values in the querystring:
?project=1&project=2&team=3. - Old single-value URLs (
?project=1) still resolve —getlistreturns 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 fromindex()(unpaid_wages + pending_adj_add - pending_adj_sub) but respects the report's project/team filters. Stamped withcurrent_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 is365.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)whereproject_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)whereteam_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(not0— makes non-zero values stand out) - Horizontally scrollable if > 6 project columns
Backend
New context key team_project_activity:
{
'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
@pageconfig:A4 portrait; margin: 1.8cm - Same
_build_report_contexthelper — 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 projectsproject_ids=[]equivalent toNone(= all)project_ids=[X] & team_ids=[Y]intersects correctly- Backward compat: old single-value URL (
?project=1) parses fine viagetlist
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_contextsignature + new helpers (_current_outstanding_in_scope,_company_cost_velocity,_team_project_activity),generate_report+generate_report_pdfswitch togetlist.core/templates/core/_report_config_modal.html—multipleattribute 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_costshelper — unchanged - Existing
index()/payroll_dashboardviews — 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:
- After backend helpers + tests (Chapter I + Hero numbers work)
- After multi-select modal (Choices.js integrated, filter pills render)
- After Chapter IV pivot + full HTML layout
- After PDF template mirrors HTML
Four natural demo-able pauses. Similar cadence to the 2026-04-22 work-log-payroll-crosslink plan.