627 lines
33 KiB
PHP
627 lines
33 KiB
PHP
{{--
|
|
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
|
|
<div class="rd-kpi">
|
|
{{-- Completed --}}
|
|
<div class="rd-kcell @if ($completedCount > 0) has-detail @endif" tabindex="{{ $completedCount > 0 ? 0 : -1 }}">
|
|
@if ($completedCount > 0)
|
|
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
|
@endif
|
|
<div class="kv">
|
|
{{ $completedCount }}@if ($totalMsCount > 0)<small>/{{ $totalMsCount }}</small>@endif
|
|
@if ($completedDelta > 0)
|
|
<span class="up" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-up"></i> +{{ $completedDelta }}</span>
|
|
@elseif ($completedDelta < 0)
|
|
<span class="down" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-down"></i> {{ $completedDelta }}</span>
|
|
@endif
|
|
</div>
|
|
<div class="kl">{{ $totalMsCount > 0 ? __('stakeholder.kpi.milestones_completed') : __('stakeholder.kpi.completed_this_period') }}</div>
|
|
@if ($completedCount > 0)
|
|
<div class="kdrill">
|
|
<div class="kd-hd">{{ __('stakeholder.kpi.drill.completed') }}</div>
|
|
<ul>
|
|
@foreach ($completedItems as $m)
|
|
@php $m = (object) $m; @endphp
|
|
<li>
|
|
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
|
|
<span class="mt">{{ $fmtDate($m->completedOn ?? $m->modified ?? null) }}</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@if ($completedMoreCount > 0)
|
|
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $completedMoreCount) }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Goals on track — drill lists the ON-TRACK goals (what the count is) --}}
|
|
<div class="rd-kcell @if (count($onTrackItems) > 0) has-detail @endif" tabindex="{{ count($onTrackItems) > 0 ? 0 : -1 }}">
|
|
@if (count($onTrackItems) > 0)
|
|
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
|
@endif
|
|
<div class="kv">{{ $goalsOnTrack }}<small>/{{ $goalsTotal }}</small></div>
|
|
<div class="kl">{{ __('stakeholder.kpi.goals_on_track_lc') }}</div>
|
|
@if (count($onTrackItems) > 0)
|
|
<div class="kdrill">
|
|
<div class="kd-hd">{{ __('stakeholder.kpi.drill.on_track') }}</div>
|
|
<ul>
|
|
@foreach ($onTrackItems as $g)
|
|
@php $g = (object) $g; @endphp
|
|
<li>
|
|
<span class="nm" title="{{ $g->title ?? '' }}">{{ $g->title ?? $g->description ?? __('stakeholder.goals.untitled') }}</span>
|
|
<span class="mt">{{ round((float) ($g->goalProgress ?? 0)) }}%</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@if ($onTrackMoreCount > 0)
|
|
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $onTrackMoreCount) }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Overdue milestones --}}
|
|
<div class="rd-kcell @if ($overdueCount > 0) risk @endif @if ($overdueCount > 0) has-detail @endif" tabindex="{{ $overdueCount > 0 ? 0 : -1 }}">
|
|
@if ($overdueCount > 0)
|
|
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
|
@endif
|
|
<div class="kv">{{ $overdueCount }}@if ($openMsCount > 0)<small>/{{ $openMsCount }}</small>@endif</div>
|
|
<div class="kl">{{ $openMsCount > 0 ? __('stakeholder.kpi.overdue_of_open') : __('stakeholder.kpi.milestones_overdue') }}</div>
|
|
@if ($overdueCount > 0)
|
|
<div class="kdrill">
|
|
<div class="kd-hd">{{ __('stakeholder.kpi.drill.overdue') }}</div>
|
|
<ul>
|
|
@foreach ($overdueItems as $m)
|
|
@php $m = (object) $m; @endphp
|
|
<li>
|
|
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
|
|
<span class="mt">{{ $m->projectName ?? '' }}</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@if ($overdueMoreCount > 0)
|
|
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $overdueMoreCount) }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Hours logged — drill lists per-project breakdown, largest first --}}
|
|
<div class="rd-kcell @if (count($hoursItems) > 0) has-detail @endif" tabindex="{{ count($hoursItems) > 0 ? 0 : -1 }}">
|
|
@if (count($hoursItems) > 0)
|
|
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
|
@endif
|
|
<div class="kv">{{ number_format($hoursLogged, $hoursLogged >= 100 ? 0 : 1) }}<small>h</small></div>
|
|
<div class="kl">{{ __('stakeholder.kpi.hours_this_period') }}</div>
|
|
@if (count($hoursItems) > 0)
|
|
<div class="kdrill">
|
|
<div class="kd-hd">{{ __('stakeholder.kpi.drill.hours') }}</div>
|
|
<ul>
|
|
@foreach ($hoursItems as $h)
|
|
<li>
|
|
<span class="nm" title="{{ $h['name'] }}">{{ $h['name'] }}</span>
|
|
<span class="mt">{{ number_format($h['hours'], $h['hours'] >= 100 ? 0 : 1) }}h</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@if ($hoursMoreCount > 0)
|
|
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $hoursMoreCount) }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── 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
|
|
<div class="p1-topband">
|
|
{{-- Peak this period — recommend-and-override. Ranked from completed
|
|
milestones; owner override is a future write path. --}}
|
|
@if ($peak === null)
|
|
<div class="p1-hero empty">
|
|
<div class="eye"><span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span></div>
|
|
<div class="h">{{ __('stakeholder.overview.peak_none_title') }}</div>
|
|
<div>{{ __('stakeholder.overview.peak_none_hint') }}</div>
|
|
@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. --}}
|
|
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="p1-hero-cta">
|
|
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.overview.peak_none_cta') }}
|
|
</a>
|
|
@endif
|
|
</div>
|
|
@else
|
|
<div class="p1-hero">
|
|
<div class="eye">
|
|
<span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span>
|
|
<span class="rec"><i class="fa fa-wand-magic-sparkles"></i> {{ __('stakeholder.overview.peak_recommended') }}</span>
|
|
</div>
|
|
<h3>{{ $peak->headline ?? '' }}</h3>
|
|
@if ($peakBody !== '')
|
|
<p>{{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}</p>
|
|
@endif
|
|
<div class="hf">
|
|
@if (! empty($peak->projectName))
|
|
<span class="badge-goal"><i class="fa fa-diagram-project"></i> {{ $peak->projectName }}</span>
|
|
@endif
|
|
@if (! $peakIsStrong)
|
|
<span class="rec-note" title="{{ __('stakeholder.overview.peak_weak_tip') }}"><i class="fa fa-circle-info"></i> {{ __('stakeholder.overview.peak_weak_note') }}</span>
|
|
@endif
|
|
@if ($peakDate !== '')
|
|
<span class="hm">{{ $peakDate }}</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="p1-needs @if ($needsCount === 0) calm @endif">
|
|
<div class="nt"><i class="fa @if ($needsCount === 0) fa-check-circle @else fa-triangle-exclamation @endif"></i>{{ __('stakeholder.overview.needs_label') }}</div>
|
|
<div class="nx">
|
|
@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)
|
|
<div class="na-grp">
|
|
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_goals_hd'), count($goalsAtRisk)) }}</div>
|
|
<ul class="na-items">
|
|
@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
|
|
<li>
|
|
<span class="na-name">{{ $title }}</span>
|
|
<span class="na-badge @if ($isMiss) miss @endif">{{ $isMiss ? __('stakeholder.goals.miss') : __('stakeholder.goals.atrisk') }}</span>
|
|
<span class="na-meta">· {{ $progress }}%</span>
|
|
</li>
|
|
@endforeach
|
|
@if (count($goalsAtRisk) > 3)
|
|
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($goalsAtRisk) - 3) }}</li>
|
|
@endif
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Overdue milestones — named + project. --}}
|
|
@if (count($overdueMilestones) > 0)
|
|
<div class="na-grp">
|
|
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_milestones_hd'), count($overdueMilestones)) }}</div>
|
|
<ul class="na-items">
|
|
@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
|
|
<li>
|
|
<span class="na-name">{{ $mname }}</span>
|
|
@if ($projName !== '')
|
|
<span class="na-meta">· {{ $projName }}</span>
|
|
@endif
|
|
</li>
|
|
@endforeach
|
|
@if (count($overdueMilestones) > 3)
|
|
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($overdueMilestones) - 3) }}</li>
|
|
@endif
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Stale/silent projects — quiet flag, one line per project. --}}
|
|
@if (count($staleProjects) > 0)
|
|
<div class="na-grp">
|
|
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_silent_hd'), count($staleProjects)) }}</div>
|
|
<ul class="na-items">
|
|
@foreach (array_slice($staleProjects, 0, 3) as $p)
|
|
@php $p = (object) $p; @endphp
|
|
<li>
|
|
<span class="na-name">{{ $p->name ?? '' }}</span>
|
|
<span class="na-meta">· {{ __('stakeholder.overview.na_no_update_30d') }}</span>
|
|
</li>
|
|
@endforeach
|
|
@if (count($staleProjects) > 3)
|
|
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($staleProjects) - 3) }}</li>
|
|
@endif
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── 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
|
|
|
|
<div class="p1-narrative">
|
|
<div class="lb"><i class="fa fa-message"></i> {{ __('stakeholder.overview.narrative_label') }}</div>
|
|
<div class="nn">
|
|
@forelse ($narrativeNotes as $n)
|
|
<div class="nr @if ($n['portfolio']) portfolio @endif">
|
|
<b>{{ $n['label'] }}</b> — {{ $n['text'] }}
|
|
@if (($d = $fmtNoteDate($n['date'])) !== '')
|
|
<span class="dt">{{ $d }}</span>
|
|
@endif
|
|
</div>
|
|
@empty
|
|
<div class="nempty">{{ __('stakeholder.overview.narrative_empty') }}</div>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Theory of Change narrative (stage-colored) ────────────────── --}}
|
|
@if ($hasLM && ! empty($logicModel['narrative']['hasItems']))
|
|
@php $stageTexts = $logicModel['narrative']['stageTexts'] ?? []; @endphp
|
|
<div class="p1-toc collapsed" id="p1TocSection">
|
|
<div class="tl">
|
|
<span class="lbl-inner">
|
|
{{ __('stakeholder.overview.toc_label') }}
|
|
<span class="p1-info" tabindex="0">
|
|
<span class="ii" aria-hidden="true">i</span>
|
|
<span class="pop" role="tooltip">
|
|
<span class="h">{{ __('stakeholder.overview.color_legend') }}</span>
|
|
<span class="lg"><span class="sw" style="background:var(--rd-s1)"></span><span class="nm">{{ __('box.logicmodel.inputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.inputs') }}</span></span>
|
|
<span class="lg"><span class="sw" style="background:var(--rd-s2)"></span><span class="nm">{{ __('box.logicmodel.activities') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.activities') }}</span></span>
|
|
<span class="lg"><span class="sw" style="background:var(--rd-s3)"></span><span class="nm">{{ __('box.logicmodel.outputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outputs') }}</span></span>
|
|
<span class="lg"><span class="sw" style="background:var(--rd-s4)"></span><span class="nm">{{ __('box.logicmodel.outcomes') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outcomes') }}</span></span>
|
|
<span class="lg"><span class="sw" style="background:var(--rd-s5)"></span><span class="nm">{{ __('box.logicmodel.impact') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.impact') }}</span></span>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<button type="button" class="toc-toggle" onclick="p1TocToggle(this)" aria-label="{{ __('stakeholder.overview.toc_toggle') }}">
|
|
<i class="fa fa-chevron-down"></i>
|
|
</button>
|
|
</div>
|
|
<div class="tx">
|
|
{{ __('stakeholder.overview.toc_by_investing') }}
|
|
<span class="n1">{{ $stageTexts['inputs'] ?? '['.__('box.logicmodel.inputs').']' }}</span>
|
|
{{ __('stakeholder.overview.toc_and_delivering') }}
|
|
<span class="n2">{{ $stageTexts['activities'] ?? '['.__('box.logicmodel.activities').']' }}</span>,
|
|
{{ __('stakeholder.overview.toc_we_produce') }}
|
|
<span class="n3">{{ $stageTexts['outputs'] ?? '['.__('box.logicmodel.outputs').']' }}</span>
|
|
— {{ __('stakeholder.overview.toc_toward') }}
|
|
<span class="n4">{{ $stageTexts['outcomes'] ?? '['.__('box.logicmodel.outcomes').']' }}</span>,
|
|
{{ __('stakeholder.overview.toc_in_service_of') }}
|
|
<span class="n5">{{ $stageTexts['impact'] ?? '['.__('box.logicmodel.impact').']' }}</span>.
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="p1-toc empty">{{ __('stakeholder.overview.toc_empty') }}</div>
|
|
@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
|
|
|
|
<div class="p1-theory">
|
|
<div class="hd">
|
|
<span class="l">
|
|
{{ __('stakeholder.overview.theory_health_label') }}
|
|
<span class="p1-info" tabindex="0">
|
|
<span class="ii" aria-hidden="true">i</span>
|
|
<span class="pop" role="tooltip">
|
|
{{ __('stakeholder.overview.theory_health_explain') }}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<span class="rd @if ($fragile > 0) risk @endif">
|
|
@if ($fragile === 0 && $unassessed === 0)
|
|
<b>{{ sprintf(__('stakeholder.overview.theory_all_solid'), $solid) }}</b>
|
|
@elseif ($fragile === 0)
|
|
<b>{{ sprintf(__('stakeholder.overview.theory_solid_and_unassessed'), $solid, $unassessed) }}</b>
|
|
@else
|
|
<b>{{ sprintf(__('stakeholder.overview.theory_summary_mixed'), $solid, $fragile) }}</b>@if ($unassessed > 0) · {{ sprintf(__('stakeholder.overview.theory_plus_unassessed'), $unassessed) }}@endif
|
|
@endif
|
|
</span>
|
|
</div>
|
|
|
|
{{-- 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
|
|
<div class="segs">
|
|
@for ($s = 1; $s <= 5; $s++)
|
|
<span class="stage s{{ $s }}">{{ $stageNames[$s] }}</span>
|
|
@if ($s < 5)
|
|
@php $slot = $bySlot[$s]; @endphp
|
|
<div class="conn {{ $slot['status'] }}" tabindex="0">
|
|
<div class="badge">
|
|
{{-- fa-circle-* family, matching the LM canvas's status pills
|
|
(Logicmodelcanvas::STATUS_LABELS uses the same set). --}}
|
|
<i class="fa
|
|
@if ($slot['status'] === 'ok') fa-circle-check
|
|
@elseif ($slot['status'] === 'warning') fa-circle-exclamation
|
|
@elseif ($slot['status'] === 'risk') fa-triangle-exclamation
|
|
@else fa-circle-question
|
|
@endif"></i>
|
|
</div>
|
|
@if ($slot['assumption'] !== '' || $slot['evidence'] !== '')
|
|
<div class="tip">
|
|
<b>{{ $slot['label'] !== '' ? $slot['label'] : 'Link '.$s }}</b>
|
|
@if ($slot['assumption'] !== '')
|
|
<em>{{ $slot['assumption'] }}</em>
|
|
@endif
|
|
@if ($slot['evidence'] !== '')
|
|
<div style="margin-top:6px;"><b>{{ __('stakeholder.overview.theory_evidence') }}</b>{{ $slot['evidence'] }}</div>
|
|
@elseif ($slot['status'] !== 'ok')
|
|
<div style="margin-top:6px;opacity:.85;">{{ __('stakeholder.overview.theory_no_evidence_short') }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
@endfor
|
|
</div>
|
|
|
|
{{-- 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
|
|
<div class="detail @if ($first['status'] === 'risk') crit @endif">
|
|
<i class="fa @if ($first['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
|
|
<div>
|
|
<b>{{ $first['label'] }}</b> {{ $first['status'] === 'risk' ? __('stakeholder.overview.theory_is_critical') : __('stakeholder.overview.theory_needs_work') }}
|
|
@if ($first['assumption'] !== '')
|
|
— {{ __('stakeholder.overview.theory_it_rests_on') }} <em>{{ $first['assumption'] }}</em>
|
|
@endif
|
|
@if ($first['evidence'] === '')
|
|
<span class="p1-info detail-info evidence-tag" tabindex="0">
|
|
<i class="fa fa-circle-exclamation"></i>
|
|
<span class="tag">{{ __('stakeholder.overview.theory_unproven') }}</span>
|
|
<span class="pop" role="tooltip">{{ __('stakeholder.overview.theory_unproven_explain') }}</span>
|
|
</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 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)
|
|
<div class="also-fragile">
|
|
<span class="lbl">{{ __('stakeholder.overview.theory_also_fragile') }}</span>
|
|
@foreach ($others as $o)
|
|
<span class="af-chip {{ $o['status'] }}" title="{{ $o['assumption'] }}">
|
|
<i class="fa @if ($o['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
|
|
<span class="nm">{{ $o['label'] }}</span>
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
@else
|
|
<div class="calm-line">
|
|
<i class="fa fa-check-circle"></i>
|
|
{{ __('stakeholder.overview.theory_all_ok') }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
<script>
|
|
/* Theory of Change collapse — click chevron to toggle the narrative. Persists
|
|
the state in localStorage so it survives page reloads. */
|
|
(function () {
|
|
if (window.__p1TocInit) return;
|
|
window.__p1TocInit = true;
|
|
|
|
// Default state is COLLAPSED (rendered server-side with .collapsed). Only
|
|
// remove it if the user has previously explicitly expanded it.
|
|
var KEY = 'rd.p1.toc.collapsed';
|
|
var section = document.getElementById('p1TocSection');
|
|
if (section && localStorage.getItem(KEY) === '0') {
|
|
section.classList.remove('collapsed');
|
|
}
|
|
|
|
window.p1TocToggle = function (btn) {
|
|
var s = btn.closest('.p1-toc');
|
|
if (!s) return;
|
|
var isNowCollapsed = s.classList.toggle('collapsed');
|
|
try { localStorage.setItem(KEY, isNowCollapsed ? '1' : '0'); } catch (e) {}
|
|
};
|
|
})();
|
|
</script>
|