{{-- Stakeholder Report — Page 1 (Overview) §5 page 1: KPI band + peak-this-period hero + needs-attention + Theory-of-Change narrative + theory-health strip. Vars in (from deck): $completedCount int $completedDelta int (may be negative) $goalsOnTrack int $goalsTotal int $overdueCount int $hoursLogged float $needsAttn array — $report['needsAttention'] $logicModel null | {narrative, healthBadges, ...} $hasLM bool --}} {{-- ── KPI band (value-first, delta inline, lowercase label) ──────── --}} @php // Drill-down source data. Show top 5 per cell, "+N more" tail if longer. $completedItems = array_slice($report['milestones']['completed'] ?? [], 0, 5); $completedMoreCount = max(0, count($report['milestones']['completed'] ?? []) - 5); $overdueItems = array_slice($report['needsAttention']['overdueMilestones'] ?? [], 0, 5); $overdueMoreCount = max(0, count($report['needsAttention']['overdueMilestones'] ?? []) - 5); // Denominator counts for KPI context — a raw "3 overdue" doesn't say // "out of how many". Total milestones = completed + inFlight + overdue + // upcoming; open = the not-yet-done subset (inFlight + overdue + upcoming). $inFlightCount = (int) ($stats['inFlight'] ?? 0); $upcomingCount = (int) ($stats['upcoming'] ?? 0); $openMsCount = $inFlightCount + $overdueCount + $upcomingCount; $totalMsCount = $completedCount + $openMsCount; // Drill on "Goals on track" lists the ON-TRACK goals (the number the cell // represents). The count ($goalsOnTrack) comes from $stats, which the engine // derives from the FULL report ($report['goals']) across the strategy AND its // programs — so the drill list must read the same set, not $goalsGroup (which // is scoped to the strategy's own goals only and is empty when goals live on // programs, leaving a "9 on track" cell with an empty list). At-risk goals // surface in the Needs Attention block. $allGoalsForDrill = $report['goals']['goals'] ?? ($goalsGroup['goals'] ?? []); $onTrackAll = array_filter($allGoalsForDrill, fn ($g) => ((array) $g)['status'] === 'status_ontrack' || (is_object($g) && ($g->status ?? '') === 'status_ontrack')); $onTrackAll = array_values($onTrackAll); $onTrackItems = array_slice($onTrackAll, 0, 5); $onTrackMoreCount = max(0, count($onTrackAll) - 5); // Hours drill = per-project effort breakdown, sorted desc. Project names // come from $report['summaries'] (keyed by projectId with .name field). $effortByProj = $report['effort']['byProject'] ?? []; arsort($effortByProj); $projNames = []; foreach (($report['summaries'] ?? []) as $s) { $s = (object) $s; $projNames[(int) ($s->id ?? 0)] = (string) ($s->name ?? ''); } $hoursItems = []; foreach ($effortByProj as $pid => $h) { if ($h <= 0) continue; $hoursItems[] = ['name' => $projNames[(int) $pid] ?? ('#'.$pid), 'hours' => (float) $h]; } $hoursMoreCount = max(0, count($hoursItems) - 5); $hoursItems = array_slice($hoursItems, 0, 5); $fmtDate = fn ($v) => is_object($v) ? $v->setToUserTimezone()->format('M j') : ($v ? date('M j', strtotime((string) $v)) : ''); @endphp
{{-- Completed --}}
@if ($completedCount > 0) {{ __('stakeholder.kpi.see_list') }} @endif
{{ $completedCount }}@if ($totalMsCount > 0)/{{ $totalMsCount }}@endif @if ($completedDelta > 0) +{{ $completedDelta }} @elseif ($completedDelta < 0) {{ $completedDelta }} @endif
{{ $totalMsCount > 0 ? __('stakeholder.kpi.milestones_completed') : __('stakeholder.kpi.completed_this_period') }}
@if ($completedCount > 0)
{{ __('stakeholder.kpi.drill.completed') }}
    @foreach ($completedItems as $m) @php $m = (object) $m; @endphp
  • {{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }} {{ $fmtDate($m->completedOn ?? $m->modified ?? null) }}
  • @endforeach
@if ($completedMoreCount > 0)
{{ sprintf(__('stakeholder.kpi.drill.more'), $completedMoreCount) }}
@endif
@endif
{{-- Goals on track — drill lists the ON-TRACK goals (what the count is) --}}
@if (count($onTrackItems) > 0) {{ __('stakeholder.kpi.see_list') }} @endif
{{ $goalsOnTrack }}/{{ $goalsTotal }}
{{ __('stakeholder.kpi.goals_on_track_lc') }}
@if (count($onTrackItems) > 0)
{{ __('stakeholder.kpi.drill.on_track') }}
    @foreach ($onTrackItems as $g) @php $g = (object) $g; @endphp
  • {{ $g->title ?? $g->description ?? __('stakeholder.goals.untitled') }} {{ round((float) ($g->goalProgress ?? 0)) }}%
  • @endforeach
@if ($onTrackMoreCount > 0)
{{ sprintf(__('stakeholder.kpi.drill.more'), $onTrackMoreCount) }}
@endif
@endif
{{-- Overdue milestones --}}
@if ($overdueCount > 0) {{ __('stakeholder.kpi.see_list') }} @endif
{{ $overdueCount }}@if ($openMsCount > 0)/{{ $openMsCount }}@endif
{{ $openMsCount > 0 ? __('stakeholder.kpi.overdue_of_open') : __('stakeholder.kpi.milestones_overdue') }}
@if ($overdueCount > 0)
{{ __('stakeholder.kpi.drill.overdue') }}
    @foreach ($overdueItems as $m) @php $m = (object) $m; @endphp
  • {{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }} {{ $m->projectName ?? '' }}
  • @endforeach
@if ($overdueMoreCount > 0)
{{ sprintf(__('stakeholder.kpi.drill.more'), $overdueMoreCount) }}
@endif
@endif
{{-- Hours logged — drill lists per-project breakdown, largest first --}}
@if (count($hoursItems) > 0) {{ __('stakeholder.kpi.see_list') }} @endif
{{ number_format($hoursLogged, $hoursLogged >= 100 ? 0 : 1) }}h
{{ __('stakeholder.kpi.hours_this_period') }}
@if (count($hoursItems) > 0)
{{ __('stakeholder.kpi.drill.hours') }}
    @foreach ($hoursItems as $h)
  • {{ $h['name'] }} {{ number_format($h['hours'], $h['hours'] >= 100 ? 0 : 1) }}h
  • @endforeach
@if ($hoursMoreCount > 0)
{{ sprintf(__('stakeholder.kpi.drill.more'), $hoursMoreCount) }}
@endif
@endif
{{-- ── Hero (peak this period) + Needs attention ─────────────────── --}} @php // Needs-attention detail from ReportEngine::buildNeedsAttention. Key names // verified against the service — the block reads items by NAME, not counts. $overdueMilestones = $needsAttn['overdueMilestones'] ?? []; $goalsAtRisk = $needsAttn['goalsAtRisk'] ?? []; $staleProjects = $needsAttn['staleProjects'] ?? []; $statusAlerts = $needsAttn['statusAlerts'] ?? []; $needsCount = count($overdueMilestones) + count($goalsAtRisk) + count($staleProjects) + count($statusAlerts); // Peak-this-period nomination (recommend-and-override, §5 page 1 hero). // Rank candidates from completed-this-period milestones: // 1. Has non-empty outcomeImpact (structural closure carrying its own board narrative) // 2. Otherwise, most recent completion // The top pick is the recommendation; the (future) owner override lives here. $completed = $report['milestones']['completed'] ?? []; $withImpact = []; $withoutImpact = []; foreach ($completed as $m) { $m = (object) $m; $impact = trim((string) ($m->outcomeImpact ?? '')); if ($impact !== '') { $withImpact[] = $m; } else { $withoutImpact[] = $m; } } $sortByCompletion = fn ($a, $b) => strcmp((string) ($b->completedOn ?? $b->modified ?? ''), (string) ($a->completedOn ?? $a->modified ?? '')); usort($withImpact, $sortByCompletion); usort($withoutImpact, $sortByCompletion); $peak = $withImpact[0] ?? $withoutImpact[0] ?? null; $peakIsStrong = $peak !== null && trim((string) ($peak->outcomeImpact ?? '')) !== ''; if ($peak !== null) { $peakDate = ! empty($peak->completedOn) ? (is_object($peak->completedOn) ? $peak->completedOn->setToUserTimezone()->format('M j') : date('M j', strtotime((string) $peak->completedOn))) : (! empty($peak->modified) ? date('M j', strtotime((string) $peak->modified)) : ''); $peakBody = trim((string) ($peak->outcomeImpact ?? $peak->description ?? '')); // Strip any HTML that survived from a rich-text editor. $peakBody = trim(strip_tags($peakBody)); } @endphp
{{-- Peak this period — recommend-and-override. Ranked from completed milestones; owner override is a future write path. --}} @if ($peak === null)
{{ __('stakeholder.overview.peak_label') }}
{{ __('stakeholder.overview.peak_none_title') }}
{{ __('stakeholder.overview.peak_none_hint') }}
@if ($scope === 'strategy') {{-- The Logic Model is strategy-scoped, so only offer the "build it" CTA in a strategy report — a program report would link to the wrong (or no) canvas. --}} {{ __('stakeholder.overview.peak_none_cta') }} @endif
@else
{{ __('stakeholder.overview.peak_label') }} {{ __('stakeholder.overview.peak_recommended') }}

{{ $peak->headline ?? '' }}

@if ($peakBody !== '')

{{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}

@endif
@if (! empty($peak->projectName)) {{ $peak->projectName }} @endif @if (! $peakIsStrong) {{ __('stakeholder.overview.peak_weak_note') }} @endif @if ($peakDate !== '') {{ $peakDate }} @endif
@endif
{{ __('stakeholder.overview.needs_label') }}
@if ($needsCount === 0) {{ __('stakeholder.overview.nothing_needs_attention') }} @else {{-- At-risk goals — named. The block loses its meaning as a plain count; a board wants to know WHICH goal is at risk to decide what to do. --}} @if (count($goalsAtRisk) > 0)
{{ sprintf(__('stakeholder.overview.na_goals_hd'), count($goalsAtRisk)) }}
    @foreach (array_slice($goalsAtRisk, 0, 3) as $goal) @php $goal = (object) $goal; $isMiss = (string) $goal->status === 'status_miss'; $title = trim((string) ($goal->title ?? $goal->description ?? __('stakeholder.goals.untitled'))); $progress = round((float) ($goal->goalProgress ?? 0)); @endphp
  • {{ $title }} {{ $isMiss ? __('stakeholder.goals.miss') : __('stakeholder.goals.atrisk') }} · {{ $progress }}%
  • @endforeach @if (count($goalsAtRisk) > 3)
  • {{ sprintf(__('stakeholder.overview.na_more'), count($goalsAtRisk) - 3) }}
  • @endif
@endif {{-- Overdue milestones — named + project. --}} @if (count($overdueMilestones) > 0)
{{ sprintf(__('stakeholder.overview.na_milestones_hd'), count($overdueMilestones)) }}
    @foreach (array_slice($overdueMilestones, 0, 3) as $m) @php $m = (object) $m; $mname = trim((string) ($m->headline ?? __('stakeholder.overview.na_untitled_milestone'))); $projName = trim((string) ($m->projectName ?? '')); @endphp
  • {{ $mname }} @if ($projName !== '') · {{ $projName }} @endif
  • @endforeach @if (count($overdueMilestones) > 3)
  • {{ sprintf(__('stakeholder.overview.na_more'), count($overdueMilestones) - 3) }}
  • @endif
@endif {{-- Stale/silent projects — quiet flag, one line per project. --}} @if (count($staleProjects) > 0)
{{ sprintf(__('stakeholder.overview.na_silent_hd'), count($staleProjects)) }}
    @foreach (array_slice($staleProjects, 0, 3) as $p) @php $p = (object) $p; @endphp
  • {{ $p->name ?? '' }} · {{ __('stakeholder.overview.na_no_update_30d') }}
  • @endforeach @if (count($staleProjects) > 3)
  • {{ sprintf(__('stakeholder.overview.na_more'), count($staleProjects) - 3) }}
  • @endif
@endif @endif
{{-- ── Status narrative — authored, verbatim, per-program ────────── The one place on this page where a human says WHY and what they're doing about it. Everything else is computed. Renders portfolio note first (strategy scope) or the program's own note (program scope), then per-program notes newest-first, cap 4 total. Silent when zero notes exist — never apologizes. --}} @php // Assemble a normalized note list with a "portfolio" flag so the // portfolio note leads and per-program notes follow. $narrativeNotes = []; // strategyUpdates is keyed by projectId => array of updates (same shape as // programUpdates below) — take the newest note of the report subject itself. foreach (($strategyUpdates ?? []) as $notes) { $notesArr = is_array($notes) ? $notes : [$notes]; if (empty($notesArr)) { continue; } $newest = $notesArr[0]; // repo returns newest first $narrativeNotes[] = [ 'label' => __('stakeholder.overview.narrative_portfolio'), 'text' => trim(strip_tags((string) ($newest->text ?? ''))), 'date' => (string) ($newest->date ?? ''), 'portfolio' => true, 'sortKey' => (string) ($newest->date ?? ''), ]; } // programUpdates is keyed by projectId => array of updates. Take the // newest update per program (already newest-first from the repo). $programNameById = []; foreach (($programRows ?? []) as $pr) { $pid = (int) (is_array($pr) ? ($pr['id'] ?? 0) : ($pr->id ?? 0)); $nm = (string) (is_array($pr) ? ($pr['name'] ?? '') : ($pr->name ?? '')); if ($pid > 0) $programNameById[$pid] = $nm; } foreach (($programUpdates ?? []) as $projectId => $notes) { $projectId = (int) $projectId; $notesArr = is_array($notes) ? $notes : []; if (empty($notesArr)) continue; $newest = $notesArr[0]; // repo returns newest first $narrativeNotes[] = [ 'label' => $programNameById[$projectId] ?? __('stakeholder.overview.narrative_program_fallback'), 'text' => trim(strip_tags((string) ($newest->text ?? ''))), 'date' => (string) ($newest->date ?? ''), 'portfolio' => false, 'sortKey' => (string) ($newest->date ?? ''), ]; } // Portfolio always first, then per-program by date desc, cap at 4. usort($narrativeNotes, function ($a, $b) { if ($a['portfolio'] !== $b['portfolio']) return $a['portfolio'] ? -1 : 1; return strcmp($b['sortKey'], $a['sortKey']); }); $narrativeNotes = array_values(array_filter($narrativeNotes, fn ($n) => $n['text'] !== '')); $narrativeNotes = array_slice($narrativeNotes, 0, 4); $fmtNoteDate = function (string $iso) { if ($iso === '') return ''; try { return (new \DateTimeImmutable(substr($iso, 0, 19)))->format('M j'); } catch (\Exception $e) { return ''; } }; @endphp
{{ __('stakeholder.overview.narrative_label') }}
@forelse ($narrativeNotes as $n)
{{ $n['label'] }} — {{ $n['text'] }} @if (($d = $fmtNoteDate($n['date'])) !== '') {{ $d }} @endif
@empty
{{ __('stakeholder.overview.narrative_empty') }}
@endforelse
{{-- ── Theory of Change narrative (stage-colored) ────────────────── --}} @if ($hasLM && ! empty($logicModel['narrative']['hasItems'])) @php $stageTexts = $logicModel['narrative']['stageTexts'] ?? []; @endphp @else
{{ __('stakeholder.overview.toc_empty') }}
@endif {{-- ── Theory-health strip — full state, not just warnings ──────── --}} @if ($hasLM) @php $badges = $logicModel['healthBadges'] ?? []; // Ensure we render all 4 connector slots even when a row is missing; // the missing case reads as "no assessment yet" (grey), not "ok". $bySlot = []; for ($i = 1; $i <= 4; $i++) { $b = $badges[$i] ?? null; $status = $b && ! empty($b['has_data']) ? (string) ($b['health_status'] ?? '') : ''; $bySlot[$i] = [ 'status' => $status !== '' ? $status : 'none', 'label' => $b['connector_label'] ?? '', 'assumption' => trim((string) ($b['assumption_text'] ?? '')), 'evidence' => trim((string) ($b['evidence_notes'] ?? '')), ]; } $counts = ['ok' => 0, 'warning' => 0, 'risk' => 0, 'none' => 0]; $risky = []; foreach ($bySlot as $slot) { $counts[$slot['status']]++; if (in_array($slot['status'], ['warning', 'risk'], true)) { $risky[] = $slot; } } // Sort by severity so a `risk` link leads the detail callout over any // `warning` links — critical always takes priority in the board's read. usort($risky, fn ($a, $b) => ($b['status'] === 'risk' ? 1 : 0) <=> ($a['status'] === 'risk' ? 1 : 0)); $solid = $counts['ok']; $fragile = $counts['warning'] + $counts['risk']; $unassessed = $counts['none']; $tone = $fragile > 0 ? 'risk' : ($unassessed > 0 ? 'partial' : 'solid'); @endphp
{{ __('stakeholder.overview.theory_health_label') }} {{ __('stakeholder.overview.theory_health_explain') }} @if ($fragile === 0 && $unassessed === 0) {{ sprintf(__('stakeholder.overview.theory_all_solid'), $solid) }} @elseif ($fragile === 0) {{ sprintf(__('stakeholder.overview.theory_solid_and_unassessed'), $solid, $unassessed) }} @else {{ sprintf(__('stakeholder.overview.theory_summary_mixed'), $solid, $fragile) }}@if ($unassessed > 0) · {{ sprintf(__('stakeholder.overview.theory_plus_unassessed'), $unassessed) }}@endif @endif
{{-- Chain: 5 stage chips + 4 colored connectors between. Each connector is a line with the health icon as a badge; hover for detail. --}} @php $stageNames = [ 1 => __('box.logicmodel.inputs'), 2 => __('box.logicmodel.activities'), 3 => __('box.logicmodel.outputs'), 4 => __('box.logicmodel.outcomes'), 5 => __('box.logicmodel.impact'), ]; @endphp
@for ($s = 1; $s <= 5; $s++) {{ $stageNames[$s] }} @if ($s < 5) @php $slot = $bySlot[$s]; @endphp
{{-- fa-circle-* family, matching the LM canvas's status pills (Logicmodelcanvas::STATUS_LABELS uses the same set). --}}
@if ($slot['assumption'] !== '' || $slot['evidence'] !== '')
{{ $slot['label'] !== '' ? $slot['label'] : 'Link '.$s }} @if ($slot['assumption'] !== '') {{ $slot['assumption'] }} @endif @if ($slot['evidence'] !== '')
{{ __('stakeholder.overview.theory_evidence') }}{{ $slot['evidence'] }}
@elseif ($slot['status'] !== 'ok')
{{ __('stakeholder.overview.theory_no_evidence_short') }}
@endif
@endif
@endif @endfor
{{-- Fragile-link detail. Only if there's something to flag. --}} @if ($fragile > 0) @php $first = $risky[0]; @endphp @php $others = array_slice($risky, 1); $otherCount = count($others); @endphp
{{ $first['label'] }} {{ $first['status'] === 'risk' ? __('stakeholder.overview.theory_is_critical') : __('stakeholder.overview.theory_needs_work') }} @if ($first['assumption'] !== '') — {{ __('stakeholder.overview.theory_it_rests_on') }} {{ $first['assumption'] }} @endif @if ($first['evidence'] === '') {{ __('stakeholder.overview.theory_unproven') }} {{ __('stakeholder.overview.theory_unproven_explain') }} @endif
{{-- Secondary fragile links as compact "Also fragile" chip row — visible (not hidden in a tooltip) so a board sees the full fragile set at once. --}} @if ($otherCount > 0)
{{ __('stakeholder.overview.theory_also_fragile') }} @foreach ($others as $o) {{ $o['label'] }} @endforeach
@endif @else
{{ __('stakeholder.overview.theory_all_ok') }}
@endif
@endif