833 lines
42 KiB
PHP
833 lines
42 KiB
PHP
{{--
|
|
Stakeholder Report — Page 2 (Logic Model read-out)
|
|
|
|
Fully rewritten against the punch-list. Behavior contracts:
|
|
|
|
1. Goal-as-truth. If an item has any linked goal (projectLinks entry with
|
|
linked_entity_type='goal'), the goal record supplies BOTH current and
|
|
target values. The item description is display label only — never
|
|
parsed for a number in that case. Items with no goal link show no
|
|
denominator and no percent.
|
|
2. Guard: if aggregate target <= 0 or current/target > 5, drop the ratio
|
|
(impossible math never reaches a funder) and Log it.
|
|
3. Templated read: max 2 lines per stage, one going-well + one exception,
|
|
materiality-weighted at 20%. Vocabulary is fixed — no freeform prose.
|
|
4. Belief line is ONE sentence, ≤220 chars, built from first 3 activities
|
|
+ first impact.
|
|
5. Risk box: one weakest health badge, single sentence, single period.
|
|
6. Layout: max-width 900px, min-width:0 on every grid child, no
|
|
horizontal blowout at 1280px.
|
|
7. Status color map is fixed — at-risk is white bg + inset ring, never cream.
|
|
|
|
Vars in:
|
|
$logicModel null | {canvasId, narrative, stageProgress, healthBadges,
|
|
coverageMatrix, projectLinks, linkedGoals, projectMeta}
|
|
$hasLM bool
|
|
$scope 'strategy' | 'program'
|
|
--}}
|
|
|
|
|
|
@php
|
|
// Empty-state trigger is keyed on ITEM COUNT, not just board existence.
|
|
// A blank Logic Model board (0 items) makes $hasLM true but leaves the
|
|
// read-out hollow; the reverse flow itself creates a board on start, so
|
|
// gating only on board-existence would strand users in an empty skeleton.
|
|
// Treat "no board" and "board with no items" identically — both land on
|
|
// the populate-from-your-work empty-state below.
|
|
$lmStages = $hasLM ? ($logicModel['coverageMatrix']['stages'] ?? []) : [];
|
|
$lmHasContent = false;
|
|
foreach ($lmStages as $lmStage) {
|
|
if (count($lmStage['items'] ?? []) > 0) {
|
|
$lmHasContent = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Existing linked work in this strategy, for the empty-state's "we found
|
|
// your work" line. programRows carry type + projectCount; program rows
|
|
// count as programs, and projectCount sums to total leaf projects (direct
|
|
// + under programs). Empty at program scope, so the line self-hides there.
|
|
$lmProgramCount = 0;
|
|
$lmProjectCount = 0;
|
|
foreach ($programRows ?? [] as $lmRow) {
|
|
// programRows carries stdClass rows (object[], like page-programs), so
|
|
// cast before array access to avoid "Cannot use object as array".
|
|
$lmRow = (array) $lmRow;
|
|
if (($lmRow['type'] ?? '') === 'program') {
|
|
$lmProgramCount++;
|
|
}
|
|
$lmProjectCount += (int) ($lmRow['projectCount'] ?? 0);
|
|
}
|
|
// Bold the counts so the totals read as totals. Every piece is server-side
|
|
// (ints + translated nouns, both e()-escaped), so the {!! !!} render below
|
|
// carries no user input.
|
|
$lmFoundParts = [];
|
|
if ($lmProgramCount > 0) {
|
|
$lmFoundParts[] = '<b>'.$lmProgramCount.'</b> '.e(__($lmProgramCount === 1 ? 'stakeholder.lm.found_program' : 'stakeholder.lm.found_programs'));
|
|
}
|
|
if ($lmProjectCount > 0) {
|
|
$lmFoundParts[] = '<b>'.$lmProjectCount.'</b> '.e(__($lmProjectCount === 1 ? 'stakeholder.lm.found_project' : 'stakeholder.lm.found_projects'));
|
|
}
|
|
$lmFoundStr = implode('<span class="sep">·</span>', $lmFoundParts);
|
|
@endphp
|
|
|
|
@if (! $lmHasContent)
|
|
<div class="p2-wrap">
|
|
<div class="p2-lm-emptyzone">
|
|
<div class="p2-lm-empty">
|
|
<span class="ic"><i class="fa fa-diagram-project" aria-hidden="true"></i></span>
|
|
<h2 class="t">{{ __('stakeholder.lm.empty_title') }}</h2>
|
|
<div class="b">{{ __('stakeholder.lm.empty_body') }}</div>
|
|
@if (($scope ?? '') === 'strategy')
|
|
@if ($lmFoundStr !== '')
|
|
<div class="found">
|
|
<i class="fa fa-circle-check" aria-hidden="true"></i>
|
|
<span>{!! sprintf(e(__('stakeholder.lm.empty_found')), $lmFoundStr) !!}</span>
|
|
</div>
|
|
@endif
|
|
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="cta">
|
|
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.lm.empty_cta') }}
|
|
</a>
|
|
<div class="hint">{{ __('stakeholder.lm.empty_hint') }}</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
@php
|
|
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
|
|
$columns = $logicModel['coverageMatrix']['columns'] ?? [];
|
|
$unaligned = $logicModel['coverageMatrix']['unalignedColumns'] ?? [];
|
|
$healthBadges = $logicModel['healthBadges'] ?? [];
|
|
$projectLinks = $logicModel['projectLinks'] ?? [];
|
|
$linkedGoals = $logicModel['linkedGoals'] ?? [];
|
|
$projectMeta = $logicModel['projectMeta'] ?? [];
|
|
|
|
$activityItems = $stages['activities']['items'] ?? [];
|
|
$outputItems = $stages['outputs']['items'] ?? [];
|
|
$outcomeItems = $stages['outcomes']['items'] ?? [];
|
|
$impactItems = $stages['impact']['items'] ?? [];
|
|
|
|
$isEvaluated = fn ($item) => isset($projectLinks[(int) (((array) $item)['id'] ?? 0)])
|
|
&& count($projectLinks[(int) (((array) $item)['id'] ?? 0)]) > 0;
|
|
|
|
// Belief line — templated, ≤220 chars.
|
|
$activityDescs = array_slice(
|
|
array_values(array_filter(array_map(fn ($it) => trim((string) (((array) $it)['description'] ?? '')), $activityItems))),
|
|
0, 3
|
|
);
|
|
$activitiesSummary = '';
|
|
if (count($activityDescs) > 0) {
|
|
$lowered = array_map(fn ($s) => (mb_strtolower(mb_substr($s, 0, 1)) . mb_substr($s, 1)), $activityDescs);
|
|
$activitiesSummary = count($lowered) === 1
|
|
? $lowered[0]
|
|
: (count($lowered) === 2
|
|
? $lowered[0] . ' and ' . $lowered[1]
|
|
: $lowered[0] . ', ' . $lowered[1] . ', and ' . $lowered[2]);
|
|
}
|
|
$impactSummary = '';
|
|
if (count($impactItems) > 0) {
|
|
$impactSummary = trim((string) (((array) $impactItems[0])['description'] ?? ''));
|
|
// lowercase first char for grammar
|
|
if ($impactSummary !== '') $impactSummary = mb_strtolower(mb_substr($impactSummary, 0, 1)) . mb_substr($impactSummary, 1);
|
|
}
|
|
$beliefLine = '';
|
|
if ($activitiesSummary !== '' && $impactSummary !== '') {
|
|
$beliefLine = sprintf(__('stakeholder.lm.belief_full'), $activitiesSummary, $impactSummary);
|
|
} elseif ($activitiesSummary !== '') {
|
|
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
|
|
}
|
|
if (mb_strlen($beliefLine) > 220 && $activitiesSummary !== '') {
|
|
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
|
|
}
|
|
|
|
// Infer the item's expected metric unit ONCE from its authored label.
|
|
// Cached in $itemExpectedUnit so render never regexes. `%` → percent,
|
|
// `$` → currency, anything else → number. This is the label's contract;
|
|
// goals whose metricType disagrees are dropped from the item's rollup
|
|
// (rather than silently pretending unrelated units are the same thing).
|
|
$itemExpectedUnit = [];
|
|
$inferItemUnit = function (string $desc): string {
|
|
$d = trim($desc);
|
|
if ($d === '') return 'number';
|
|
if (str_contains($d, '%')) return 'percent';
|
|
if (str_contains($d, '$')) return 'currency';
|
|
return 'number';
|
|
};
|
|
foreach (array_merge($outputItems, $outcomeItems, $activityItems, $impactItems) as $it) {
|
|
$iid = (int) (((array) $it)['id'] ?? 0);
|
|
if ($iid > 0) $itemExpectedUnit[$iid] = $inferItemUnit((string) (((array) $it)['description'] ?? ''));
|
|
}
|
|
|
|
// Goal-based aggregate for an item. Only goals whose unit matches the
|
|
// item's expected unit contribute — mismatched goals are logged and
|
|
// dropped (safety over silent lying). Returns null when no matching
|
|
// goals exist, or the ratio is impossible.
|
|
$itemGoalAggregate = function ($item) use ($projectLinks, $linkedGoals, $itemExpectedUnit) {
|
|
$itemId = (int) (((array) $item)['id'] ?? 0);
|
|
$expectedUnit = $itemExpectedUnit[$itemId] ?? 'number';
|
|
$links = $projectLinks[$itemId] ?? [];
|
|
$goals = [];
|
|
$mismatched = 0;
|
|
foreach ($links as $link) {
|
|
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
|
|
$gid = (int) ($link['linked_entity_id'] ?? 0);
|
|
if (! isset($linkedGoals[$gid])) continue;
|
|
$g = $linkedGoals[$gid];
|
|
$gUnit = ($g['metricType'] ?? 'number') === 'percent' ? 'percent'
|
|
: (($g['metricType'] ?? 'number') === 'currency' ? 'currency' : 'number');
|
|
if ($gUnit !== $expectedUnit) {
|
|
$mismatched++;
|
|
\Illuminate\Support\Facades\Log::info('page2: unit mismatch on link', [
|
|
'itemId' => $itemId, 'expected' => $expectedUnit,
|
|
'goalId' => $gid, 'goalUnit' => $gUnit,
|
|
]);
|
|
continue;
|
|
}
|
|
$goals[] = $g;
|
|
}
|
|
if (count($goals) === 0) return null;
|
|
|
|
$current = array_sum(array_column($goals, 'currentValue'));
|
|
$end = array_sum(array_column($goals, 'endValue'));
|
|
$hasTarget = $end > 0;
|
|
$ratio = $hasTarget ? ($current / $end) : null;
|
|
if ($ratio !== null && $ratio > 5) {
|
|
\Illuminate\Support\Facades\Log::warning('page2: implausible ratio', ['itemId' => $itemId, 'current' => $current, 'target' => $end]);
|
|
$hasTarget = false;
|
|
}
|
|
|
|
$anyRisk = false; $allOnTrack = true;
|
|
foreach ($goals as $g) {
|
|
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') $anyRisk = true;
|
|
if ($g['status'] !== 'status_ontrack') $allOnTrack = false;
|
|
}
|
|
return [
|
|
'goals' => $goals,
|
|
'current' => $current,
|
|
'end' => $end,
|
|
'hasTarget' => $hasTarget,
|
|
'pct' => $hasTarget ? min(100, (int) round($ratio * 100)) : 0,
|
|
'metricType' => $expectedUnit,
|
|
'ragClass' => $anyRisk ? 'risk' : ($allOnTrack ? 'ok' : 'wip'),
|
|
'mismatched' => $mismatched,
|
|
];
|
|
};
|
|
|
|
// Format a metric respecting its type (percent vs number).
|
|
$fmtMetric = function ($value, string $metricType) {
|
|
$value = (float) $value;
|
|
if ($metricType === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
|
|
if ($value == floor($value)) return number_format($value, 0, '.', ',');
|
|
return number_format($value, 1, '.', ',');
|
|
};
|
|
|
|
// Strip leading numeric token from label when it's redundant with a goal
|
|
// aggregate. "1,200 screenings completed" → "screenings completed".
|
|
$stripLeadingNumber = function (string $desc): string {
|
|
if (preg_match('/^[\d][\d,\.]*(?:%|\+|)?\s+(.+)$/u', trim($desc), $m)) {
|
|
return trim($m[1]);
|
|
}
|
|
return trim($desc);
|
|
};
|
|
|
|
// Stage-level aggregate — total contributions across items in the stage.
|
|
$stageAgg = function (array $items) use ($isEvaluated, $itemGoalAggregate) {
|
|
$total = 0.0; $end = 0.0; $anyRisk = false; $rolled = [];
|
|
foreach ($items as $item) {
|
|
if (! $isEvaluated($item)) continue;
|
|
$a = $itemGoalAggregate($item);
|
|
if ($a === null) continue;
|
|
$itemId = (int) (((array) $item)['id'] ?? 0);
|
|
$rolled[$itemId] = $a;
|
|
$total += $a['current'];
|
|
$end += $a['end'];
|
|
if ($a['ragClass'] === 'risk') $anyRisk = true;
|
|
}
|
|
$pct = $end > 0 ? min(100, (int) round(($total / $end) * 100)) : 0;
|
|
return ['rolled' => $rolled, 'pct' => $pct, 'anyRisk' => $anyRisk, 'total' => $total, 'end' => $end];
|
|
};
|
|
|
|
// Per-program contribution across a stage — for Show-the-breakdown and
|
|
// the templated read. Returns {contributors, unresolvedShare}.
|
|
//
|
|
// A goal is UNRESOLVED when its owning project is missing, has no
|
|
// parent program, or is itself the strategy — those are never
|
|
// contributors (a strategy is not its own program; a null bucket is
|
|
// not a colleague). Unresolved value is tracked separately so the
|
|
// page can render an honest data-quality note without personifying
|
|
// the null bucket as a named entity.
|
|
$stageProgramRollup = function (array $items) use ($isEvaluated, $itemGoalAggregate, $projectMeta) {
|
|
$perProgram = [];
|
|
$unresolvedCurrent = 0.0;
|
|
$unresolvedEnd = 0.0;
|
|
|
|
foreach ($items as $item) {
|
|
if (! $isEvaluated($item)) continue;
|
|
$a = $itemGoalAggregate($item);
|
|
if ($a === null) continue;
|
|
|
|
$itemId = (int) (((array) $item)['id'] ?? 0);
|
|
$itemDesc = trim((string) (((array) $item)['description'] ?? ''));
|
|
|
|
foreach ($a['goals'] as $g) {
|
|
$pid = $g['projectId'];
|
|
$progId = $g['programId'];
|
|
// Unresolved: no project, no program, or the "project" is
|
|
// actually a strategy row (which happens when a goal lives
|
|
// on the strategy's own canvas).
|
|
$isUnresolved = ($pid === null)
|
|
|| ($progId === null)
|
|
|| (($g['projectType'] ?? '') === 'strategy');
|
|
if ($isUnresolved) {
|
|
$unresolvedCurrent += $g['currentValue'];
|
|
$unresolvedEnd += $g['endValue'];
|
|
continue;
|
|
}
|
|
|
|
$key = $progId;
|
|
if (! isset($perProgram[$key])) {
|
|
$perProgram[$key] = [
|
|
'id' => $progId,
|
|
'name' => $projectMeta[$pid]['programName'] ?? ('#'.$progId),
|
|
'current' => 0.0,
|
|
'end' => 0.0,
|
|
'anyRisk' => false,
|
|
'allOnTrack' => true,
|
|
'projects' => [],
|
|
'riskItems' => [], // {id, description} for citation in exception line
|
|
];
|
|
}
|
|
$perProgram[$key]['current'] += $g['currentValue'];
|
|
$perProgram[$key]['end'] += $g['endValue'];
|
|
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') {
|
|
$perProgram[$key]['anyRisk'] = true;
|
|
// Track the ITEM this contributor caused risk on — the read
|
|
// uses it to make exception lines cite what's off.
|
|
$alreadyTracked = false;
|
|
foreach ($perProgram[$key]['riskItems'] as $ri) {
|
|
if ($ri['id'] === $itemId) { $alreadyTracked = true; break; }
|
|
}
|
|
if (! $alreadyTracked) {
|
|
$perProgram[$key]['riskItems'][] = ['id' => $itemId, 'description' => $itemDesc];
|
|
}
|
|
}
|
|
if ($g['status'] !== 'status_ontrack') $perProgram[$key]['allOnTrack'] = false;
|
|
|
|
// Project row aggregation inside program. Skip strategy-typed
|
|
// projects (defense-in-depth: already filtered above).
|
|
if (($g['projectType'] ?? '') === 'strategy') continue;
|
|
|
|
$found = false;
|
|
foreach ($perProgram[$key]['projects'] as &$pj) {
|
|
if ($pj['id'] === $pid) {
|
|
$pj['current'] += $g['currentValue'];
|
|
$pj['end'] += $g['endValue'];
|
|
if ($g['status'] === 'status_atrisk') $pj['ragClass'] = 'risk';
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
unset($pj);
|
|
if (! $found) {
|
|
$perProgram[$key]['projects'][] = [
|
|
'id' => $pid,
|
|
'name' => $projectMeta[$pid]['name'] ?? ('#'.$pid),
|
|
'current' => $g['currentValue'],
|
|
'end' => $g['endValue'],
|
|
'ragClass' => $g['status'] === 'status_atrisk' ? 'risk' : ($g['status'] === 'status_ontrack' ? 'ok' : 'wip'),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Renormalize shares over RESOLVED contributors only. Unresolved
|
|
// value doesn't dilute the lead's share — it's tracked separately
|
|
// as a data-quality signal.
|
|
$resolvedTotal = array_sum(array_column($perProgram, 'current'));
|
|
foreach ($perProgram as &$prog) {
|
|
$prog['pct'] = $resolvedTotal > 0 ? (int) round(($prog['current'] / $resolvedTotal) * 100) : 0;
|
|
$prog['ragClass'] = $prog['anyRisk'] ? 'risk' : ($prog['allOnTrack'] ? 'ok' : 'wip');
|
|
$prog['statusWord'] = $prog['anyRisk'] ? __('stakeholder.lm.status_word_behind') : ($prog['allOnTrack'] ? __('stakeholder.lm.status_word_ontrack') : __('stakeholder.lm.status_word_ramping'));
|
|
usort($prog['projects'], fn ($a, $b) => $b['current'] <=> $a['current']);
|
|
}
|
|
unset($prog);
|
|
uasort($perProgram, fn ($a, $b) => $b['pct'] <=> $a['pct']);
|
|
|
|
$grandTotal = $resolvedTotal + $unresolvedCurrent;
|
|
$unresolvedShare = $grandTotal > 0 ? (int) round(($unresolvedCurrent / $grandTotal) * 100) : 0;
|
|
|
|
return [
|
|
'contributors' => array_values($perProgram),
|
|
'unresolvedShare' => $unresolvedShare,
|
|
'hasUnresolved' => $unresolvedCurrent > 0 || $unresolvedEnd > 0,
|
|
];
|
|
};
|
|
|
|
// Templated read — max 2 lines. Vocabulary is fixed per punch-list §2.
|
|
// BRANCHES ON CONTRIBUTOR COUNT:
|
|
// 1 → single-contributor sentence (scoped to the stage — "on outputs
|
|
// here", not bare "on this" — so cross-card contradictions read
|
|
// as legitimate scope differences)
|
|
// 2 → lead is carrying + second adds the rest
|
|
// 3+ → lead + weighted exception, materiality threshold 20%
|
|
// Exception lines CITE THE ITEM the contributor is off on — the row
|
|
// status and the read reconcile via the item name.
|
|
$renderRead = function (array $programs, string $scopeLabel) {
|
|
$n = count($programs);
|
|
if ($n === 0) return [];
|
|
|
|
$statusLead = fn ($p) => match ($p['ragClass']) {
|
|
'ok' => '<span class="g">' . e($p['statusWord']) . '</span>',
|
|
'risk' => '<span class="r">' . e($p['statusWord']) . '</span>',
|
|
default => '<span class="w">' . e($p['statusWord']) . '</span>',
|
|
};
|
|
|
|
// ── Single-contributor branch: no shares, no driver language.
|
|
// Scoped ("outputs here" / "outcomes here") so a program appearing
|
|
// in both cards with different statuses reads as legitimate scope,
|
|
// not contradiction.
|
|
if ($n === 1) {
|
|
$only = $programs[0];
|
|
$type = $only['ragClass'] === 'risk' ? 'risk' : ($only['ragClass'] === 'ok' ? 'good' : 'watch');
|
|
return [[
|
|
'type' => $type,
|
|
'html' => sprintf(
|
|
__('stakeholder.lm.read_only_program_scoped'),
|
|
e($only['name']),
|
|
e($scopeLabel),
|
|
$statusLead($only)
|
|
),
|
|
]];
|
|
}
|
|
|
|
$lead = $programs[0];
|
|
$second = $programs[1] ?? null;
|
|
$lines = [];
|
|
|
|
// Helper: name the specific item this contributor is off on.
|
|
// Cites the first risk item; if none, empty string skips the
|
|
// "on X" clause.
|
|
$riskItemName = function (array $prog): string {
|
|
$items = $prog['riskItems'] ?? [];
|
|
return count($items) > 0 ? (string) ($items[0]['description'] ?? '') : '';
|
|
};
|
|
|
|
$exceptionLine = function (array $prog, bool $critical) use ($statusLead, $riskItemName) {
|
|
$item = $riskItemName($prog);
|
|
$onClause = $item !== '' ? sprintf(__('stakeholder.lm.read_on_item_clause'), e($item)) : '';
|
|
$tmpl = $critical ? 'stakeholder.lm.read_critical_cited' : 'stakeholder.lm.read_watch_cited';
|
|
// Order: NAME is STATUS_WORD on ITEM — but at X% ...
|
|
// statusLead comes BEFORE onClause; onClause carries its own
|
|
// leading space so tokens don't run together.
|
|
return [
|
|
'type' => $critical ? 'risk' : 'watch',
|
|
'html' => '<span class="lead-label">' . e($critical ? __('stakeholder.lm.critical_label') : __('stakeholder.lm.watch_label')) . ':</span> '
|
|
. sprintf(
|
|
__($tmpl),
|
|
e($prog['name']),
|
|
$statusLead($prog),
|
|
$onClause,
|
|
$prog['pct']
|
|
),
|
|
];
|
|
};
|
|
|
|
// ── Two-contributor branch: lead + second.
|
|
// If second is the exception, DROP the "adds the rest" clause —
|
|
// otherwise it names the second twice (benign in line 1, behind
|
|
// in line 2). Never two lines about the same entity.
|
|
if ($n === 2) {
|
|
$secondIsException = $second['ragClass'] !== 'ok';
|
|
$goingWellHtml = sprintf(
|
|
__('stakeholder.lm.read_going_well'),
|
|
e($lead['name']),
|
|
$lead['pct'],
|
|
$statusLead($lead)
|
|
);
|
|
if (! $secondIsException) {
|
|
$goingWellHtml .= sprintf(__('stakeholder.lm.read_second_clause'), e($second['name']));
|
|
}
|
|
$lines[] = ['type' => 'good', 'html' => $goingWellHtml];
|
|
if ($secondIsException) {
|
|
$lines[] = $exceptionLine($second, $second['pct'] >= 20);
|
|
}
|
|
return $lines;
|
|
}
|
|
|
|
// ── 3+ contributor branch: lead + weighted exception.
|
|
$exception = null;
|
|
foreach ($programs as $p) {
|
|
if ($p['ragClass'] !== 'ok') { $exception = $p; break; }
|
|
}
|
|
|
|
$leadIsException = $exception !== null && $exception['id'] === $lead['id'];
|
|
if (! $leadIsException) {
|
|
$lines[] = [
|
|
'type' => 'good',
|
|
'html' => sprintf(
|
|
__('stakeholder.lm.read_going_well'),
|
|
e($lead['name']),
|
|
$lead['pct'],
|
|
$statusLead($lead)
|
|
),
|
|
];
|
|
}
|
|
|
|
if ($exception !== null) {
|
|
$lines[] = $exceptionLine($exception, $exception['pct'] >= 20 || $leadIsException);
|
|
}
|
|
|
|
return $lines;
|
|
};
|
|
|
|
// Weakest fragile link for the Risk block — one, not four.
|
|
$fragileLink = null;
|
|
$riskPref = ['risk' => 0, 'warning' => 1];
|
|
foreach ($healthBadges as $badge) {
|
|
$s = $badge['health_status'] ?? '';
|
|
if (! isset($riskPref[$s])) continue;
|
|
if ($fragileLink === null || $riskPref[$s] < $riskPref[$fragileLink['health_status']]) {
|
|
$fragileLink = $badge;
|
|
continue;
|
|
}
|
|
// Tie-break on risk_level (higher = worse).
|
|
if ($s === $fragileLink['health_status'] && ((int) ($badge['risk_level'] ?? 0)) > ((int) ($fragileLink['risk_level'] ?? 0))) {
|
|
$fragileLink = $badge;
|
|
}
|
|
}
|
|
@endphp
|
|
|
|
<div class="p2-wrap">
|
|
|
|
<div class="p2-subhead">{{ __('stakeholder.lm.page_subhead') }}</div>
|
|
|
|
{{-- Belief — one templated sentence --}}
|
|
<div class="p2-believe">
|
|
<span class="lb">{{ __('stakeholder.lm.believe_label') }}</span>
|
|
@if ($beliefLine !== '')
|
|
{{ $beliefLine }}
|
|
@else
|
|
{{ __('stakeholder.lm.belief_empty') }}
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Stage card renderer — one call for Outputs, one for Outcomes. --}}
|
|
@foreach ([
|
|
['key' => 'outputs', 'items' => $outputItems, 'frame' => __('stakeholder.lm.frame_producing_outputs'), 'icon' => 'fa-boxes-stacked', 'cls' => 'outputs'],
|
|
['key' => 'outcomes', 'items' => $outcomeItems, 'frame' => __('stakeholder.lm.frame_achieving_outcomes'), 'icon' => 'fa-chart-line', 'cls' => 'outcomes'],
|
|
] as $stage)
|
|
@php
|
|
$stageItems = array_values(array_filter($stage['items'], $isEvaluated));
|
|
if (count($stageItems) === 0) continue;
|
|
|
|
$agg = $stageAgg($stage['items']);
|
|
$rollupResult = $stageProgramRollup($stage['items']);
|
|
$rollup = $rollupResult['contributors'];
|
|
$scopeLabel = $stage['key'] === 'outputs' ? __('stakeholder.lm.scope_outputs') : __('stakeholder.lm.scope_outcomes');
|
|
$readLns = $renderRead($rollup, $scopeLabel);
|
|
|
|
// Badge derives from the READ's outcome, not from item-level rollup.
|
|
// This makes the badge the read's headline — a reader can't catch
|
|
// the card contradicting itself (v4 Fix 4).
|
|
// No resolved contributors → "no evidence yet"
|
|
// Any Critical line → "At risk"
|
|
// Any Watch line only → "In progress"
|
|
// All going-well → "On track"
|
|
$readTypes = array_column($readLns, 'type');
|
|
if (count($rollup) === 0) {
|
|
$badgeCls = 'pending';
|
|
$badgeLbl = __('stakeholder.lm.no_evidence_yet');
|
|
} elseif (in_array('risk', $readTypes, true)) {
|
|
$badgeCls = 'risk';
|
|
$badgeLbl = __('stakeholder.lm.verdict_at_risk');
|
|
} elseif (in_array('watch', $readTypes, true)) {
|
|
$badgeCls = 'wip';
|
|
$badgeLbl = __('stakeholder.lm.verdict_in_progress');
|
|
} else {
|
|
$badgeCls = 'ok';
|
|
$badgeLbl = __('stakeholder.lm.verdict_on_track');
|
|
}
|
|
@endphp
|
|
|
|
<div class="p2-stage {{ $stage['cls'] }}">
|
|
<div class="stage-hd">
|
|
<div class="frame"><i class="fa {{ $stage['icon'] }}"></i> {{ $stage['frame'] }}</div>
|
|
<span class="p2-vbadge {{ $badgeCls }}"><span class="sd"></span> {{ $badgeLbl }}</span>
|
|
</div>
|
|
|
|
@if (count($readLns) > 0)
|
|
<div class="p2-read">
|
|
@foreach ($readLns as $ln)
|
|
<div class="p2-readline {{ $ln['type'] }}">
|
|
<span class="dot"></span>
|
|
<div>{!! $ln['html'] !!}</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@elseif ($rollupResult['hasUnresolved'])
|
|
{{-- No resolved contributors, only unresolved: don't render a
|
|
read; render the not-linked note alone. --}}
|
|
<div class="p2-unresolved">{{ __('stakeholder.lm.not_linked_note') }}</div>
|
|
@endif
|
|
|
|
@if (count($readLns) > 0 && $rollupResult['unresolvedShare'] >= 10)
|
|
{{-- Data-quality note, muted, non-prose. Not a bullet, not in
|
|
the read's voice. --}}
|
|
<div class="p2-unresolved">{{ sprintf(__('stakeholder.lm.unresolved_note'), $rollupResult['unresolvedShare']) }}</div>
|
|
@endif
|
|
|
|
<div class="p2-rows">
|
|
@foreach ($stageItems as $item)
|
|
@php
|
|
$itemId = (int) (((array) $item)['id'] ?? 0);
|
|
$arr = (array) $item;
|
|
// Label is ALWAYS the authored description, verbatim
|
|
// — never prefixed, never regex-parsed for a number.
|
|
$label = trim((string) ($arr['description'] ?? ''));
|
|
// Aggregate carries only unit-matched goals (see
|
|
// itemGoalAggregate). When present, we can safely
|
|
// render the current value as its own element beside
|
|
// the label — no splice.
|
|
$a = $agg['rolled'][$itemId] ?? null;
|
|
$currentDisplay = '';
|
|
if ($a !== null) {
|
|
// Row status = goal RAG (same as the contributor
|
|
// rollup that feeds the badge). Not a pct
|
|
// threshold — a row and its card must agree.
|
|
if ($a['ragClass'] === 'risk') {
|
|
$rowCls = 'risk'; $rowLbl = __('stakeholder.lm.status_at_risk');
|
|
} elseif ($a['ragClass'] === 'ok') {
|
|
$rowCls = 'ok'; $rowLbl = __('stakeholder.lm.status_on_track');
|
|
} else {
|
|
$rowCls = 'wip'; $rowLbl = __('stakeholder.lm.status_in_progress');
|
|
}
|
|
$currentDisplay = sprintf(__('stakeholder.lm.so_far'), $fmtMetric($a['current'], $a['metricType']));
|
|
} else {
|
|
$rowCls = 'pending';
|
|
$rowLbl = __('stakeholder.lm.status_pending');
|
|
}
|
|
@endphp
|
|
@php
|
|
// Provenance for the row status — surfaced as a
|
|
// tooltip on hover so the reader can trace it back to
|
|
// a person and date without instructional prose on
|
|
// the page. Format: "Set by {Author} · {Date}". Falls
|
|
// back to a generic note when author/date is missing.
|
|
$statusBasis = '';
|
|
if ($a !== null && count($a['goals']) > 0) {
|
|
$sourceGoal = null;
|
|
foreach ($a['goals'] as $ag) {
|
|
if ($sourceGoal === null || (string) ($ag['modified'] ?? '') > (string) ($sourceGoal['modified'] ?? '')) {
|
|
$sourceGoal = $ag;
|
|
}
|
|
}
|
|
$author = trim((string) ($sourceGoal['authorName'] ?? ''));
|
|
$dateStr = '';
|
|
$mod = (string) ($sourceGoal['modified'] ?? '');
|
|
if ($mod !== '') {
|
|
try { $dateStr = (new \DateTimeImmutable($mod))->format('M j'); } catch (\Exception $e) {}
|
|
}
|
|
if ($author !== '' && $dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who_when'), $author, $dateStr);
|
|
elseif ($author !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who'), $author);
|
|
elseif ($dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_when'), $dateStr);
|
|
}
|
|
@endphp
|
|
<div class="p2-row">
|
|
<div class="p2-row-title">
|
|
{{ $label }}
|
|
@if ($currentDisplay !== '')
|
|
<span class="p2-row-value">{{ $currentDisplay }}</span>
|
|
@endif
|
|
</div>
|
|
<span class="p2-row-status {{ $rowCls }}"@if ($statusBasis !== '') data-tippy-content="{{ $statusBasis }}"@endif><span class="sd"></span> {{ $rowLbl }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if (count($rollup) > 0)
|
|
<details class="p2-brk">
|
|
<summary><i class="fa fa-chevron-right"></i> {{ __('stakeholder.lm.show_breakdown') }}</summary>
|
|
<div class="p2-brk-body">
|
|
@php
|
|
// Same contributor-count branch as the read:
|
|
// n = 1 → no bar, no percent, no "100%" tautology
|
|
// n ≥ 2 → name + count + share bar + share % + status
|
|
$contribCount = count($rollup);
|
|
@endphp
|
|
@foreach ($rollup as $prog)
|
|
@php
|
|
$showProjects = array_slice($prog['projects'], 0, 4);
|
|
$moreProj = max(0, count($prog['projects']) - 4);
|
|
$projCount = count($prog['projects']);
|
|
$nProjectsLbl = $projCount === 1
|
|
? __('stakeholder.lm.n_project_one')
|
|
: sprintf(__('stakeholder.lm.n_projects'), $projCount);
|
|
$pstatLbl = $prog['ragClass'] === 'risk'
|
|
? __('stakeholder.lm.status_at_risk')
|
|
: ($prog['ragClass'] === 'ok' ? __('stakeholder.lm.status_on_track') : __('stakeholder.lm.status_in_progress'));
|
|
@endphp
|
|
<div class="p2-brk-prog">
|
|
@if ($contribCount === 1)
|
|
<div class="p2-brk-progrow single">
|
|
<span class="pdot"></span>
|
|
<div>
|
|
<div class="pn">{{ $prog['name'] }}</div>
|
|
<div class="pmeta">{{ $nProjectsLbl }}</div>
|
|
</div>
|
|
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
|
|
</div>
|
|
@else
|
|
<div class="p2-brk-progrow">
|
|
<span class="pdot"></span>
|
|
<div>
|
|
<div class="pn">{{ $prog['name'] }}</div>
|
|
<div class="pmeta">{{ $nProjectsLbl }}</div>
|
|
</div>
|
|
<div class="pbar"><i style="width:{{ min(100, $prog['pct']) }}%;"></i></div>
|
|
<div class="pshare">{{ $prog['pct'] }}%</div>
|
|
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
|
|
</div>
|
|
@endif
|
|
@if (count($showProjects) > 0)
|
|
<div class="p2-brk-projs">
|
|
@foreach ($showProjects as $pj)
|
|
<div class="p2-brk-pj">
|
|
<span class="pjn">{{ $pj['name'] }}</span>
|
|
<span class="pjd {{ $pj['ragClass'] }}"></span>
|
|
</div>
|
|
@endforeach
|
|
@if ($moreProj > 0)
|
|
<div class="p2-brk-more">+ {{ $moreProj }} {{ __('stakeholder.lm.more_word') }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</details>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
|
|
{{-- Impact — one aim, not a list --}}
|
|
@if (count($impactItems) > 0)
|
|
@php $primaryImpact = trim((string) (((array) $impactItems[0])['description'] ?? '')); @endphp
|
|
@if ($primaryImpact !== '')
|
|
<div class="p2-impact">
|
|
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.lm.for_what_label') }}</div>
|
|
<div class="goal">{{ $primaryImpact }}</div>
|
|
<div class="horizon">{{ __('stakeholder.lm.impact_horizon') }}</div>
|
|
</div>
|
|
@endif
|
|
@endif
|
|
|
|
{{-- Risk — one weakest fragile link, single sentence, single period --}}
|
|
@if ($fragileLink !== null)
|
|
@php
|
|
$assumption = trim((string) ($fragileLink['assumption_text'] ?? ''));
|
|
$assumption = rtrim($assumption, '.!?');
|
|
$connector = trim((string) ($fragileLink['connector_label'] ?? ''));
|
|
@endphp
|
|
<div class="p2-risk">
|
|
<i class="fa fa-triangle-exclamation ri"></i>
|
|
<div class="rb">
|
|
<span class="rl">{{ __('stakeholder.lm.risk_label') }}</span>
|
|
@if ($assumption !== '')
|
|
{{ __('stakeholder.lm.risk_leap_intro') }} <b>{{ $assumption }}</b>.
|
|
@elseif ($connector !== '')
|
|
{{ __('stakeholder.lm.risk_generic_intro') }} <b>{{ $connector }}</b>.
|
|
@endif
|
|
@if (empty($fragileLink['has_data']))
|
|
<b>{{ __('stakeholder.lm.risk_no_evidence') }}</b>{{ __('stakeholder.lm.risk_keep_honest') }}
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Also this period — real completed work with NO LM link.
|
|
Item-grain drift (unalignedColumns names program-grain drift; this
|
|
is one level finer). Each row is a completion that touched real
|
|
numbers but maps to nothing in the model — a link-me-to-an-outcome
|
|
invitation, not a scold. Silent when nothing is unlinked. --}}
|
|
@php
|
|
$completedThisPeriod = (array) ($report['milestones']['completed'] ?? []);
|
|
// Build the set of milestone IDs any LM item links to.
|
|
$linkedMilestoneIds = [];
|
|
foreach ($projectLinks as $itemLinks) {
|
|
foreach ($itemLinks as $link) {
|
|
if (($link['linked_entity_type'] ?? '') === 'milestone') {
|
|
$linkedMilestoneIds[(int) $link['linked_entity_id']] = true;
|
|
}
|
|
}
|
|
}
|
|
// "Something real to show" per the spec — a metric, a count, OR
|
|
// a completion. A completed milestone IS a completion, so a
|
|
// headline is sufficient; task stats are optional decoration.
|
|
// Filter out anything with no headline (a bare row is noise).
|
|
$alsoThisPeriod = [];
|
|
foreach ($completedThisPeriod as $ms) {
|
|
$mid = (int) ($ms->id ?? 0);
|
|
if ($mid === 0 || isset($linkedMilestoneIds[$mid])) continue;
|
|
$headline = trim((string) ($ms->headline ?? ''));
|
|
if ($headline === '') continue;
|
|
$taskStats = (array) ($ms->taskStats ?? []);
|
|
$doneCount = (int) ($taskStats['done'] ?? 0);
|
|
$totalCount = (int) ($taskStats['total'] ?? 0);
|
|
$metric = $totalCount > 0
|
|
? sprintf(__('stakeholder.lm.also_metric_tasks'), $doneCount, $totalCount)
|
|
: '';
|
|
$alsoThisPeriod[] = [
|
|
'id' => $mid,
|
|
'headline' => $headline,
|
|
'metric' => $metric,
|
|
'projectId' => (int) ($ms->projectId ?? 0),
|
|
'canvasId' => (int) ($logicModel['canvasId'] ?? 0),
|
|
];
|
|
}
|
|
$alsoCount = count($alsoThisPeriod);
|
|
$alsoShown = array_slice($alsoThisPeriod, 0, 3);
|
|
$alsoMore = max(0, $alsoCount - 3);
|
|
@endphp
|
|
|
|
@if ($alsoCount > 0)
|
|
<div class="p2-also">
|
|
<div class="lb"><i class="fa fa-code-branch"></i> {{ __('stakeholder.lm.also_label') }}</div>
|
|
@foreach ($alsoShown as $row)
|
|
{{-- Plain drift note: a milestone that closed this period but
|
|
doesn't map to a Logic Model outcome. No CTA — the app
|
|
doesn't currently have a one-click "link a milestone to
|
|
an outcome" flow, and a dead link on the honesty page is
|
|
worse than no link. When the flow lands, wire it here. --}}
|
|
<div class="row">
|
|
<b>{{ $row['headline'] }}</b>@if ($row['metric'] !== '') · <span class="metric">{{ $row['metric'] }}</span>@endif
|
|
</div>
|
|
@endforeach
|
|
@if ($alsoMore > 0)
|
|
<div class="more">{{ sprintf(__('stakeholder.lm.also_more'), $alsoMore) }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Off-strategy drift (strategy scope) --}}
|
|
@if (($scope ?? '') === 'strategy' && count($unaligned) > 0)
|
|
@php
|
|
$unalignedNames = array_map(
|
|
fn ($id) => $columns[$id]['name'] ?? ('#'.$id),
|
|
array_slice($unaligned, 0, 5)
|
|
);
|
|
$moreDrift = max(0, count($unaligned) - 5);
|
|
@endphp
|
|
<div class="p2-drift">
|
|
<i class="fa fa-diagram-project"></i>
|
|
<div>
|
|
<b>{{ __('stakeholder.lm.drift_label') }}:</b>
|
|
{{ sprintf(__('stakeholder.lm.drift_hint'), count($unaligned)) }}
|
|
<em>{{ implode(', ', $unalignedNames) }}@if ($moreDrift > 0) +{{ $moreDrift }} @endif</em>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
</div>
|
|
@endif
|