38686-vm/docs/plans/2026-04-23-executive-report-v2-plan.md
Konrad du Plessis e2eb889a29 Plan: Executive Payroll Report v2 implementation
Task-by-task plan for the design committed at 27cdb46. 14 tasks with
4 hard-pause checkpoints at natural demo points:
  - After Task 6  (backend helpers done)
  - After Task 8  (multi-select modal + filter pills)
  - After Task 12 (full HTML layout — all 4 chapters)
  - After Task 14 (PDF mirrored + QA + shipped note)

Task 1 is a pure refactor (extract _compute_outstanding from index())
so later tasks can reuse the dashboard math with filters. Tasks 2-5
add the new helpers alongside existing code with failing-test-first
discipline. Task 6 switches the main helper to multi-value filters
(project_ids/team_ids) — existing behaviour preserved via backward-
compatible getlist. Tasks 7-12 restructure the HTML template into
Hero + 4 chapters. Task 13 mirrors in the PDF. Task 14 QAs and ships.

~11 new tests across 4 test classes; total grows from 28 to ~39.

One new dependency: Choices.js 10.2.0 via CDN, admin-only gated,
graceful fallback to native multi-select on CDN failure.

Follows the CLAUDE.md conventions: # === SECTION === comments,
plain-English docstrings, subquery-filter pattern for M2M filters,
single-batched push at the end, Co-Authored-By trailer on every
commit, never amend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:45:31 +02:00

69 KiB
Raw Blame History

Executive Payroll Report v2 — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Rebuild /report/ as an executive-grade dashboard — multi-select project/team filters (Choices.js), hero KPI band with live "Outstanding Now" + FoxFitt-wide cost velocity, restructured chapters IIV, new team × project activity pivot. Mirrored in the PDF. No model changes, no migrations.

Architecture: The single helper _build_report_context(start, end, project_ids, team_ids) remains the source of truth for both the HTML view and the PDF view. Three new sub-helpers feed it: _current_outstanding_in_scope, _company_cost_velocity, _team_project_activity. The dashboard's outstanding math (currently inline in index() at lines 165203) is first extracted to a reusable _compute_outstanding() helper so both the home dashboard and the report call the same code — zero drift risk. Templates restructured into four named chapters; Choices.js enhances the modal's two <select multiple> elements with graceful degradation to native multi-select.

Tech Stack: Django 5.2.7 · Python 3.13 · SQLite (local) / MySQL (prod) · Bootstrap 5.3 · Font Awesome 6 · Choices.js 10.2.0 (CDN, new) · WeasyPrint for PDF. Existing patterns followed: # === SECTION === comments, plain-English docstrings, subquery-filter pattern for M2M (from commit f1e246c), CDN-only dependencies (no bundler).

Design source: docs/plans/2026-04-23-executive-report-v2-design.md (commit 27cdb46).

Commit convention: One commit per task; Co-Authored-By trailer; never amend (rule from CLAUDE.md). Local-only until Task 14 then a single batched push to origin/ai-dev.

Reference schema gotchas (from CLAUDE.md — don't re-learn these the hard way):

  • PayrollAdjustment.description (not reason)
  • log.adjustments_by_work_log (not payrolladjustment_set) — FK has related_name
  • log.overtime_amount (not log.overtime)
  • For M2M filters in queries that end in .values().annotate(Sum()): use id__in=Model.objects.filter(m2m__field=X).values('id') — do NOT chain .filter(m2m__field=X).distinct() (that causes N² inflation; see commit f1e246c)

Review checkpoints

Stop and show Konrad after Tasks 6, 8, 12, and 14. Each is a natural demo point:

  • After Task 6 — backend helpers + refactored _build_report_context work; existing report at /report/?project=1 still functions; new numbers computed (not yet rendered).
  • After Task 8 — multi-select modal + filter pills visible in the browser; /report/?project=1&project=2 works end-to-end.
  • After Task 12 — full HTML layout done (hero band + 4 chapters + pivot); report page renders in its new design.
  • After Task 14 — PDF matches HTML; QA complete; shipped note in the design doc.

Task 1: Extract _compute_outstanding() helper from index() (refactor, zero behaviour change)

Why first: Task 3 needs this math callable with filters. Today it's inline in index(). Extracting FIRST (as a pure refactor) means the dashboard's existing behaviour stays identical, and Task 3 just calls the new helper with scope arguments.

Files:

  • Modify: core/views.py (lines 157203 — extract the block; update index() to call the helper)

Step 1.1: Add the helper above index() (around current line 145)

# =============================================================================
# === OUTSTANDING PAYMENTS — SHARED HELPER ===
# Used by the home dashboard AND the payroll report. Computes:
#   - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments)
#   - unpaid_wages: Decimal (pure daily rates for unpaid workers)
#   - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
#   - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments)
#   - outstanding_by_project: dict[str project_name -> Decimal amount]
#
# Accepts optional project_ids / team_ids filters. Empty list or None = no filter.
# =============================================================================

def _compute_outstanding(project_ids=None, team_ids=None):
    """Return current-moment outstanding payment breakdown.

    Plain-English: for each work log that hasn't been fully paid, adds up
    each unpaid worker's daily rate. Then adds unpaid additive adjustments
    (bonuses, overtime, new loans, advances) and subtracts unpaid deductive
    adjustments (deductions, loan/advance repayments). Results are the
    "as of right now" snapshot shown on the home dashboard's Outstanding
    Payments card. Optional filters scope the answer to specific projects
    and/or teams.
    """
    # --- Work logs in scope ---
    work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records')
    if project_ids:
        work_logs = work_logs.filter(project_id__in=project_ids)
    if team_ids:
        work_logs = work_logs.filter(team_id__in=team_ids)

    unpaid_wages = Decimal('0.00')
    outstanding_by_project = {}

    for wl in work_logs:
        paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
        project_name = wl.project.name if wl.project else 'No Project'
        for worker in wl.workers.all():
            if worker.id not in paid_worker_ids:
                cost = worker.daily_rate
                unpaid_wages += cost
                outstanding_by_project.setdefault(project_name, Decimal('0.00'))
                outstanding_by_project[project_name] += cost

    # --- Unpaid adjustments in scope ---
    adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project')
    if project_ids:
        adj_qs = adj_qs.filter(project_id__in=project_ids)
    if team_ids:
        # worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha)
        adj_qs = adj_qs.filter(
            worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
        )

    pending_adj_add = Decimal('0.00')
    pending_adj_sub = Decimal('0.00')
    for adj in adj_qs:
        project_name = adj.project.name if adj.project else 'No Project'
        outstanding_by_project.setdefault(project_name, Decimal('0.00'))
        if adj.type in ADDITIVE_TYPES:
            pending_adj_add += adj.amount
            outstanding_by_project[project_name] += adj.amount
        elif adj.type in DEDUCTIVE_TYPES:
            pending_adj_sub += adj.amount
            outstanding_by_project[project_name] -= adj.amount

    outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub

    return {
        'outstanding_payments': outstanding_payments,
        'unpaid_wages': unpaid_wages,
        'pending_adj_add': pending_adj_add,
        'pending_adj_sub': pending_adj_sub,
        'outstanding_by_project': outstanding_by_project,
    }

Step 1.2: Replace the inline block in index() (currently lines 157203) with a call to the helper

Find in core/views.py around line 157:

all_worklogs = WorkLog.objects.select_related(
    'project'
).prefetch_related('workers', 'payroll_records')

…through line 203:

outstanding_payments = unpaid_wages + pending_adjustments_add - pending_adjustments_sub

Replace with:

# === OUTSTANDING BREAKDOWN ===
# Uses the shared _compute_outstanding helper so the dashboard and the
# payroll report can't drift. Unscoped (no filters) = whole company.
_o = _compute_outstanding()
outstanding_payments     = _o['outstanding_payments']
unpaid_wages             = _o['unpaid_wages']
pending_adjustments_add  = _o['pending_adj_add']
pending_adjustments_sub  = _o['pending_adj_sub']
outstanding_by_project   = _o['outstanding_by_project']

Variable names preserved so the rest of index() still works unchanged.

Step 1.3: Verify — run the full suite + manual dashboard load

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2

Expected: Ran 28 tests ... OK (unchanged).

Open http://localhost:8000/ as admin. The Outstanding Payments card value should be identical to before the refactor.

Step 1.4: Commit

git add core/views.py
git commit -m "$(cat <<'EOF'
Extract _compute_outstanding helper from index() (refactor)

Pure refactor: the ~45 lines of outstanding-payment math inside index()
(computing unpaid_wages + pending_adj_add - pending_adj_sub, with a
per-project breakdown) move into a standalone _compute_outstanding()
helper. index() now calls it with no arguments for unchanged behaviour.
The helper accepts optional project_ids / team_ids for Task 3.

No tests changed; 28/28 still pass. Dashboard Outstanding Payments
card shows the same value before and after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: _company_cost_velocity() helper + 3 tests

Why now: Pure function, pure testable. Gets the Avg R/day and Avg R/month numbers computed for the hero KPI band. Independent of the filter refactor.

Files:

  • Modify: core/views.py (add helper below _compute_outstanding from Task 1)
  • Modify: core/tests.py (append new test class)

Step 2.1: Write the failing tests first

Append to core/tests.py:

# =============================================================================
# === TESTS FOR EXECUTIVE REPORT v2 ===
# Covers the new helpers introduced in the report rebuild (Apr 2026):
# _company_cost_velocity, _current_outstanding_in_scope, _team_project_activity,
# and the multi-filter extension of _build_report_context.
# =============================================================================


class CompanyCostVelocityTests(TestCase):
    """Company-wide avg daily and monthly labour cost (hero KPI card 3 & 4)."""

    def test_empty_db_returns_zero(self):
        from core.views import _company_cost_velocity
        result = _company_cost_velocity()
        self.assertEqual(result['avg_daily'], Decimal('0.00'))
        self.assertEqual(result['avg_monthly'], Decimal('0.00'))
        self.assertEqual(result['working_days'], 0)

    def test_known_values(self):
        from core.views import _company_cost_velocity
        # Setup: 2 workers (daily_rate = 4000/20 = R 200 each), each works 5 distinct dates.
        # Lifetime cost = 2 workers * 5 days * R 200 = R 2000. Working days = 5.
        # Avg daily = 2000 / 5 = R 400.
        # Avg monthly = 400 * 30.44 = R 12,176.
        admin = User.objects.create_user(username='admin-cv', is_staff=True)
        project = Project.objects.create(name='P')
        w1 = Worker.objects.create(name='W1', id_number='W1', monthly_salary=Decimal('4000'))
        w2 = Worker.objects.create(name='W2', id_number='W2', monthly_salary=Decimal('4000'))
        for d in range(1, 6):  # 5 distinct dates
            log = WorkLog.objects.create(
                date=datetime.date(2026, 3, d),
                project=project, supervisor=admin,
            )
            log.workers.add(w1, w2)

        result = _company_cost_velocity()
        self.assertEqual(result['working_days'], 5)
        self.assertEqual(result['avg_daily'], Decimal('400.00'))
        # Tolerance: ±1 cent for the 30.44 multiplication
        self.assertAlmostEqual(
            float(result['avg_monthly']), 12176.00, delta=0.01
        )

    def test_duplicate_dates_not_double_counted(self):
        """Two workers working the same date = 1 distinct date, not 2."""
        from core.views import _company_cost_velocity
        admin = User.objects.create_user(username='admin-cv2', is_staff=True)
        project = Project.objects.create(name='P2')
        w1 = Worker.objects.create(name='X', id_number='X1', monthly_salary=Decimal('4000'))
        w2 = Worker.objects.create(name='Y', id_number='Y1', monthly_salary=Decimal('4000'))
        log = WorkLog.objects.create(
            date=datetime.date(2026, 3, 1), project=project, supervisor=admin,
        )
        log.workers.add(w1, w2)
        result = _company_cost_velocity()
        self.assertEqual(result['working_days'], 1)  # not 2

Step 2.2: Run tests — expect 3 failures (helper not defined)

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.CompanyCostVelocityTests -v 2

Expected: errors like ImportError: cannot import name '_company_cost_velocity'.

Step 2.3: Implement the helper — add to core/views.py right after _compute_outstanding:

# =============================================================================
# === COMPANY COST VELOCITY ===
# Lifetime "what does a typical FoxFitt working day cost us?" metric.
# Denominator = COUNT(DISTINCT work_log.date) — true working days, not
# calendar days (rain days, weekends, permit delays don't dilute the rate).
# Used by the hero KPI band on the payroll report.
# =============================================================================

def _company_cost_velocity():
    """Return company-wide avg daily and monthly labour cost (lifetime)."""
    # Total lifetime labour cost: sum of (worker.daily_rate) over every
    # (log, worker) pair that has ever been logged.
    total_cost = Decimal('0.00')
    for wl in WorkLog.objects.prefetch_related('workers').all():
        for worker in wl.workers.all():
            total_cost += worker.daily_rate

    # Distinct work-log dates = working days
    working_days = WorkLog.objects.values('date').distinct().count()

    if working_days == 0:
        avg_daily = Decimal('0.00')
    else:
        avg_daily = (total_cost / working_days).quantize(Decimal('0.01'))

    # 30.44 = 365.25 / 12 — standard month-length approximation.
    # Keeps annualised totals correct on average.
    avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01'))

    return {
        'avg_daily': avg_daily,
        'avg_monthly': avg_monthly,
        'working_days': working_days,
    }

Step 2.4: Run tests — expect all 3 to pass

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.CompanyCostVelocityTests -v 2

Expected: Ran 3 tests ... OK.

Step 2.5: Commit

git add core/views.py core/tests.py
git commit -m "$(cat <<'EOF'
Add _company_cost_velocity helper + 3 tests

Computes company-wide avg daily and monthly labour cost for the
executive report's hero KPI band (cards 3 and 4). Denominator is
working days (distinct work-log dates), not calendar days — true
cost-of-a-productive-day metric per design section 2.

Monthly = daily * 30.44 (the 365.25/12 month-length approximation,
which keeps annualised totals correct on average).

Tests cover: empty DB returns zero, known values with assertAlmostEqual
for the 30.44 multiplication, and that multiple workers on one date
count as 1 working day (not N).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: _current_outstanding_in_scope() + 3 tests (Hero card 2)

Files:

  • Modify: core/views.py (add helper after _company_cost_velocity)
  • Modify: core/tests.py (append new test class)

Step 3.1: Write failing tests

Append to core/tests.py:

class CurrentOutstandingInScopeTests(TestCase):
    """Hero card 2 — 'Outstanding NOW' with optional filter scope."""

    def setUp(self):
        self.admin = User.objects.create_user(username='a-out', is_staff=True)
        self.p1 = Project.objects.create(name='ProjA')
        self.p2 = Project.objects.create(name='ProjB')
        self.t1 = Team.objects.create(name='TeamA', supervisor=self.admin)
        self.w = Worker.objects.create(
            name='Wkr', id_number='W1', monthly_salary=Decimal('4000')
        )
        self.t1.workers.add(self.w)
        # Unpaid log on project 1
        log1 = WorkLog.objects.create(
            date=datetime.date(2026, 3, 1),
            project=self.p1, team=self.t1, supervisor=self.admin,
        )
        log1.workers.add(self.w)
        # Unpaid log on project 2
        log2 = WorkLog.objects.create(
            date=datetime.date(2026, 3, 2),
            project=self.p2, team=self.t1, supervisor=self.admin,
        )
        log2.workers.add(self.w)

    def test_no_filters_includes_all_projects(self):
        from core.views import _current_outstanding_in_scope
        result = _current_outstanding_in_scope()
        # daily_rate = 4000/20 = 200; 2 unpaid logs * 200 = 400
        self.assertEqual(result['total'], Decimal('400.00'))
        self.assertEqual(len(result['by_project']), 2)

    def test_project_filter_scopes_total(self):
        from core.views import _current_outstanding_in_scope
        result = _current_outstanding_in_scope(project_ids=[self.p1.id])
        self.assertEqual(result['total'], Decimal('200.00'))
        self.assertEqual(len(result['by_project']), 1)
        self.assertEqual(result['by_project'][0]['name'], 'ProjA')

    def test_team_filter_scopes_total(self):
        """Team filter on work logs + worker__teams on adjustments."""
        from core.views import _current_outstanding_in_scope
        # Adjustment on a worker not in t1
        other_worker = Worker.objects.create(
            name='Other', id_number='O1', monthly_salary=Decimal('4000')
        )
        PayrollAdjustment.objects.create(
            worker=other_worker, project=self.p1, type='Bonus',
            amount=Decimal('500.00'), date=datetime.date(2026, 3, 3),
        )
        # With team filter, only self.w's logs appear — R 400 total
        result = _current_outstanding_in_scope(team_ids=[self.t1.id])
        self.assertEqual(result['total'], Decimal('400.00'))

Step 3.2: Run — expect 3 failures

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.CurrentOutstandingInScopeTests -v 2

Step 3.3: Implement — add to core/views.py after _company_cost_velocity:

# =============================================================================
# === CURRENT OUTSTANDING — SCOPED FOR THE REPORT ===
# Thin wrapper around _compute_outstanding that shapes the output for
# the executive report's hero card 2. Includes a 'by_project' list
# sorted by amount desc, ready for direct template rendering.
# =============================================================================

def _current_outstanding_in_scope(project_ids=None, team_ids=None):
    """Return current outstanding payments, optionally scoped by project/team.

    Calls _compute_outstanding and reshapes the by_project dict into a
    list sorted by amount descending (for display). The 'total' field
    is the net outstanding (unpaid wages + additive adjustments minus
    deductive adjustments), matching the home dashboard card.
    """
    raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
    by_project_list = sorted(
        [{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
        key=lambda r: -r['amount'],
    )
    return {
        'total': raw['outstanding_payments'],
        'by_project': by_project_list,
    }

Step 3.4: Run — expect 3 pass + 3 earlier pass = 6 pass

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.CurrentOutstandingInScopeTests core.tests.CompanyCostVelocityTests -v 2

Expected: Ran 6 tests ... OK.

Step 3.5: Commit

git add core/views.py core/tests.py
git commit -m "Add _current_outstanding_in_scope helper + 3 tests

Hero KPI card 2 needs 'Outstanding NOW' scoped to the report's selected
projects/teams. This helper wraps _compute_outstanding, reshapes the
by_project dict into a sorted list, and exposes the net total for direct
rendering.

Tests cover unfiltered total, project-scoped total, and team-scoped
total (including the worker__teams subquery path for adjustments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: _team_project_activity() + 4 tests (Chapter IV backend)

Files:

  • Modify: core/views.py (add helper)
  • Modify: core/tests.py (append new test class)

Step 4.1: Write failing tests

Append to core/tests.py:

class TeamProjectActivityTests(TestCase):
    """Chapter IV pivot: rows=team, columns=project, cell=distinct log dates."""

    def setUp(self):
        self.admin = User.objects.create_user(username='a-tpa', is_staff=True)
        self.p1 = Project.objects.create(name='P1')
        self.p2 = Project.objects.create(name='P2')
        self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
        self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
        w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))

        # T1 works 3 distinct dates on P1
        for d in (1, 2, 3):
            log = WorkLog.objects.create(
                date=datetime.date(2026, 3, d), project=self.p1, team=self.t1,
                supervisor=self.admin,
            )
            log.workers.add(w)

        # T2 works 2 distinct dates on P1 and 1 on P2
        for d in (4, 5):
            log = WorkLog.objects.create(
                date=datetime.date(2026, 3, d), project=self.p1, team=self.t2,
                supervisor=self.admin,
            )
            log.workers.add(w)
        log = WorkLog.objects.create(
            date=datetime.date(2026, 3, 6), project=self.p2, team=self.t2,
            supervisor=self.admin,
        )
        log.workers.add(w)

        self.logs_qs = WorkLog.objects.filter(
            date__gte=datetime.date(2026, 3, 1),
            date__lte=datetime.date(2026, 3, 31),
        )

    def test_pivot_shape(self):
        from core.views import _team_project_activity
        r = _team_project_activity(self.logs_qs)
        # 2 columns (P1, P2), 2 rows (T1, T2)
        self.assertEqual(len(r['columns']), 2)
        self.assertEqual(len(r['rows']), 2)

    def test_cell_counts(self):
        from core.views import _team_project_activity
        r = _team_project_activity(self.logs_qs)
        rows = {row['team_name']: row for row in r['rows']}
        # T1 has 3 days on P1, 0 on P2
        self.assertEqual(rows['T1']['cells_by_project_id'][self.p1.id], 3)
        self.assertEqual(rows['T1']['cells_by_project_id'].get(self.p2.id, 0), 0)
        # T2 has 2 days on P1, 1 on P2
        self.assertEqual(rows['T2']['cells_by_project_id'][self.p1.id], 2)
        self.assertEqual(rows['T2']['cells_by_project_id'][self.p2.id], 1)

    def test_row_and_column_totals(self):
        from core.views import _team_project_activity
        r = _team_project_activity(self.logs_qs)
        rows = {row['team_name']: row for row in r['rows']}
        self.assertEqual(rows['T1']['row_total'], 3)
        self.assertEqual(rows['T2']['row_total'], 3)
        self.assertEqual(r['col_totals'][self.p1.id], 5)
        self.assertEqual(r['col_totals'][self.p2.id], 1)
        self.assertEqual(r['grand_total'], 6)

    def test_team_with_no_logs_omitted(self):
        """Team with zero logs in the period should not appear as a row."""
        from core.views import _team_project_activity
        Team.objects.create(name='GhostTeam', supervisor=self.admin)
        r = _team_project_activity(self.logs_qs)
        team_names = [row['team_name'] for row in r['rows']]
        self.assertNotIn('GhostTeam', team_names)

Step 4.2: Run — expect 4 failures

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TeamProjectActivityTests -v 2

Step 4.3: Implement — add to core/views.py:

# =============================================================================
# === TEAM × PROJECT ACTIVITY PIVOT ===
# Chapter IV of the executive report: "how many days did each team work
# on each project in this period?" Cell value = COUNT(DISTINCT work_log.date).
# Logs with no team (team IS NULL) are excluded — the pivot is meaningless
# without a team axis.
# =============================================================================

def _team_project_activity(work_logs_qs):
    """Return pivot data for team × project activity within a work-logs queryset.

    Plain-English: for each team-project pair represented in the given
    queryset, counts the number of distinct calendar dates the team worked
    on that project. Rows and columns include only teams/projects that
    actually appeared (zero-activity teams/projects aren't shown).
    """
    # Narrow to logs that have both a team and a project (we can't pivot
    # on NULL axes; also filters out the "No Project" ghost rows).
    qs = work_logs_qs.filter(team__isnull=False, project__isnull=False)

    # Aggregate: (team_id, project_id) -> distinct dates
    from django.db.models import Count
    rows_data = qs.values(
        'team_id', 'team__name', 'project_id', 'project__name'
    ).annotate(days=Count('date', distinct=True)).order_by('team__name')

    # Build column list (unique projects, ordered by name)
    columns_seen = {}
    for r in rows_data:
        columns_seen.setdefault(r['project_id'], r['project__name'])
    columns = [
        {'id': pid, 'name': pname}
        for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1])
    ]

    # Build rows: team_id -> cells_by_project_id dict
    rows_by_team = {}  # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'}
    col_totals = {col['id']: 0 for col in columns}
    grand_total = 0

    for r in rows_data:
        tid = r['team_id']
        pid = r['project_id']
        days = r['days']
        row = rows_by_team.setdefault(tid, {
            'team_id': tid,
            'team_name': r['team__name'],
            'cells_by_project_id': {},
            'row_total': 0,
        })
        row['cells_by_project_id'][pid] = days
        row['row_total'] += days
        col_totals[pid] += days
        grand_total += days

    # Ordered rows list (by team name)
    rows = sorted(rows_by_team.values(), key=lambda r: r['team_name'])

    return {
        'columns': columns,
        'rows': rows,
        'col_totals': col_totals,
        'grand_total': grand_total,
    }

Step 4.4: Run — expect 4 pass

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TeamProjectActivityTests -v 2

Expected: Ran 4 tests ... OK.

Step 4.5: Commit

git add core/views.py core/tests.py
git commit -m "Add _team_project_activity helper + 4 tests

Chapter IV pivot backend: for each (team, project) pair in the given
work-logs queryset, counts distinct work-log dates. Returns columns
(projects), rows (teams with cell dict), column totals, and grand total
ready for direct template rendering.

Logs with NULL team or NULL project are excluded (can't pivot on NULL).
Teams/projects with zero activity don't appear as rows/columns — keeps
the pivot tight.

Tests cover shape, cell counts, row+column+grand totals, and
zero-activity team omission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Enrich Chapter I lifetime data (working_days + avg_per_working_day)

Files:

  • Modify: core/views.py (update _build_report_context to enrich alltime_projects — do NOT touch _get_labour_costs since that helper is used elsewhere too)
  • Modify: core/tests.py (append test class)

Step 5.1: Write failing test

Append to core/tests.py:

class ChapterOneEnrichmentTests(TestCase):
    """Chapter I — All Time Projects gains working_days and avg_per_working_day."""

    def test_alltime_projects_includes_working_days_and_avg(self):
        from core.views import _build_report_context
        admin = User.objects.create_user(username='c1', is_staff=True)
        proj = Project.objects.create(name='C1', start_date=datetime.date(2026, 1, 1))
        w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
        # 4 distinct dates, 1 worker each; daily_rate=200; total = R 800; working_days=4; avg=200
        for d in (1, 2, 3, 4):
            log = WorkLog.objects.create(
                date=datetime.date(2026, 3, d), project=proj, supervisor=admin,
            )
            log.workers.add(w)
        ctx = _build_report_context(
            datetime.date(2026, 1, 1), datetime.date(2026, 12, 31),
        )
        by_name = {p['project']: p for p in ctx['alltime_projects']}
        self.assertIn('C1', by_name)
        self.assertEqual(by_name['C1']['working_days'], 4)
        self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
        self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))

Step 5.2: Run — expect failure (new keys missing)

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ChapterOneEnrichmentTests -v 2

Step 5.3: Enrich _build_report_context — find the block in core/views.py around line 1930 that computes alltime_projects:

alltime_projects = _get_labour_costs(all_time_logs, 'project__name', 'project')

Replace with:

# === CHAPTER I — All Time Projects (enriched) ===
# Adds working_days and avg_per_working_day (the 2026-04-23 design).
# Can't just extend _get_labour_costs because that helper is used by
# other sections with different columns. Wrap it here instead.
alltime_projects_raw = _get_labour_costs(all_time_logs, 'project__name', 'project')
# Build a lookup of working_days per project (distinct work-log dates)
from django.db.models import Count  # safe to re-import locally; no-op if already imported
project_working_days = dict(
    all_time_logs.filter(project__isnull=False)
    .values('project_id', 'project__name')
    .annotate(days=Count('date', distinct=True))
    .values_list('project__name', 'days')
)
# Lookup project start_date from the Project model (authoritative source)
start_dates = dict(
    Project.objects.values_list('name', 'start_date')
)
alltime_projects = []
for row in alltime_projects_raw:
    name = row['project']
    wdays = project_working_days.get(name, 0)
    total = row['total'] or Decimal('0.00')
    avg = (total / wdays).quantize(Decimal('0.01')) if wdays else Decimal('0.00')
    alltime_projects.append({
        'project': name,
        'worker_days': row['worker_days'],
        'total': total,
        'start_date': start_dates.get(name),   # may be None
        'working_days': wdays,
        'avg_per_working_day': avg,
    })

Step 5.4: Run — expect 1 pass

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ChapterOneEnrichmentTests -v 2

Step 5.5: Commit

git add core/views.py core/tests.py
git commit -m "Enrich alltime_projects context with working_days + avg_per_working_day

Chapter I of the executive report needs per-project working-day count and
avg rand per working day. Instead of modifying the shared _get_labour_costs
helper (used by other sections with different column sets), enrich the
output INSIDE _build_report_context: wrap the raw result and add
working_days (distinct work-log dates per project) and avg_per_working_day
(total_cost / working_days, null-safe).

Also attaches start_date from the Project model (may be None if not set).

1 test added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: Refactor _build_report_context signature (ids → multi) + view getlist + multi-filter test

Why now: All the new helpers exist. Time to change the main entry point's signature from single IDs to lists of IDs, update the two views to pass lists, and prove the subquery-filter pattern extends cleanly to __in queries.

Files:

  • Modify: core/views.py (_build_report_context signature + internal filters + views generate_report and generate_report_pdf)
  • Modify: core/tests.py (update existing ReportContextFilterInflationTests to call new signature; add ReportMultiFilterTests)

Step 6.1: Update existing tests to new signature (the old tests will still pass, they just take lists now)

In core/tests.py, find the ReportContextFilterInflationTests class. Replace every project_id=self.project.id with project_ids=[self.project.id] and same for team. Also update _ctx helper:

def _ctx(self, project_ids=None, team_ids=None):
    return _build_report_context(
        datetime.date(2026, 3, 1),
        datetime.date(2026, 3, 31),
        project_ids=project_ids,
        team_ids=team_ids,
    )

And every caller: self._ctx(project_ids=[self.project.id]), etc.

Step 6.2: Write a NEW multi-filter test

Append to core/tests.py:

class ReportMultiFilterTests(TestCase):
    """Task 6 — multi-value project_ids / team_ids filters."""

    def setUp(self):
        self.admin = User.objects.create_user(username='mf', is_staff=True)
        self.p1 = Project.objects.create(name='P1')
        self.p2 = Project.objects.create(name='P2')
        self.p3 = Project.objects.create(name='P3')
        self.team = Team.objects.create(name='T', supervisor=self.admin)
        self.w = Worker.objects.create(
            name='W', id_number='W1', monthly_salary=Decimal('4000')
        )
        self.team.workers.add(self.w)
        # One log + one paid record per project
        for proj in (self.p1, self.p2, self.p3):
            log = WorkLog.objects.create(
                date=datetime.date(2026, 3, 1),
                project=proj, team=self.team, supervisor=self.admin,
            )
            log.workers.add(self.w)
            rec = PayrollRecord.objects.create(
                worker=self.w, amount_paid=Decimal('100.00'),
                date=datetime.date(2026, 3, 5),
            )
            rec.work_logs.add(log)

    def _ctx(self, project_ids=None, team_ids=None):
        from core.views import _build_report_context
        return _build_report_context(
            datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
            project_ids=project_ids, team_ids=team_ids,
        )

    def test_multi_project_union(self):
        ctx = self._ctx(project_ids=[self.p1.id, self.p2.id])
        # Two projects paid R 100 each = R 200; third excluded
        self.assertEqual(ctx['total_paid_out'], Decimal('200.00'))

    def test_empty_list_equals_none(self):
        ctx_none = self._ctx(project_ids=None)
        ctx_empty = self._ctx(project_ids=[])
        self.assertEqual(ctx_none['total_paid_out'], ctx_empty['total_paid_out'])

    def test_no_inflation_with_multi_project(self):
        """Worker breakdown must not inflate when multiple projects are selected."""
        ctx = self._ctx(project_ids=[self.p1.id, self.p2.id, self.p3.id])
        self.assertEqual(len(ctx['worker_breakdown']), 1)
        # All three records are for the same worker, R 100 each = R 300
        self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))

Step 6.3: Run tests — expect 3 new failures + existing inflation tests may fail too

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.ReportMultiFilterTests core.tests.ReportContextFilterInflationTests -v 2

Expected: signature errors for project_ids kwarg (not yet supported).

Step 6.4: Change _build_report_context signature + internal filters

In core/views.py, find the helper at ~line 1867. Update the signature and the three places that reference project_id / team_id.

a. Signature (top of _build_report_context):

def _build_report_context(start_date, end_date, project_ids=None, team_ids=None):
    """
    Compute all report data for the given date range and filters.

    project_ids / team_ids are lists of ints. None or [] treated as "no filter".
    """

Also adjust the project_name / team_name context values at the bottom (they used to show a single name; now show a comma-joined list or "All X"):

'project_name': (
    ', '.join(Project.objects.filter(id__in=project_ids).values_list('name', flat=True))
    if project_ids else 'All Projects'
),
'team_name': (
    ', '.join(Team.objects.filter(id__in=team_ids).values_list('name', flat=True))
    if team_ids else 'All Teams'
),

b. Replace the 3 filter blocks. Find this (from the current state):

records = PayrollRecord.objects.filter(date_filter)
if project_id:
    records = records.filter(
        id__in=PayrollRecord.objects.filter(
            work_logs__project_id=project_id
        ).values('id')
    )
if team_id:
    records = records.filter(
        id__in=PayrollRecord.objects.filter(
            work_logs__team_id=team_id
        ).values('id')
    )

Replace with the __in version (extends cleanly from = to __in):

records = PayrollRecord.objects.filter(date_filter)
if project_ids:
    records = records.filter(
        id__in=PayrollRecord.objects.filter(
            work_logs__project_id__in=project_ids
        ).values('id')
    )
if team_ids:
    records = records.filter(
        id__in=PayrollRecord.objects.filter(
            work_logs__team_id__in=team_ids
        ).values('id')
    )

c. Same treatment for adjustments, work_logs_qs, all_time_logs, year_logs, active_loans, loans_issued_qs, advances_issued_qs. Search for every if project_id: and if team_id: inside _build_report_context and convert:

  • project_id=project_idproject_id__in=project_ids
  • team_id=team_idteam_id__in=team_ids
  • worker__teams__id=team_idworker__in=Worker.objects.filter(teams__id__in=team_ids).values('id') (keeps the subquery pattern; just uses __in)

d. Add the three new hero-KPI keys to the return dict — near the bottom of _build_report_context:

# === Hero KPI band (executive report v2) ===
_cv = _company_cost_velocity()

And in the return dict, add:

    'current_outstanding': _current_outstanding_in_scope(
        project_ids=project_ids, team_ids=team_ids
    ),
    'current_as_of': timezone.now(),
    'company_avg_daily': _cv['avg_daily'],
    'company_avg_monthly': _cv['avg_monthly'],
    'company_working_days': _cv['working_days'],
    'team_project_activity': _team_project_activity(work_logs_qs),

Step 6.5: Update the two views to use getlist

In core/views.py, find generate_report (around line 2106) and generate_report_pdf (around line 2133). In each, change:

project_id = request.GET.get('project') or None
team_id = request.GET.get('team') or None

to:

# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
# Cast to ints; drop empties.
def _ids(name):
    return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
project_ids = _ids('project') or None
team_ids = _ids('team') or None

And update the call to _build_report_context(start_date, end_date, project_ids, team_ids).

Step 6.6: Run all tests

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2

Expected: Ran ~39 tests ... OK (28 original + 3 company cost + 3 outstanding + 4 pivot + 1 enrichment + 3 multi-filter).

Manual smoke: curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/report/?project=1 → 302 (auth redirect). curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/report/?project=1&project=2" → 302.

Step 6.7: Commit

git add core/views.py core/tests.py
git commit -m "Refactor _build_report_context signature to multi-value filters

project_id/team_id become project_ids/team_ids (list[int] or None).
Every internal filter uses the __in lookup; M2M filters use the
id__in subquery pattern documented in CLAUDE.md's Django ORM gotcha.
generate_report and generate_report_pdf switch to request.GET.getlist.
Old URL ?project=1 still works — getlist returns a single-element list.

Return dict gains five hero-KPI keys: current_outstanding, current_as_of,
company_avg_daily, company_avg_monthly, team_project_activity — ready
for the template restructure in Tasks 9-12.

Tests: 3 new multi-filter tests; existing inflation tests updated to the
new kwarg names. 39 total, all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

🛑 CHECKPOINT 1 — backend helpers done

Show Konrad:

  1. Full test run output (Ran ~39 tests ... OK)
  2. Open http://localhost:8000/report/?project=1&project=2 as admin — the page still renders (template not yet updated), but check the context in DevTools or by adding a brief print(ctx['current_outstanding']) temporarily if verifying from Python shell.

Await approval before Task 7.


Task 7: Choices.js CDN + multi-select in _report_config_modal.html

Files:

  • Modify: core/templates/core/_report_config_modal.html

Step 7.1: Update the two <select> elements to multi-select

Find (around lines 63-79):

<select name="project" class="form-select">
    <option value="">All Projects</option>
    {% for p in projects %}
    <option value="{{ p.id }}">{{ p.name }}</option>
    {% endfor %}
</select>

Change to:

<select name="project" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
    {% for p in projects %}
    <option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
    {% endfor %}
</select>

Do the same for the team select. Note: the empty <option value="">All...</option> row is removed; "all" is now the empty-selection state.

Step 7.2: Add Choices.js CDN + init block at the bottom of the modal partial

At the very end of _report_config_modal.html (just before the closing script block from Step 7.3 or inside the existing script block near the end):

{# === CHOICES.JS — multi-select enhancement (admin-only) === #}
{# Loaded CDN-only; falls back to native <select multiple> if the CDN fails. #}
{% if user.is_staff or user.is_superuser %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js" defer></script>
<script>
(function() {
    document.addEventListener('DOMContentLoaded', function() {
        if (typeof Choices === 'undefined') return;  // graceful fallback
        document.querySelectorAll('.report-multi').forEach(function(el) {
            new Choices(el, {
                removeItemButton: true,
                shouldSort: false,
                placeholder: true,
                placeholderValue: el.getAttribute('data-placeholder') || '',
            });
        });
    });
})();
</script>
{% endif %}

Step 7.3: Extend the context passed by generate_report and index

So the selected options persist after submission. In core/views.py, in both generate_report and index (where the modal is included), make sure the context includes:

context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]

For index this is trivially empty lists (the dashboard has no active filter state):

context['selected_project_ids'] = []
context['selected_team_ids'] = []

Step 7.4: Smoke test in the browser

Open http://localhost:8000/report/ as admin → Generate Report modal → Project / Team dropdowns should show Choices.js multi-select UI (chips, search-as-you-type).

Select 2 projects → click Generate → resulting URL /report/?...&project=1&project=2&... → report page renders.

Open /report/?project=1&project=2 directly and open the modal again → those two projects should already be selected.

Step 7.5: Commit

git add core/templates/core/_report_config_modal.html core/views.py
git commit -m "Modal: multi-select projects and teams via Choices.js

Replaces the two single <select> elements in the report config modal
with <select multiple> enhanced by Choices.js (CDN 10.2.0, admin-only
gated, graceful fallback to native on CDN failure).

Removes the 'All Projects' / 'All Teams' placeholder option rows —
empty selection = all, matching Choices.js convention.

Persists selected values across submissions via two new context keys
(selected_project_ids, selected_team_ids) threaded through index() and
generate_report().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Files:

  • Modify: core/templates/core/report.html
  • Modify: static/css/custom.css (add .filter-pill rule)

Step 8.1: Add a pill strip beneath the header

Find the "REPORT HEADER" block in core/templates/core/report.html (around line 10) and after the closing </div> of the flex container, add:

{# === FILTER PILLS === #}
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
    <span class="filter-pill">
        <i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}
    </span>
    <span class="filter-pill">
        <i class="fas fa-folder me-1"></i>{{ project_name }}
        {% if selected_project_ids %}
        <a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">&times;</a>
        {% endif %}
    </span>
    <span class="filter-pill">
        <i class="fas fa-users me-1"></i>{{ team_name }}
        {% if selected_team_ids %}
        <a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">&times;</a>
        {% endif %}
    </span>
</div>

Step 8.2: Build the clear-filter query strings in generate_report

In core/views.py, inside generate_report after context['query_string'] = ..., add:

# For the filter-pill × buttons: rebuild the querystring with one filter removed.
def _qs_without(key):
    qd = request.GET.copy()
    qd.pop(key, None)
    return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')

Step 8.3: Add pill styles to static/css/custom.css

Append to the end:

/* === Report filter pills === */
.filter-pill {
    display: inline-flex;
    align-items: center;
    padding: 0.35rem 0.75rem;
    font-size: 0.825rem;
    background: var(--bg-inset);
    color: var(--text-primary);
    border: 1px solid var(--border-default);
    border-radius: 999px;
    line-height: 1.2;
}
.filter-pill i {
    color: var(--accent);
    font-size: 0.75rem;
}
.filter-pill__x {
    margin-left: 0.5rem;
    padding: 0 0.35rem;
    color: var(--text-tertiary);
    text-decoration: none;
    font-weight: 600;
    border-radius: 50%;
    transition: color 120ms, background-color 120ms;
}
.filter-pill__x:hover {
    color: var(--text-primary);
    background: var(--bg-card-hover);
    text-decoration: none;
}

Step 8.4: Smoke test

Open /report/?project=1&project=2 as admin. You should see 3 pills: date range, Project A, Project B with ×, All Teams without × (because no team filter is active).

Click the × on the project pill → URL drops project=... params, page reloads, pill becomes "All Projects" without ×.

Step 8.5: Commit

git add core/templates/core/report.html core/views.py static/css/custom.css
git commit -m "Report: filter-pill strip with × to clear individual filters

Three pills under the header: date range, project(s), team(s). Shows
comma-joined names when multi-valued (project_name in context is already
a comma-joined string from Task 6). × buttons on the project and team
pills remove just that filter via a rebuilt querystring; the calendar
pill has no × (date range is required).

Helper context keys query_string_without_project / _without_team do the
rebuild in the view so the template stays declarative.

Pill CSS uses existing design tokens (--bg-inset, --accent, etc.) so
dark and light themes work without overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

🛑 CHECKPOINT 2 — multi-select + pills visible

Show Konrad:

  1. Open modal, pick 2 projects, generate → URL has both, report page renders, pills show "Project A, Project B".
  2. Click × on pill → filter clears.
  3. Old single-project URL (/report/?project=1) still works.

Await approval before Task 9.


Task 9: Hero KPI band (4 cards) + restyle the 6 summary cards

Files:

  • Modify: core/templates/core/report.html
  • Modify: static/css/custom.css

Step 9.1: Replace the current "All Time / This Year" card row with the hero band

In core/templates/core/report.html, find the block that starts <!-- ALL TIME & THIS YEAR -- around line 42 and ends at the closing </div> of that row (around line 127). Replace the WHOLE block with:

{# === HERO KPI BAND === #}
<div class="row g-3 mb-4 hero-kpi-row">
    <div class="col-lg-3 col-md-6">
        <div class="stat-card stat-card--danger stat-card--hero h-100">
            <div class="stat-label">Paid This Period</div>
            <div class="stat-value">R {{ total_paid_out|money }}</div>
            <div class="stat-subline">{{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</div>
        </div>
    </div>
    <div class="col-lg-3 col-md-6">
        <div class="stat-card stat-card--warning stat-card--hero h-100">
            <div class="stat-label">Outstanding Now</div>
            <div class="stat-value">R {{ current_outstanding.total|money }}</div>
            <div class="stat-subline">as of {{ current_as_of|date:"H:i" }}</div>
        </div>
    </div>
    <div class="col-lg-3 col-md-6">
        <div class="stat-card stat-card--info stat-card--hero h-100">
            <div class="stat-label">FoxFitt Avg / Day</div>
            <div class="stat-value">R {{ company_avg_daily|money }}</div>
            <div class="stat-subline">lifetime avg · {{ company_working_days }} working days</div>
        </div>
    </div>
    <div class="col-lg-3 col-md-6">
        <div class="stat-card stat-card--info stat-card--hero h-100">
            <div class="stat-label">FoxFitt Avg / Month</div>
            <div class="stat-value">R {{ company_avg_monthly|money }}</div>
            <div class="stat-subline">lifetime avg · ~30.44 days/month</div>
        </div>
    </div>
</div>

Step 9.2: Add hero-card styles to static/css/custom.css

Append:

/* === Hero KPI card variant (executive report) === */
.stat-card--hero {
    padding: 1.25rem 1.4rem;
    min-height: 130px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
}
.stat-card--hero .stat-label {
    font-size: 0.7rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-tertiary);
    margin-bottom: 0.4rem;
}
.stat-card--hero .stat-value {
    font-family: 'Poppins', sans-serif;
    font-weight: 600;
    font-size: 1.85rem;
    line-height: 1;
    color: var(--text-primary);
    font-variant-numeric: tabular-nums;
}
.stat-card--hero .stat-subline {
    font-size: 0.78rem;
    color: var(--text-tertiary);
    margin-top: 0.6rem;
}

Step 9.3: Render the filter pill strip and hero band correctly (sanity check)

Open /report/ as admin. You should see the pill strip, then a row of 4 hero cards. The numbers should be reasonable (non-inflated from Task 6's fix).

Step 9.4: Commit

git add core/templates/core/report.html static/css/custom.css
git commit -m "Report: hero KPI band (4 cards) replacing All-Time/YTD row

Chapter 0 of the executive redesign: four large cards at the top
showing Paid This Period, Outstanding Now (live, stamped with the
generation time), FoxFitt Avg/Day, and FoxFitt Avg/Month.

Drops the old four-small-cards All-Time/YTD row (YTD specifically
documented as redundant per design doc section 3). All-Time detail
moves into Chapter I below.

New .stat-card--hero variant uses Poppins 1.85rem for the number,
uppercase tracked labels, subtle tertiary sub-lines. tabular-nums
keeps the R-amounts pixel-aligned across cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: Chapter I (All-Time Projects + Teams, 2 wide cards)

Files:

  • Modify: core/templates/core/report.html

Step 10.1: Add Chapter I section right after the hero band

Insert between the hero band and the existing "SELECTED PERIOD" heading:

{# === CHAPTER I — Lifetime Context === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">I</span>Lifetime Context</h5>
<div class="row g-3 mb-4">
    <div class="col-lg-7">
        <div class="card h-100">
            <div class="card-header py-3">
                <h6 class="m-0 fw-bold"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>All Time — Projects</h6>
            </div>
            <div class="card-body p-0">
                {% if alltime_projects %}
                <div class="table-responsive">
                    <table class="table table-sm mb-0 report-numeric">
                        <thead>
                            <tr>
                                <th>Project</th>
                                <th>Start</th>
                                <th class="text-end">Working Days</th>
                                <th class="text-end">Total Cost</th>
                                <th class="text-end">Avg R / Working Day</th>
                            </tr>
                        </thead>
                        <tbody>
                            {% for item in alltime_projects %}
                            <tr>
                                <td class="fw-medium">{{ item.project }}</td>
                                <td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
                                <td class="text-end">{{ item.working_days|default:"—" }}</td>
                                <td class="text-end fw-semibold">R {{ item.total|money }}</td>
                                <td class="text-end">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}</td>
                            </tr>
                            {% endfor %}
                        </tbody>
                    </table>
                </div>
                {% else %}<p class="text-muted text-center py-3 mb-0">No lifetime project data.</p>{% endif %}
            </div>
        </div>
    </div>
    <div class="col-lg-5">
        <div class="card h-100">
            <div class="card-header py-3">
                <h6 class="m-0 fw-bold"><i class="fas fa-users me-2" style="color: var(--accent);"></i>All Time — Teams</h6>
            </div>
            <div class="card-body p-0">
                {% if alltime_teams %}
                <div class="table-responsive">
                    <table class="table table-sm mb-0 report-numeric">
                        <thead><tr><th>Team</th><th class="text-end">Total Cost</th></tr></thead>
                        <tbody>
                            {% for item in alltime_teams %}
                            <tr><td>{{ item.team }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
                            {% endfor %}
                        </tbody>
                    </table>
                </div>
                {% else %}<p class="text-muted text-center py-3 mb-0">No lifetime team data.</p>{% endif %}
            </div>
        </div>
    </div>
</div>

Step 10.2: Rename the existing "Selected Period" heading to Chapter II

Find around line 135 (<h5 class="fw-bold mb-3">...Selected Period...) and change to:

<h5 class="chapter-heading mb-3"><span class="chapter-num">II</span>Selected Period: {{ start_date|date:"d M Y" }} &ndash; {{ end_date|date:"d M Y" }}</h5>

Step 10.3: Add chapter-heading CSS

Append to static/css/custom.css:

/* === Report chapter headings === */
.chapter-heading {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    color: var(--text-primary);
    font-family: 'Poppins', sans-serif;
    font-weight: 600;
}
.chapter-heading .chapter-num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.85rem;
    height: 1.85rem;
    border-radius: 50%;
    background: var(--accent);
    color: #fff;
    font-size: 0.85rem;
    font-weight: 700;
    font-family: 'Inter', sans-serif;
}
/* tabular-nums for all numeric report tables */
.report-numeric td, .report-numeric th {
    font-variant-numeric: tabular-nums;
}

Step 10.4: Commit

git add core/templates/core/report.html static/css/custom.css
git commit -m "Report: Chapter I (lifetime context) + chapter numbering

Replaces the old narrow four-card All-Time/YTD row with two wider cards
under a numbered 'Chapter I — Lifetime Context' heading. Projects card
gains Start, Working Days, Total Cost, and Avg R / Working Day columns
per the design. Teams card keeps name + total.

Adds .chapter-heading and .chapter-num CSS for the orange numbered
markers (I, II, III, IV) and .report-numeric class that applies
tabular-nums across the number columns of every report table.

Renames the 'Selected Period' heading to Chapter II.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 11: Chapter III — Worker Breakdown polish + chapter numbering

Files:

  • Modify: core/templates/core/report.html

Step 11.1: Add Chapter III heading above the existing Worker Breakdown card

Find the <!-- Worker Breakdown --> comment (around current line 274) and insert above it:

{# === CHAPTER III — Worker Breakdown === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">III</span>Worker Breakdown</h5>

Add class="report-numeric" to the existing <table class="table table-sm mb-0"> in the worker breakdown card so tabular-nums applies.

Step 11.2: Commit

git add core/templates/core/report.html
git commit -m "Report: Chapter III heading + tabular-nums on worker breakdown table

Adds the numbered chapter heading above the existing Worker Breakdown
card. Promotes the worker-breakdown table to .report-numeric for
tabular-nums column alignment (Inter's tabular-nums variant).

No data or structural changes to the breakdown itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: Chapter IV — Team × Project Activity pivot

Files:

  • Modify: core/templates/core/report.html

Step 12.1: Append Chapter IV at the very bottom of the content (before the "Bottom Action Bar" block)

Insert before <!-- Bottom Action Bar --> (around line 311):

{# === CHAPTER IV — Team × Project Activity === #}
<h5 class="chapter-heading mb-3"><span class="chapter-num">IV</span>Team &times; Project Activity</h5>
<div class="card mb-4">
    <div class="card-header py-3">
        <h6 class="m-0 fw-bold"><i class="fas fa-th me-2" style="color: var(--accent);"></i>Distinct Work Days per Team &times; Project</h6>
    </div>
    <div class="card-body p-0">
        {% if team_project_activity.rows %}
        <div class="table-responsive">
            <table class="table table-sm mb-0 report-numeric">
                <thead>
                    <tr>
                        <th>Team</th>
                        {% for col in team_project_activity.columns %}
                        <th class="text-end">{{ col.name }}</th>
                        {% endfor %}
                        <th class="text-end fw-bold">Total</th>
                    </tr>
                </thead>
                <tbody>
                    {% for row in team_project_activity.rows %}
                    <tr>
                        <td class="fw-medium">{{ row.team_name }}</td>
                        {% for col in team_project_activity.columns %}
                        <td class="text-end">
                            {% with days=row.cells_by_project_id|dictlookup:col.id %}
                            {% if days %}{{ days }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}
                            {% endwith %}
                        </td>
                        {% endfor %}
                        <td class="text-end fw-semibold">{{ row.row_total }}</td>
                    </tr>
                    {% endfor %}
                    <tr class="table-total-row">
                        <td class="fw-bold">Total</td>
                        {% for col in team_project_activity.columns %}
                        <td class="text-end fw-bold">{{ team_project_activity.col_totals|dictlookup:col.id }}</td>
                        {% endfor %}
                        <td class="text-end fw-bold">{{ team_project_activity.grand_total }}</td>
                    </tr>
                </tbody>
            </table>
        </div>
        {% else %}<p class="text-muted text-center py-3 mb-0">No team × project activity in this period.</p>{% endif %}
    </div>
</div>

Step 12.2: Add the dictlookup template filter

Django templates can't index a dict by a variable key out-of-the-box. Add to core/templatetags/format_tags.py:

@register.filter
def dictlookup(d, key):
    """Look up a dict value by a dynamic key (Django templates can't do d[key])."""
    if d is None:
        return None
    return d.get(key)

Step 12.3: Add .table-total-row style

Append to static/css/custom.css:

/* === Pivot-table footer totals === */
.table-total-row td {
    border-top: 2px solid var(--border-default) !important;
    background: var(--bg-inset);
}

Step 12.4: Smoke test

Open /report/ with a period that has known activity. Chapter IV should render a pivot with row totals, column totals, and grand total. Zero cells show em-dashes, not 0.

Step 12.5: Commit

git add core/templates/core/report.html core/templatetags/format_tags.py static/css/custom.css
git commit -m "Report: Chapter IV — Team × Project Activity pivot

Final chapter of the executive redesign. Renders the
team_project_activity context as a pivot: rows=teams, columns=projects,
cell=COUNT(DISTINCT work-log dates). Zero cells show em-dashes in muted
grey (not '0') so non-zero cells stand out. Row totals, column totals,
and grand total on the bottom row.

Adds a tiny dictlookup template filter (format_tags.py) — Django
templates can't index a dict by a dynamic variable key, and the pivot
cell lookup is cells_by_project_id[col.id].

.table-total-row CSS: 2px top border + inset background for the footer
row so totals visually separate from the data rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

🛑 CHECKPOINT 3 — full HTML layout done

Show Konrad:

  1. Open /report/ (no filters) as admin → hero band + 4 chapters all render, no errors.
  2. Open /report/?project=1&project=2&team=3 → filters apply, pills show correct values, numbers correct.
  3. Open on a phone viewport → hero band stacks 2×2, tables scroll horizontally.

Await approval before Task 13.


Task 13: PDF template mirrors HTML

Files:

  • Modify: core/templates/core/pdf/report_pdf.html

Step 13.1: Rebuild the PDF template to match the new HTML structure but single-column

The existing report_pdf.html is 623 lines and mirrors the OLD report structure. Do a focused rewrite:

  • Keep the top-of-file WeasyPrint @page CSS block
  • Replace the body content with (in order): cover block (title + filter pills as static labels) → hero KPI band (4 cards stacked 2×2 for PDF width) → Chapter I (Projects table full-width, Teams table below, not side-by-side) → Chapter II (stat cards in one row, Payments by Date + Adjustment Summary stacked full-width, Labour Cost by Project + Team stacked full-width) → Chapter III (worker breakdown table full-width) → Chapter IV (pivot table full-width).
  • Use inline style attributes for the hero cards since WeasyPrint doesn't pick up custom.css classes exactly the same way (already the case in the current PDF template).

This is the biggest template change of the plan. The full rewrite is ~350 lines. Approach:

  1. Copy the existing report_pdf.html to report_pdf.html.bak locally (don't commit the backup).
  2. Open the current file and scroll through it. The existing structure has clear section comments: <!-- SUMMARY -->, <!-- Payments by Date -->, <!-- Adjustment Summary -->, <!-- Labour Cost by Project/Team -->, <!-- Worker Breakdown -->.
  3. Prepend a NEW hero-KPI block right after the page header. Use the same fields as the HTML: total_paid_out, current_outstanding.total, company_avg_daily, company_avg_monthly, current_as_of.
  4. Add a NEW Chapter IV block (team × project pivot) at the bottom, mirroring the HTML structure.
  5. Ensure all sections use report_numeric or inline font-variant-numeric: tabular-nums for number columns.
  6. Update the PDF's header project_name / team_name lines to show the comma-joined strings already in context.

Step 13.2: Smoke test the PDF

curl -L -o /tmp/test_report.pdf "http://localhost:8000/report/pdf/?from_month=2026-03&to_month=2026-04" -b "sessionid=..."

Or from the browser: load /report/?from_month=2026-03&to_month=2026-04 → click Download PDF → open the resulting file.

Expected: Cover block on page 1 with title + filter pills. Hero KPI section visible. All four chapters present. Numbers match the HTML exactly (both use the same helper).

Step 13.3: Commit

git add core/templates/core/pdf/report_pdf.html
git commit -m "Report PDF: mirror the executive redesign (hero band + 4 chapters)

PDF template rewritten to match the new HTML structure: cover block
with static filter pills, hero KPI band (4 stacked 2x2), Chapter I
lifetime (Projects + Teams full-width), Chapter II selected period
(6 stat cards + payments/adjustments/labour-cost), Chapter III worker
breakdown, Chapter IV team x project pivot.

Same _build_report_context helper so HTML and PDF cannot drift. All
numbers identical. WeasyPrint-friendly: inline styles where needed;
single-column body since PDFs look better sequential.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: QA pass + mark shipped

Files (this task):

  • Modify: docs/plans/2026-04-23-executive-report-v2-design.md (append a "Shipped" block)

Step 14.1: QA matrix

As admin, walk through:

Test Expected
/report/ (no filters, current month) All 4 chapters render; hero band shows real numbers; pills show "All Projects · All Teams"
/report/?project=1&project=2 Pills show two project names; × button on project pill works
/report/?team=3 Pills show team name; × button on team pill works
/report/?from_month=2026-03&to_month=2026-04 Date range pill correct; selected-period numbers match
Modal: select 2 projects + 2 teams → Generate URL has 4 params; report filters correctly
Modal: re-open on a filtered page Already-selected projects/teams appear as chips
/report/pdf/?... Downloads a PDF with same data as HTML view
Phone viewport Hero band stacks; horizontal scroll on pivot
As supervisor Report URL returns 403 (unchanged from before)

Step 14.2: Full test run

USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2

Expected: Ran ~39 tests ... OK.

Step 14.3: Django system check

USE_SQLITE=true DJANGO_DEBUG=true python manage.py check
USE_SQLITE=true DJANGO_DEBUG=true python manage.py makemigrations --dry-run

Expected: only the pre-existing node_modules warning; "No changes detected" for migrations.

Step 14.4: Append "Shipped" block to the design doc

Append to docs/plans/2026-04-23-executive-report-v2-design.md:

---

## Shipped — 23 Apr 2026

**Commits:** 27cdb46 (design) through the Task 14 shipped commit.
**Plan:** `docs/plans/2026-04-23-executive-report-v2-plan.md`
**Tests:** 28 → ~39, all pass.

**QA outcome:** full matrix green. No model changes; no migrations.
Dashboard Outstanding Payments card verified identical to pre-refactor
(Task 1 extracted the helper without changing behaviour).

**Deferred / out of scope (revisit if requested):**
- Charts / sparklines in any chapter (text-and-table only for now)
- Save-as-template feature
- Period-over-period comparison
- The YTD cards from the previous design (dropped as redundant)

Step 14.5: Commit

git add docs/plans/2026-04-23-executive-report-v2-design.md
git commit -m "Docs: mark Executive Report v2 as shipped (23 Apr 2026)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

🛑 CHECKPOINT 4 — ship

Final demo for Konrad:

  1. Full report page walkthrough (all 4 chapters, filter pills, × button).
  2. Modal flow: pick several projects + teams, generate.
  3. PDF download, open and show alongside the HTML view.
  4. Confirm dashboard's Outstanding Payments card unchanged.
  5. Test suite screenshot (~39 OK).

On approval → single batched push to origin/ai-dev (14 commits), then Gemini deploy:

  • git pull github ai-dev && python3 manage.py collectstatic --noinput && sudo systemctl restart django-dev.service

(Python-only + CSS change — needs collectstatic for the new .stat-card--hero, .filter-pill, .chapter-heading, .report-numeric, .table-total-row CSS rules to reach Apache.)


Deferred / future (not in this plan)

  • Charts: Chart.js is already loaded on the payroll dashboard; adding a small bar chart alongside Chapter I or the pivot would be a separate plan.
  • "Save report" feature: reports are bookmark-able URLs, sufficient for now.
  • Period comparison: "Mar vs Feb" side-by-side view.
  • Collapsible chapter sections: accordion UI if the report grows past a single scroll.

Plan complete and saved to docs/plans/2026-04-23-executive-report-v2-plan.md. Two execution options:

1. Subagent-Driven (this session) — I dispatch a fresh subagent per task, review between tasks, stay in this chat. Fast iteration; I catch issues before you do.

2. Parallel Session (separate) — Open a new Claude Code session with superpowers:executing-plans, batch execution with the 4 checkpoints above.

Which approach?