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

16 KiB
Raw Blame History

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_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_dailyDecimal. total_lifetime_cost / company_working_days. company_working_days = WorkLog.objects.values('date').distinct().count().
  • company_avg_monthlycompany_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:

{
    '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.htmlmultiple 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.