OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
{{--
Commitment integrity strip: milestones whose due date was pushed out of the period, and
milestones added mid-period. Kept compact it's an honesty note, not a section.
Expects:
$slippage: array{pushedOut: object[], addedMidPeriod: object[]}
$showProjects: bool
--}}
@php
$showProjects = $showProjects ?? false;
@endphp
@if (!empty($slippage['pushedOut']) || !empty($slippage['addedMidPeriod']))
<div class="reportSlippage">
<strong class="tw-block tw-mb-1"><i class="fa fa-fw fa-arrows-left-right tw-opacity-60"></i> {{ __('subtitles.changed_this_period') }}</strong>
@foreach ($slippage['pushedOut'] as $milestone)
<div>
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
@if ($showProjects)<span class="tw-opacity-70">({{ $tpl->escape($milestone->projectName) }})</span>@endif
{{ sprintf(__('text.slippage_moved_out'), $milestone->dueDateMoves, $milestone->dueDate?->formatDateForUser() ?? '') }}
</div>
@endforeach
@foreach ($slippage['addedMidPeriod'] as $milestone)
<div>
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
@if ($showProjects)<span class="tw-opacity-70">({{ $tpl->escape($milestone->projectName) }})</span>@endif
{{ __('text.slippage_added_mid_period') }}
</div>
@endforeach
</div>
@endif

View File

@@ -0,0 +1,80 @@
{{--
Goals & KPIs table: status dot, metric ("18 of 40 graduates"), progress bar.
The linked-milestone column only renders when at least one goal links a milestone.
Expects:
$goals: object[] - engine-enriched goal rows (goalProgress resolved incl. roll-ups)
$showProjects: bool
$emptyText: string
--}}
@php
$showProjects = $showProjects ?? false;
$goalStatusColors = [
'status_ontrack' => 'var(--green)',
'status_atrisk' => 'var(--yellow)',
'status_miss' => 'var(--red)',
];
$hasMilestoneLinks = false;
foreach ($goals as $goalRow) {
if (!empty($goalRow->milestoneHeadline)) {
$hasMilestoneLinks = true;
break;
}
}
$fmt = fn ($n) => \Illuminate\Support\Number::format((float) $n, maxPrecision: 1);
@endphp
@if (count($goals) === 0)
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
@else
<table class="reportTable reportGoalTable">
<thead>
<tr>
<th>{{ __('label.goal') }}</th>
@if ($showProjects)<th>{{ __('label.project') }}</th>@endif
<th class="numCol">{{ __('label.metric') }}</th>
<th style="width: 28%;">{{ __('label.progress') }}</th>
@if ($hasMilestoneLinks)<th>{{ __('label.linked_milestone') }}</th>@endif
</tr>
</thead>
<tbody>
@foreach ($goals as $goal)
<tr>
<td>
<span class="statusDot" style="background:{{ $goalStatusColors[$goal->status] ?? 'var(--grey)' }};"></span>
<strong>{{ $tpl->escape($goal->title) }}</strong>
@if ($goal->setting === 'linkAndReport')
<span class="cellNote"><i class="fa fa-sitemap tw-opacity-60"></i>
@if (!empty($goal->childGoalCount))
{{ sprintf(__('text.fed_by_n_goals'), $goal->childGoalCount) }}
@else
{{ __('text.goal_rollup_tooltip') }}
@endif
</span>
@endif
</td>
@if ($showProjects)
<td class="tw-opacity-70">{{ $tpl->escape($goal->boardProjectName ?? $goal->projectName ?? '') }}</td>
@endif
<td class="numCol">
<strong>{{ $fmt($goal->currentValue) }}</strong> <span class="tw-opacity-60">of {{ $fmt($goal->endValue) }} {{ $tpl->escape($goal->metricType ?? '') }}</span>
</td>
<td>
<div class="tw-flex tw-items-center tw-gap-2">
<div class="progress tw-flex-1 tw-m-0" style="height: 6px;">
<div class="progress-bar progress-bar-success" role="progressbar"
aria-valuenow="{{ round($goal->goalProgress) }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ round($goal->goalProgress) }}%">
</div>
</div>
<span class="tw-text-sm tw-opacity-70" style="font-variant-numeric: tabular-nums;">{{ round($goal->goalProgress) }}%</span>
</div>
</td>
@if ($hasMilestoneLinks)
<td class="tw-opacity-70">{{ $tpl->escape($goal->milestoneHeadline ?? '') }}</td>
@endif
</tr>
@endforeach
</tbody>
</table>
@endif

View File

@@ -0,0 +1,101 @@
{{--
Milestone list used by all report screens in three modes:
- completed: completion date + outcome narrative (inline-capturable) + key-task drill-down
- inflight: progress bar + due date (overdue rows passed in first by the engine)
- upcoming: schedule only
Expects:
$milestones: object[] - engine-enriched milestone rows
$mode: string - completed|inflight|upcoming
$showProjects: bool - show project names next to milestones (rollup screens)
$showTasks: bool - show the key-task drill-down on completed milestones (default true)
$allowOutcomeEdit: bool - enable inline outcome capture (project-level screen)
$effortByMilestone: array<int, float> - hours logged per milestone in the period
$period: \Leantime\Domain\Reports\Models\ReportPeriod
$emptyText: string - shown when the list is empty
--}}
@php
$showProjects = $showProjects ?? false;
$showTasks = $showTasks ?? true;
$allowOutcomeEdit = $allowOutcomeEdit ?? false;
$effortByMilestone = $effortByMilestone ?? [];
@endphp
@if (count($milestones) === 0)
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
@else
<ul class="reportMilestoneList">
@foreach ($milestones as $milestone)
<li class="reportMilestone" style="border-left: 3px solid {{ $milestone->tags }};">
<div class="milestoneTitleRow">
@if ($mode === 'completed')
<i class="fa fa-check-circle" style="color: var(--green);"></i>
@endif
<strong>
<a href="{{ BASE_URL }}/tickets/editMilestone/{{ $milestone->id }}" class="milestoneModal hideLinkOnPrint">{{ $tpl->escape($milestone->headline) }}</a>
</strong>
@if ($showProjects)
<span class="milestoneProject">{{ $tpl->escape($milestone->projectName) }}</span>
@endif
<span class="milestoneMeta">
@if ($mode === 'completed')
{{ __('label.completed_on') }} {{ $milestone->completedOn?->formatDateForUser() ?? '—' }}
@elseif ($mode === 'upcoming')
{{ $milestone->startDate?->formatDateForUser() }} {{ $milestone->dueDate?->formatDateForUser() ?? '—' }}
@else
@php $isOverdue = $milestone->dueDate !== null && $milestone->dueDate->isPast(); @endphp
<span @if ($isOverdue) style="color: var(--red); font-weight: 600;" @endif>
{{ __('label.due') }} {{ $milestone->dueDate?->formatDateForUser() ?? __('text.no_date_defined') }}
</span>
@endif
@if (!empty($effortByMilestone[$milestone->id]))
· {{ \Illuminate\Support\Number::format($effortByMilestone[$milestone->id], maxPrecision: 1) }} {{ __('label.hours_short') }}
@endif
</span>
</div>
@if ($mode === 'completed')
@include('reports::partials.outcome', ['milestone' => $milestone, 'canEdit' => $allowOutcomeEdit])
@if ($showTasks && !empty($milestone->keyTasks))
<details class="reportKeyTasks">
<summary>
{{ sprintf(__('text.tasks_done_of_total'), $milestone->taskStats['done'], $milestone->taskStats['total']) }}
</summary>
<ul class="tw-list-none tw-pl-5 tw-pt-1 tw-m-0 tw-text-sm tw-opacity-80">
@foreach ($milestone->keyTasks as $task)
<li>
<i class="fa fa-fw {{ $task->isDone ? 'fa-check tw-opacity-60' : 'fa-circle-o' }}"></i>
{{ $tpl->escape($task->headline) }}
</li>
@endforeach
@if ($milestone->taskStats['total'] > count($milestone->keyTasks))
<li class="tw-opacity-60">{{ sprintf(__('text.and_n_more'), $milestone->taskStats['total'] - count($milestone->keyTasks)) }}</li>
@endif
</ul>
</details>
@endif
@endif
@if ($mode === 'inflight')
<div class="tw-flex tw-items-center tw-gap-3 tw-mt-2">
<div class="progress">
<div class="progress-bar progress-bar-success" role="progressbar"
aria-valuenow="{{ round($milestone->percentDone) }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ round($milestone->percentDone) }}%">
</div>
</div>
<span class="tw-text-sm tw-opacity-70 tw-whitespace-nowrap" style="font-variant-numeric: tabular-nums;">{{ round($milestone->percentDone) }}%
@if ($milestone->taskStats['total'] > 0)
· {{ sprintf(__('text.tasks_done_of_total'), $milestone->taskStats['done'], $milestone->taskStats['total']) }}
@endif
</span>
</div>
@endif
</li>
@endforeach
</ul>
@endif

View File

@@ -0,0 +1,64 @@
{{--
"Needs attention" block: red/yellow projects, silent projects, overdue milestones and
at-risk goals. Rendered first on every report screen; hidden entirely when all is well.
Expects:
$needsAttention: array{statusAlerts: object[], staleProjects: object[], overdueMilestones: object[], goalsAtRisk: object[]}
$showProjects: bool - prefix items with their project name (rollup screens)
--}}
@php
$hasAttentionItems = !empty($needsAttention['statusAlerts'])
|| !empty($needsAttention['staleProjects'])
|| !empty($needsAttention['overdueMilestones'])
|| !empty($needsAttention['goalsAtRisk']);
$showProjects = $showProjects ?? false;
@endphp
@if ($hasAttentionItems)
<div class="reportNeedsAttention">
<h5 class="subtitle"><i class="fa fa-triangle-exclamation" style="color: var(--red);"></i> {{ __('subtitles.needs_attention') }}</h5>
<ul class="tw-list-none tw-p-0 tw-m-0">
@foreach ($needsAttention['statusAlerts'] as $project)
<li>
<span class="statusDot" style="background:var(--{{ $project->latestStatus === 'red' ? 'red' : 'yellow' }});"></span>
<strong>{{ $tpl->escape($project->name) }}</strong>
{{ __('text.attention_reported_status') }}
@if (!empty($project->latestStatusText))
<span class="tw-opacity-80">{{ $tpl->escape($project->latestStatusText) }}</span>
@endif
</li>
@endforeach
@foreach ($needsAttention['overdueMilestones'] as $milestone)
<li>
<i class="fa fa-fw fa-clock" style="color: var(--red);"></i>
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
@if ($showProjects)<span class="tw-opacity-60">({{ $tpl->escape($milestone->projectName) }})</span>@endif
{{ __('text.attention_overdue_since') }} {{ $milestone->dueDate?->formatDateForUser() }}
</li>
@endforeach
@foreach ($needsAttention['goalsAtRisk'] as $goal)
<li>
<i class="fa fa-fw fa-bullseye" style="color: var(--yellow);"></i>
<strong>{{ $tpl->escape($goal->title) }}</strong>
{{ $goal->status === 'status_miss' ? __('text.attention_goal_missed') : __('text.attention_goal_at_risk') }}
<span class="tw-opacity-60">({{ \Illuminate\Support\Number::format((float) $goal->currentValue, maxPrecision: 1) }} of {{ \Illuminate\Support\Number::format((float) $goal->endValue, maxPrecision: 1) }} {{ $tpl->escape($goal->metricType ?? '') }})</span>
</li>
@endforeach
@foreach ($needsAttention['staleProjects'] as $project)
<li>
<i class="fa fa-fw fa-comment-slash tw-opacity-60"></i>
<strong>{{ $tpl->escape($project->name) }}</strong>
@if (!empty($project->latestStatusDate))
{{ sprintf(__('text.attention_no_update_since'), $project->latestStatusDate->formatDateForUser()) }}
@else
{{ __('text.attention_never_updated') }}
@endif
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,46 @@
{{--
Outcome & impact block of a completed milestone: shows the narrative when present, offers
inline capture right on the report when missing. The save posts via HTMX and this partial
re-renders in place.
Expects:
$milestone: object - id, outcomeImpact
$canEdit: bool - show the inline add/edit affordance
--}}
<div class="milestoneOutcome" id="milestoneOutcome-{{ $milestone->id }}">
@if (!empty($milestone->outcomeImpact))
<div class="tw-text-sm outcomeText">
{{ $milestone->outcomeImpact }}
@if ($canEdit)
<a href="javascript:void(0)" class="tw-opacity-50 hideOnPrint"
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').show();">
<i class="fa fa-pencil"></i>
</a>
@endif
</div>
@elseif ($canEdit)
<div class="outcomeText hideOnPrint">
<a href="javascript:void(0)" class="tw-text-sm tw-opacity-60"
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').show();">
<i class="fa fa-plus-circle"></i> {{ __('links.add_outcome_impact') }}
</a>
</div>
@endif
@if ($canEdit)
<form class="outcomeForm tw-mt-1 hideOnPrint" style="display:none;"
hx-post="{{ BASE_URL }}/hx/reports/outcome/save"
hx-target="#milestoneOutcome-{{ $milestone->id }}"
hx-swap="outerHTML">
<input type="hidden" name="milestoneId" value="{{ $milestone->id }}" />
<textarea name="outcomeImpact" rows="2" class="tw-w-full tw-text-sm"
placeholder="{{ __('input.placeholders.outcome_impact') }}">{{ $milestone->outcomeImpact }}</textarea>
<button type="submit" class="btn btn-primary btn-xs">{{ __('buttons.save') }}</button>
<a href="javascript:void(0)" class="btn btn-xs"
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').show();">
{{ __('buttons.cancel') }}
</a>
</form>
@endif
</div>

View File

@@ -0,0 +1,108 @@
{{--
Project status report body swapped by the period picker via HTMX.
Expects:
$report: array - ReportEngine::buildReport() output for [$projectId]
$period: \Leantime\Domain\Reports\Models\ReportPeriod
$projectId: int
--}}
@php
$summary = $report['summaries'][$projectId] ?? null;
$stats = $report['stats'];
$deltas = $report['deltas'];
$inFlightMilestones = array_merge($report['milestones']['overdue'], $report['milestones']['inProgress']);
$fmt = fn ($n) => \Illuminate\Support\Number::format((float) $n, maxPrecision: 1);
@endphp
<div id="reportBody">
{{-- Header band: status, progress, timeline --}}
@if ($summary !== null)
<div class="reportHeaderBand">
@include('reports::partials.statusPill', ['status' => $summary->latestStatus, 'date' => $summary->latestStatusDate])
<span>
<strong>{{ round($summary->progress['percent'] ?? 0) }}%</strong> {{ __('label.report_complete') }}
@php $completionState = $summary->progress['estimatedCompletionState'] ?? 'ready'; @endphp
@if ($completionState === 'needs_more_data')
· <a href="{{ BASE_URL }}/tickets/showAll" class="btn btn-primary"><i class="fa fa-thumb-tack"></i> {{ __('label.complete_more_todos') }}</a>
@elseif ($completionState === 'complete')
· <a href="{{ BASE_URL }}/projects/showAll" class="btn btn-primary"><i class="fa fa-suitcase"></i> {{ __('label.project_complete_onto_next') }}</a>
@elseif (!empty($summary->progress['estimatedCompletionDate']) && $summary->progress['estimatedCompletionDate'] !== false)
· {{ __('label.estimated_completion') }} {{ $summary->progress['estimatedCompletionDate'] }}
@endif
</span>
<span class="tw-opacity-70">{{ $period->label() }}</span>
</div>
@endif
@include('reports::partials.statTiles', ['tiles' => [
['label' => __('label.milestones_completed'), 'value' => $stats['completed'], 'delta' => ['value' => $deltas['completedDelta'], 'goodWhenUp' => true, 'vs' => __('label.vs_prior_period_short')]],
['label' => __('label.milestones_in_flight'), 'value' => $stats['inFlight']],
['label' => __('label.milestones_overdue'), 'value' => $stats['overdue'], 'tone' => 'danger'],
['label' => __('label.hours_logged'), 'value' => $fmt($stats['hoursLogged']), 'delta' => ['value' => $deltas['hoursDelta'], 'goodWhenUp' => null, 'vs' => __('label.vs_prior_period_short')]],
]])
@include('reports::partials.needsAttention', ['needsAttention' => $report['needsAttention'], 'showProjects' => false])
<div class="reportSection">
<h5 class="subtitle">{{ __('subtitles.accomplished_this_period') }} <span class="sectionCount">{{ count($report['milestones']['completed']) }}</span></h5>
@include('reports::partials.milestoneList', [
'milestones' => $report['milestones']['completed'],
'mode' => 'completed',
'allowOutcomeEdit' => true,
'effortByMilestone' => $report['effort']['byMilestone'],
'period' => $period,
'emptyText' => __('text.report_no_completed_milestones'),
])
@include('reports::partials.changedThisPeriod', ['slippage' => $report['milestones']['slippage'], 'showProjects' => false])
</div>
<div class="reportSection">
<h5 class="subtitle">{{ __('subtitles.in_flight') }} <span class="sectionCount">{{ count($inFlightMilestones) }}</span></h5>
@include('reports::partials.milestoneList', [
'milestones' => $inFlightMilestones,
'mode' => 'inflight',
'effortByMilestone' => $report['effort']['byMilestone'],
'period' => $period,
'emptyText' => __('text.report_no_inflight_milestones'),
])
</div>
<div class="reportSection">
<h5 class="subtitle">{{ __('subtitles.coming_up') }}</h5>
@if (count($report['milestones']['upcomingByQuarter']) === 0)
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ __('text.report_no_upcoming_milestones') }}</div>
@else
@foreach ($report['milestones']['upcomingByQuarter'] as $quarterLabel => $quarterMilestones)
<h6 class="tw-font-bold tw-opacity-70 tw-mt-3 tw-mb-1">{{ $quarterLabel }}</h6>
@include('reports::partials.milestoneList', [
'milestones' => $quarterMilestones,
'mode' => 'upcoming',
'period' => $period,
'emptyText' => '',
])
@endforeach
@endif
</div>
<div class="reportSection">
<h5 class="subtitle">{{ __('subtitles.goals_kpis') }}</h5>
@include('reports::partials.goalTable', [
'goals' => $report['goals']['goals'],
'emptyText' => __('text.report_no_goals'),
])
</div>
<div class="reportSection">
<h5 class="subtitle">{{ __('subtitles.status_narrative') }}</h5>
@include('reports::partials.statusNarrative', [
'updatesByProject' => $report['statusUpdates'],
'summaries' => $report['summaries'],
'showProjects' => false,
'emptyText' => __('text.report_no_status_updates'),
])
</div>
</div>

View File

@@ -0,0 +1,39 @@
{{--
Stakeholder Report page-header three-dot menu.
Verdict override (green / yellow / red / revert) + Print. Called from both
the strategy and program report templates; POST target changes based on
$scope.
Vars in:
$scope 'strategy' | 'program'
$projectId int strategy or program id
$verdictOverride null | 'green' | 'yellow' | 'red' (drives Revert visibility)
--}}
@php
$hxBase = BASE_URL.'/hx/'.($scope === 'strategy' ? 'strategyPro' : 'pgmPro').'/report/setVerdict';
@endphp
<span class="dropdown dropdownWrapper headerEditDropdown hideOnPrint">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown" aria-label="{{ __('stakeholder.header.actions') }}"><i class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown rd-actions-menu">
<li class="dropdown-header">{{ __('stakeholder.verdict.set_label') }}</li>
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"green","projectId":{{ (int) $projectId }}}' hx-swap="none">
<i class="fa fa-circle-check" style="color:#3E937A;"></i> {{ __('stakeholder.verdict.ontrack') }}
</a></li>
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"yellow","projectId":{{ (int) $projectId }}}' hx-swap="none">
<i class="fa fa-circle-exclamation" style="color:#C09035;"></i> {{ __('stakeholder.verdict.atrisk') }}
</a></li>
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"red","projectId":{{ (int) $projectId }}}' hx-swap="none">
<i class="fa fa-triangle-exclamation" style="color:#C2295B;"></i> {{ __('stakeholder.verdict.off') }}
</a></li>
@if (($verdictOverride ?? null) !== null)
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"revert","projectId":{{ (int) $projectId }}}' hx-swap="none">
<i class="fa fa-arrow-rotate-left"></i> {{ __('stakeholder.verdict.revert') }}
</a></li>
@endif
<li class="border"></li>
<li><a href="javascript:window.print();"><i class="fa fa-print"></i> {{ __('label.print_report') }}</a></li>
</ul>
</span>

View File

@@ -0,0 +1,192 @@
{{--
Renders a single capacity vs. demand card. Used at two levels:
- top-level: one card per program (strategy scope) or per project (program scope)
- nested: inside an expanded program card, one compact card per child project
Vars in:
$c The capacity analysis row (project or program-rolled).
$verdictLabels Map of verdict key translated label.
$compactOnly When true, always render the one-liner regardless of verdict.
--}}
@php
$compactOnly = $compactOnly ?? false;
$vLabel = $verdictLabels[$c['verdict']] ?? $c['verdict'];
$showFull = ! $compactOnly && in_array($c['verdict'], ['critical', 'tight', 'no_capacity'], true);
@endphp
@if ($showFull)
@php
// Balance bar geometry — carry the same reading the text states.
// Fill 0 → available in the "supply" (green) segment.
// Then a distinct "deficit" segment from available → needed, in
// the verdict color. Marker sits AT the available position so a
// reader instantly sees "we have this much, we need this much".
$barMax = max($c['availableHours'], $c['referenceDemand'], 1);
$availableMark = min(100, ($c['availableHours'] / $barMax) * 100);
$demandWidth = min(100, ($c['referenceDemand'] / $barMax) * 100);
$deficitWidth = max(0, $demandWidth - $availableMark);
$gapHrs = abs($c['gap']);
$gapPct = $c['availableHours'] > 0 ? abs($c['gap'] / $c['availableHours']) * 100 : 0;
$isShort = $c['gap'] > 0;
// Run-rate framing. Supply is a current weekly rate; projecting it over
// a (past) period to a total is the fiction we avoid. Demand is a real
// total of estimated work — expressed here as the weekly rate needed to
// clear it within the period. The gap % (verdict) is unchanged: rate
// ratios equal total ratios, so the bar geometry above still holds.
$weeks = max(1, (int) $c['weeksInWindow']);
$supplyPerWk = $c['weeklyHoursToProject'];
$demandPerWk = $c['referenceDemand'] / $weeks;
$gapPerWk = $gapHrs / $weeks;
@endphp
<div class="p3-cap {{ $c['verdict'] }}">
<div class="p3-cap-hd">
<span class="verdict {{ $c['verdict'] }}">
<i class="fa fa-{{ $c['verdict'] === 'critical' ? 'triangle-exclamation' : ($c['verdict'] === 'no_capacity' ? 'ban' : 'circle-exclamation') }}"></i>
{{ $vLabel }}
</span>
<div class="name">{{ $c['name'] }}</div>
@if ($c['trustSignal'] === 'budgeted' && $c['effortHours'] > 0)
<span class="trust good" data-tippy-content="{{ __('stakeholder.rc.cap.trust_budgeted') }}">
<i class="fa fa-check"></i>{{ __('stakeholder.rc.cap.trust_high') }}
</span>
@elseif ($c['trustSignal'] === 'effort')
<span class="trust warn" data-tippy-content="{{ __('stakeholder.rc.cap.trust_effort') }}">
<i class="fa fa-triangle-exclamation"></i>{{ __('stakeholder.rc.cap.trust_effort_short') }}
</span>
@elseif ($c['trustSignal'] === 'mixed')
<span class="trust warn" data-tippy-content="{{ sprintf(__('stakeholder.rc.cap.trust_mixed'), (int) ($c['divergence'] * 100)) }}">
<i class="fa fa-triangle-exclamation"></i>{{ __('stakeholder.rc.cap.trust_mixed_short') }}
</span>
@endif
@if ($c['referenceDemand'] > 0 && $c['availableHours'] > 0)
<div class="headline-num {{ $c['verdict'] }}">
@if ($isShort)
<span class="unit-h" data-hours="{{ round($gapPerWk) }}">{{ round($gapPerWk) }}h</span>/wk {{ sprintf(__('stakeholder.rc.cap.short_suffix'), (int) $gapPct) }}
@else
<span class="unit-h" data-hours="{{ round($gapPerWk) }}">{{ round($gapPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.buffer_suffix') }}
@endif
</div>
@endif
</div>
<div class="p3-cap-body">
<div class="p3-cap-row">
<div class="lbl">{{ __('stakeholder.rc.cap.scope') }}</div>
<div class="val">
@if ($c['openTicketCount'] === 0)
<span class="muted">{{ __('stakeholder.rc.cap.no_tickets') }}</span>
@else
@if ($c['budgetedHours'] > 0)
<span class="primary"><span class="unit-h" data-hours="{{ round($c['budgetedHours']) }}">{{ round($c['budgetedHours']) }}h</span> {{ __('stakeholder.rc.cap.budgeted') }}</span>
<span class="muted">
({{ $c['ticketsWithBudget'] }}/{{ $c['openTicketCount'] }}
{{ __('stakeholder.rc.cap.tickets_with_hours') }} {{ (int) ($c['coverage'] * 100) }}% {{ __('stakeholder.rc.cap.coverage') }})
</span>
@else
<span class="muted">{{ __('stakeholder.rc.cap.no_budgeted') }} ({{ $c['openTicketCount'] }} {{ __('stakeholder.rc.cap.open_tickets') }})</span>
@endif
<br>
@if ($c['effortPoints'] > 0)
<span class="primary"><span class="unit-h" data-hours="{{ round($c['effortHours']) }}">{{ round($c['effortHours']) }}h</span> {{ __('stakeholder.rc.cap.effort') }}</span>
<span class="muted">({{ $c['effortPoints'] }}
<span class="pts-info" data-tippy-content="{{ __('stakeholder.rc.cap.points_help') }}">pts <i class="fa fa-circle-info"></i></span>
× {{ $c['hoursPerPoint'] }}h/pt)</span>
@else
<span class="muted">{{ __('stakeholder.rc.cap.no_effort') }}</span>
@endif
@endif
</div>
</div>
<div class="p3-cap-row">
<div class="lbl">{{ __('stakeholder.rc.cap.capacity') }}</div>
<div class="val">
@if ($c['peopleCount'] === 0)
<span class="muted">{{ __('stakeholder.rc.cap.no_people') }}</span>
@else
<span class="primary"><span class="unit-h" data-hours="{{ round($supplyPerWk) }}">{{ round($supplyPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.supply') }}</span>
<span class="muted">
({{ $c['peopleCount'] }} {{ __($c['peopleCount'] === 1 ? 'stakeholder.rc.cap.person' : 'stakeholder.rc.cap.people') }} × <span class="unit-h" data-hours="{{ round($supplyPerWk / max(1, $c['peopleCount']), 1) }}">{{ round($supplyPerWk / max(1, $c['peopleCount']), 1) }}h</span>/wk)
</span>
@endif
</div>
</div>
@if ($c['referenceDemand'] > 0 && $c['availableHours'] > 0)
<div class="p3-cap-row">
<div class="lbl">{{ __('stakeholder.rc.cap.balance') }}</div>
<div class="val">
<span class="unit-h" data-hours="{{ round($demandPerWk) }}">{{ round($demandPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.demand') }}
<span class="divider">·</span>
<span class="unit-h" data-hours="{{ round($supplyPerWk) }}">{{ round($supplyPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.supply') }}
<div class="p3-cap-bar">
<div class="track {{ $c['verdict'] }}">
{{-- Supply segment: green fill 0 available. --}}
<div class="supply" style="width:{{ $availableMark }}%;"></div>
{{-- Deficit segment: verdict-color band from
available needed, showing the shortfall. --}}
@if ($isShort && $deficitWidth > 0)
<div class="deficit" style="left:{{ $availableMark }}%;width:{{ $deficitWidth }}%;"
data-tippy-content="{{ sprintf(__("stakeholder.rc.cap.deficit_tooltip"), round($gapPerWk)) }}"></div>
@endif
<div class="marker" style="left:{{ $availableMark }}%;">
<span class="marker-label">{{ __('stakeholder.rc.cap.marker_label') }}</span>
</div>
</div>
<div class="legend">
<span><span class="unit-h" data-hours="0">0h</span></span>
<span><span class="unit-h" data-hours="{{ round($barMax) }}">{{ round($barMax) }}h</span></span>
</div>
</div>
</div>
</div>
@endif
@if ($isShort && $c['recommendations']['extendWeeks'] > 0)
@php $r = $c['recommendations']; @endphp
<div class="p3-cap-rebalance">
<div class="hd">{{ __('stakeholder.rc.cap.rebalance_hd') }}</div>
<div class="opts">
<div class="opt">
<div class="icn"><i class="fa fa-calendar-plus"></i></div>
<div class="lever">{{ __('stakeholder.rc.cap.lever_extend_pre') }} <b>{{ $r['extendWeeks'] }}</b> {{ __('stakeholder.rc.cap.lever_extend_post') }}</div>
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_extend_detail'), round($c['weeklyHoursToProject'])) }}</div>
</div>
<div class="opt">
<div class="icn"><i class="fa fa-user-plus"></i></div>
<div class="lever">{{ __('stakeholder.rc.cap.lever_add_pre') }} <b>{{ $r['addPeople'] }}</b> {{ $r['addPeople'] === 1 ? __('stakeholder.rc.cap.lever_add_post_one') : __('stakeholder.rc.cap.lever_add_post_many') }}</div>
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_add_detail'), round($c['weeklyHoursToProject'] / max(1, $c['peopleCount']), 1), $c['weeksInWindow']) }}</div>
</div>
<div class="opt">
<div class="icn"><i class="fa fa-scissors"></i></div>
<div class="lever">{{ __('stakeholder.rc.cap.lever_cut_pre') }} <b>{{ round($r['cutPoints']) }}</b> {{ __('stakeholder.rc.cap.lever_cut_post') }}</div>
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_cut_detail'), round($r['cutHours']), $c['hoursPerPoint']) }}</div>
</div>
</div>
</div>
@endif
</div>
</div>
@else
{{-- Compact one-liner --}}
<div class="p3-cap-compact">
<span class="verdict {{ $c['verdict'] }}">
@if ($c['verdict'] === 'buffer')<i class="fa fa-check"></i>@else<i class="fa fa-minus"></i>@endif
{{ $vLabel }}
</span>
<div class="name">{{ $c['name'] }}</div>
<div class="summary">
@if ($c['verdict'] === 'no_work')
{{ __('stakeholder.rc.cap.summary_no_work') }}
@else
<span class="unit-h" data-hours="{{ round($c['referenceDemand']) }}">{{ round($c['referenceDemand']) }}h</span> {{ __('stakeholder.rc.cap.summary_needed') }}
·
<span class="unit-h" data-hours="{{ round($c['availableHours']) }}">{{ round($c['availableHours']) }}h</span> {{ __('stakeholder.rc.cap.summary_available') }}
@endif
</div>
</div>
@endif

View File

@@ -0,0 +1,343 @@
{{--
Stakeholder Report 4-page deck shell.
Reused by both StrategyPro (strategy scope) and PgmPro (program scope).
Data passed in via @include vars; this partial owns:
- persistent header (subject, period, updated, status verdict)
- global controls (period picker, print)
- deck navigation (4 tabs + swipe + arrow keys + arrow buttons)
- the 4 page containers (Overview / Logic Model / Resources & Coverage / Programs)
- scoped CSS with `minmax(0,1fr)` discipline (§2 layout constraint)
- print stylesheet expanding the deck (§7)
Vars in:
$scope 'strategy' | 'program'
$subject string displayed in the header
$period ReportPeriod
$updatedAt string
$verdict 'ontrack' | 'atrisk' | 'off' | 'unknown'
$verdictLabel string the visible verdict
$verdictSource string provenance line (never hidden, per §3)
$report ReportEngine::buildReport() output
$stats $report['stats']
$deltas $report['deltas']
$needsAttn $report['needsAttention']
$logicModel null | {canvasId, narrative, stageProgress, healthBadges, coverageMatrix}
$goalsGroup {goals, byProject, counts} strategy: strategyGoals; program: programGoals
$programRows array strategy only, empty at program scope
$programUpdates array strategy only, empty at program scope
--}}
@php
use Leantime\Domain\Reports\Models\ReportPeriod;
$verdictDotColor = match ($verdict) {
'ontrack' => '#3E937A',
'inprogress' => '#3F72B0',
'atrisk' => '#C09035',
'off' => '#C2295B',
default => '#9CA3AF',
};
$completedCount = (int) ($stats['completed'] ?? 0);
$overdueCount = (int) ($stats['overdue'] ?? 0);
$goalsOnTrack = (int) ($stats['goalsOnTrack'] ?? 0);
$goalsTotal = (int) ($stats['goalsTotal'] ?? 0);
$hoursLogged = (float) ($stats['hoursLogged'] ?? 0);
$completedDelta = (int) ($deltas['completedDelta'] ?? 0);
$hasLM = $logicModel !== null;
// Semantic period label — the "why this period" chip in the header sub-line.
// Board audiences care WHY the report is showing this range (because it's
// last closed) more than the raw dates, which appear separately in the picker.
$periodMeaning = match ($period->preset) {
ReportPeriod::PRESET_LAST_QUARTER => __('stakeholder.period.last_closed'),
ReportPeriod::PRESET_THIS_QUARTER => __('stakeholder.period.in_progress'),
ReportPeriod::PRESET_NEXT_QUARTER => __('stakeholder.period.upcoming'),
ReportPeriod::PRESET_CUSTOM => __('stakeholder.period.custom'),
default => '',
};
// Preset name for the picker button — matches what the user selects in the
// dropdown ("Last quarter" / "This quarter" / "Next quarter"). Deliberately
// NOT "Q2 2026" — Leantime doesn't let companies define fiscal quarters, so
// a calendar Q# label would be a lie for anyone whose fiscal year isn't
// calendar-aligned. The literal date range is shown next to it.
$presetName = match ($period->preset) {
ReportPeriod::PRESET_LAST_QUARTER => __('label.period_last_quarter'),
ReportPeriod::PRESET_THIS_QUARTER => __('label.period_this_quarter'),
ReportPeriod::PRESET_NEXT_QUARTER => __('label.period_next_quarter'),
ReportPeriod::PRESET_CUSTOM => __('label.period_custom'),
default => __('label.period_this_quarter'),
};
// Reload URL bases for the period picker preset links.
$reportUrl = BASE_URL.'/'.($scope === 'strategy' ? 'strategyPro' : 'pgmPro').'/report';
@endphp
@php
// Prefer a $rdDark boolean passed by the caller/composer; fall back to the
// Theme service only when it isn't supplied, so the view isn't required to
// do a container lookup.
$rdDark = $rdDark ?? (app()->make(\Leantime\Core\UI\Theme::class)->getColorMode() === 'dark');
@endphp
<div class="rd-scope @if ($rdDark) rd-dark @endif">
{{-- ── Persistent document header (doc-shell) ───────────────────────
breadcrumb + subject switcher + status verdict + actions live in the
card; the plugin collapses the shared teal pageheader (body.report-doc)
so the report reads as one document. Switcher + actions degrade safely
when the caller doesn't pass switchableSubjects / projectId. --}}
<div class="rd-hdr">
<div class="st">
{{-- One report = ONE subject; Task-view breadcrumb flow
("Report // {subject}", like "To-Dos // All To-Dos"). The
scope label and period meaning are redundant here — the
breadcrumb says Report, the period picker below owns the
period context — so the under-text keeps only freshness. --}}
<h1 class="h"><span class="crumb-type">{{ __('stakeholder.header.crumb_report') }}</span> <span class="crumb-sep" aria-hidden="true">/</span> {{ $subject }}</h1>
</div>
{{-- RIGHT = what about it: verdict with freshness stacked under it,
centered as one group against the title row, then ⋮. --}}
<div class="verdict">
{{-- Provenance ("set 1 month ago · overrides metrics") is secondary:
it lives in the tooltip so the right side stays one balanced,
vertically-centered row with the actions menu. --}}
<div class="v" data-tippy-content="{{ $verdictSource }}"><span class="dot" style="background:{{ $verdictDotColor }}"></span>{{ $verdictLabel }}</div>
<div class="prov">{{ __('stakeholder.header.updated') }} {{ $updatedAt }}</div>
</div>
@if (! empty($projectId ?? null))
<div class="rd-actions">
@include('reports::partials.stakeholder.actionsMenu', [
'scope' => $scope,
'projectId' => $projectId,
'verdictOverride' => $verdictOverride ?? null,
])
</div>
@endif
</div>
{{-- ── Tab bar + period picker on ONE row (saves a full row of vertical
space; picker sits with the view-mode controls it belongs with) ── --}}
<div class="lt-tabs lt-tabs--floating hideOnPrint">
{{-- Framed segmented tab group (mirrors the global .tabs nav): the tabs
sit in one outlined container so they read as a connected control,
the active one a white segment inside it. --}}
{{-- <nav> + aria-current, not the ARIA tabs pattern: the deck pages
aren't role=tabpanel targets, so tablist semantics would mislead
assistive tech (Copilot review). --}}
<nav class="lt-tabs-group" id="rdTabs" aria-label="{{ __('stakeholder.tabs.label') }}">
<button type="button" class="lt-tab on" data-page="0" onclick="rdGo(0)" aria-current="true"><i class="fa fa-gauge-simple-high"></i> {{ __('stakeholder.tab.overview') }}</button>
<button type="button" class="lt-tab" data-page="1" onclick="rdGo(1)"><i class="fa fa-diagram-project"></i> {{ __('stakeholder.tab.logic_model') }}</button>
<button type="button" class="lt-tab" data-page="2" onclick="rdGo(2)"><i class="fa fa-people-arrows"></i> {{ __('stakeholder.tab.resources_coverage') }}</button>
<button type="button" class="lt-tab" data-page="3" onclick="rdGo(3)"><i class="fa fa-compass"></i> {{ __('stakeholder.tab.impact_journey') }}</button>
</nav>
<div class="lt-tabs-actions">
<div class="rd-picker" id="rdPicker">
<button type="button" class="rd-picker-btn" onclick="rdTogglePicker(event)">
<i class="fa fa-calendar"></i>
<span class="rd-picker-q">{{ $presetName }}</span>
<span class="rd-picker-range">· {{ $period->from->setToUserTimezone()->format('M j') }} {{ $period->to->setToUserTimezone()->format('M j, Y') }}</span>
<i class="fa fa-caret-down"></i>
</button>
<div class="rd-picker-menu" id="rdPickerMenu" hidden>
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_LAST_QUARTER }}"
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_LAST_QUARTER) on @endif">
<span class="l">{{ __('label.period_last_quarter') }}</span>
<span class="d">{{ __('stakeholder.period.default_hint') }}</span>
</a>
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_THIS_QUARTER }}"
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_THIS_QUARTER) on @endif">
<span class="l">{{ __('label.period_this_quarter') }}</span>
<span class="d">{{ __('stakeholder.period.in_progress_hint') }}</span>
</a>
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_NEXT_QUARTER }}"
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_NEXT_QUARTER) on @endif">
<span class="l">{{ __('label.period_next_quarter') }}</span>
<span class="d">{{ __('stakeholder.period.upcoming_hint') }}</span>
</a>
<div class="rd-picker-sep"></div>
<form method="GET" action="{{ $reportUrl }}" class="rd-picker-custom">
<input type="hidden" name="preset" value="{{ ReportPeriod::PRESET_CUSTOM }}">
<label class="rd-picker-cl">{{ __('label.period_custom') }}</label>
<div class="rd-picker-crow">
<input type="text" name="from" class="rd-picker-cinput periodPickerDate"
placeholder="{{ __('label.period_from') }}"
value="{{ $period->preset === ReportPeriod::PRESET_CUSTOM ? $period->from->setToUserTimezone()->formatDateForUser() : '' }}">
<span class="rd-picker-cdash"></span>
<input type="text" name="to" class="rd-picker-cinput periodPickerDate"
placeholder="{{ __('label.period_to') }}"
value="{{ $period->preset === ReportPeriod::PRESET_CUSTOM ? $period->to->setToUserTimezone()->formatDateForUser() : '' }}">
<button type="submit" class="rd-picker-capply">{{ __('label.period_apply') }}</button>
</div>
</form>
</div>
</div>
<div class="rd-arrows">
<button type="button" class="rd-arrow" id="rdPrev" onclick="rdGo(rdActive - 1)" aria-label="{{ __('stakeholder.nav.prev') }}"><i class="fa fa-chevron-left"></i></button>
<button type="button" class="rd-arrow" id="rdNext" onclick="rdGo(rdActive + 1)" aria-label="{{ __('stakeholder.nav.next') }}"><i class="fa fa-chevron-right"></i></button>
</div>
</div>
</div>
{{-- ── Deck ─────────────────────────────────────────────────────── --}}
<div class="rd-deck">
<div class="rd-deck-viewport">
<div class="rd-deck-track" id="rdTrack">
{{-- ═══ Page 1 Overview ═════════════════════════════ --}}
<div class="rd-page on">
@include('reports::partials.stakeholder.page-overview', compact(
'completedCount', 'completedDelta', 'goalsOnTrack', 'goalsTotal',
'overdueCount', 'hoursLogged', 'needsAttn', 'logicModel', 'hasLM',
'goalsGroup', 'report', 'strategyUpdates', 'programUpdates',
'programRows'
))
</div>
{{-- ═══ Page 2 Logic Model read-out ═════════════════ --}}
<div class="rd-page">
@include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report'))
</div>
{{-- ═══ Page 3 Resources & Coverage ═════════════════ --}}
<div class="rd-page">
@include('reports::partials.stakeholder.page-resources', compact('logicModel', 'hasLM', 'resourceSummary', 'report', 'scope', 'capacityAnalysis', 'programMeta', 'programChildMap', 'capacityByProgram') + ['projectId' => $projectId ?? null])
</div>
{{-- ═══ Page 4 Impact Journey ═══════════════════════ --}}
<div class="rd-page">
@include('reports::partials.stakeholder.page-impact-journey', compact('scope', 'logicModel', 'hasLM'))
</div>
</div>
</div>
</div>
</div>
<script>
/*
* Report deck navigation. Vanilla JS — no Alpine, no jQuery dependency for the
* core interaction. Supports: tab click, prev/next buttons, arrow keys,
* horizontal swipe.
*/
(function () {
if (window.__rdDeckInit) return;
window.__rdDeckInit = true;
window.rdActive = 0;
window.rdCount = 4;
// Per-user last-viewed page persists in localStorage so a refresh (and
// returning to the report) lands you back where you were, not on the
// Overview every time.
var LS_PAGE = 'lt.stakeholderReport.activePage';
window.rdGo = function (idx, opts) {
if (idx < 0 || idx >= window.rdCount) return;
window.rdActive = idx;
var track = document.getElementById('rdTrack');
if (!track) return;
track.style.transform = 'translateX(' + (-100 * idx) + '%)';
// Only the active page contributes to height (no dead space on short pages).
var pages = track.querySelectorAll('.rd-page');
pages.forEach(function (p, i) { p.classList.toggle('on', i === idx); });
// Tab state — scoped to this deck's nav; .lt-tab is a shared global
// class, so a bare selector could toggle unrelated tab groups.
document.querySelectorAll('#rdTabs .lt-tab').forEach(function (btn) {
var on = parseInt(btn.dataset.page, 10) === idx;
btn.classList.toggle('on', on);
if (on) { btn.setAttribute('aria-current', 'true'); } else { btn.removeAttribute('aria-current'); }
});
// Arrow enable state.
var prev = document.getElementById('rdPrev');
var next = document.getElementById('rdNext');
if (prev) prev.toggleAttribute('disabled', idx === 0);
if (next) next.toggleAttribute('disabled', idx === window.rdCount - 1);
// Persist unless the caller says otherwise (used on initial restore
// so we don't rewrite the value with the very value we just read).
if (!opts || opts.persist !== false) {
try { localStorage.setItem(LS_PAGE, String(idx)); } catch (e) {}
}
};
// Arrow keys — only when focus isn't in a text input.
document.addEventListener('keydown', function (e) {
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'ArrowLeft') window.rdGo(window.rdActive - 1);
if (e.key === 'ArrowRight') window.rdGo(window.rdActive + 1);
});
// Swipe (touch). Threshold 60px so accidental drags don't switch pages.
var deck = document.querySelector('.rd-deck-viewport');
if (deck) {
var startX = 0, startY = 0, tracking = false;
deck.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX; startY = e.touches[0].clientY; tracking = true;
}, { passive: true });
deck.addEventListener('touchend', function (e) {
if (!tracking) return; tracking = false;
var dx = e.changedTouches[0].clientX - startX;
var dy = e.changedTouches[0].clientY - startY;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx)) return;
window.rdGo(window.rdActive + (dx < 0 ? 1 : -1));
}, { passive: true });
}
// Initial state — restore the last-viewed page if persisted, else Overview.
var initialPage = 0;
try {
var saved = parseInt(localStorage.getItem(LS_PAGE) || '', 10);
if (!isNaN(saved) && saved >= 0 && saved < window.rdCount) initialPage = saved;
} catch (e) {}
window.rdGo(initialPage, { persist: false });
// Compact period-picker dropdown: toggle open, dismiss on outside click.
window.rdTogglePicker = function (e) {
if (e) e.stopPropagation();
var menu = document.getElementById('rdPickerMenu');
if (!menu) return;
menu.toggleAttribute('hidden');
};
document.addEventListener('click', function (e) {
var picker = document.getElementById('rdPicker');
if (!picker || picker.contains(e.target)) return;
var menu = document.getElementById('rdPickerMenu');
if (menu && !menu.hasAttribute('hidden')) menu.setAttribute('hidden', '');
});
// Wire the datepicker to the two custom-range inputs (same helper Marcel's
// periodpicker uses). Only if jQuery + the helper are present.
if (typeof jQuery !== 'undefined' && jQuery.fn.datepicker && window.leantime?.dateHelper) {
jQuery('.rd-picker-cinput').datepicker({
dateFormat: window.leantime.dateHelper.getFormatFromSettings('dateformat', 'jquery')
});
}
// KPI drill toggle — click a cell with .has-detail to open its drill list.
// Click elsewhere closes it. Only one open at a time.
document.addEventListener('click', function (e) {
var cell = e.target.closest('.rd-kcell.has-detail');
// Clicked inside the open drill? Let the click through (don't close).
if (e.target.closest('.rd-kcell.has-detail .kdrill')) return;
// Close every other open drill first (single-open behavior).
document.querySelectorAll('.rd-kcell.has-detail.open').forEach(function (c) {
if (c !== cell) c.classList.remove('open');
});
// Toggle the clicked cell (if any).
if (cell) cell.classList.toggle('open');
});
})();
</script>

View File

@@ -0,0 +1,537 @@
{{--
Stakeholder Report Page 4 (Impact Journey)
"Not a tracker — a vision that becomes proof."
One artifact, three lens views, tense-driven. Same bars, targets, and
people stay on screen; the framing and tense change around them.
Vision (Day 1, always available) targets only, "will"
Progress (unlocks with any snapshot) captured + remaining, "is becoming"
Impact (unlocks when targets met) achieved, "did"
Bookends both authored, both present Day 1:
STARTED (the world / the problem) the journey DIFFERENT (the impact)
Human meaning is AUTHORED on the canvas (item.description / item.assumptions
/ item.conclusion), never generated §7 rule 12. A concatenated narrative
is a fabrication when this ends up in a funder's hands.
Per-lens content is rendered server-side and CSS-toggled by the parent
wrapper's data-active-lens attribute so the "same component, words &
fill change" reads as one journey maturing, not hard cuts.
Vars in:
$logicModel null | {narrative, coverageMatrix, projectLinks, linkedGoals, ...}
$hasLM bool
$scope 'strategy' | 'program'
--}}
@if (! $hasLM)
<div class="p4-wrap">
<div class="p4-empty">
<div class="lb">{{ __('stakeholder.ij.no_lm_title') }}</div>
<div class="s">{{ __('stakeholder.ij.no_lm_hint') }}</div>
</div>
</div>
@else
@php
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
$projectLinks = $logicModel['projectLinks'] ?? [];
$linkedGoals = $logicModel['linkedGoals'] ?? [];
$outputItems = $stages['outputs']['items'] ?? [];
$outcomeItems = $stages['outcomes']['items'] ?? [];
$impactItems = $stages['impact']['items'] ?? [];
// Metric aggregator (unchanged from before; comment retained for context).
$metricFor = function ($item) use ($projectLinks, $linkedGoals) {
$itemId = (int) (((array) $item)['id'] ?? 0);
$links = $projectLinks[$itemId] ?? [];
$goals = [];
foreach ($links as $link) {
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
$gid = (int) ($link['linked_entity_id'] ?? 0);
if (isset($linkedGoals[$gid])) $goals[] = $linkedGoals[$gid];
}
if (count($goals) === 0) return null;
$current = array_sum(array_column($goals, 'currentValue'));
$target = array_sum(array_column($goals, 'endValue'));
$unit = $goals[0]['metricType'] ?? 'number';
$byDate = [];
foreach ($goals as $g) {
foreach (($g['snapshots'] ?? []) as $s) {
$day = substr((string) $s['date'], 0, 10);
if (! isset($byDate[$day])) $byDate[$day] = 0.0;
$byDate[$day] += (float) $s['value'];
}
}
ksort($byDate);
$snapshots = [];
foreach ($byDate as $day => $value) $snapshots[] = ['date' => $day, 'value' => $value];
$arr = (array) $item;
// Meaning source per §1 of the authored-meaning spec:
// why_this_matters — the primary source (authored, nullable)
// No fallback to conclusion — conclusion narrows to "as measured
// by" methodology only, going forward. Mining it for meaning is
// exactly the double-duty that broke Page 2 earlier.
return [
'id' => $itemId,
'label' => trim((string) ($arr['description'] ?? '')),
'meaning' => trim((string) ($arr['why_this_matters'] ?? '')),
'measuredBy' => trim((string) ($arr['conclusion'] ?? '')),
'current' => $current,
'target' => $target,
'unit' => $unit,
'snapshots' => $snapshots,
];
};
$anySnapshots = false;
$collectMetrics = function (array $items) use ($metricFor, &$anySnapshots) {
$metrics = [];
foreach ($items as $it) {
$m = $metricFor($it);
if ($m === null) continue;
if (count($m['snapshots']) > 0) $anySnapshots = true;
$metrics[] = $m;
}
return $metrics;
};
$outMetrics = $collectMetrics($outputItems);
$ocMetrics = $collectMetrics($outcomeItems);
$anyHit = false;
foreach (array_merge($outMetrics, $ocMetrics) as $m) {
if ($m['target'] > 0 && $m['current'] >= $m['target']) { $anyHit = true; break; }
}
$progressUnlocked = $anySnapshots;
$impactUnlocked = $anyHit;
// Default lens = the most advanced state the data supports. A page
// with snapshots defaults to Progress (not Vision) so a returning
// reader lands on the current reality — Vision is reachable via
// the toggle for the "share the promise" flow.
$defaultLens = $impactUnlocked ? 'impact' : ($progressUnlocked ? 'progress' : 'vision');
$fmt = function ($value, string $unit) {
$value = (float) $value;
if ($unit === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
if ($value == floor($value)) return number_format($value, 0, '.', ',');
return number_format($value, 1, '.', ',');
};
// Detects whether the authored label starts with the target number
// (e.g. "1,200 screenings completed" for target 1,200). Used to
// suppress the redundant "target N" subtitle on Vision — the label
// is already the promise.
$labelContainsTarget = function (string $label, float $target, string $unit) use ($fmt): bool {
if ($target <= 0) return false;
$formatted = $fmt($target, $unit);
// strip commas for a looser match too
$stripped = str_replace(',', '', $formatted);
$labelStripped = str_replace(',', '', $label);
return stripos($labelStripped, $stripped) !== false;
};
// ── STARTED bookend text.
// Authored ONLY. Sourced from the Impact item's `starting_picture`
// (a dedicated field for the world today, before this work). No
// synthesis, no fallback to narrative concatenation — an empty state
// is more honest than a fabrication, and this artifact ends up in
// funders' hands.
$startedText = '';
if (count($impactItems) > 0) {
$startedText = trim((string) (((array) $impactItems[0])['starting_picture'] ?? ''));
}
// ── DIFFERENT bookend: authored impact title + authored meaning.
// Meaning source: why_this_matters ONLY. No fallback to conclusion
// (which is now "as measured by" methodology). No fallback to
// assumptions (that's for the theory-of-change assertion, a
// different concept from the funder-facing meaning).
$differentTitle = '';
$differentMeaning = '';
if (count($impactItems) > 0) {
$impArr = (array) $impactItems[0];
$differentTitle = trim((string) ($impArr['description'] ?? ''));
$differentMeaning = trim((string) ($impArr['why_this_matters'] ?? ''));
}
// ── Arc statement (Vision beat 2). The ONE place a light
// concatenation is correct: assembled from authored labels, capped
// at 3 producing + 2 achieving, two sentences maximum. Never the
// §8 rule-1 canvas dump. Nothing generated — every word is a label
// a human wrote on the canvas.
$capProducing = array_slice($outMetrics, 0, 3);
$capAchieving = array_slice($ocMetrics, 0, 2);
$producingLabels = array_values(array_filter(array_map(
static fn ($m) => trim((string) $m['label']),
$capProducing
)));
$achievingLabels = array_values(array_filter(array_map(
static fn ($m) => trim((string) $m['label']),
$capAchieving
)));
$producingSentence = $producingLabels === []
? ''
: implode('. ', $producingLabels).'.';
$achievingSentence = '';
if (count($achievingLabels) === 1) {
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $achievingLabels[0]).'.';
} elseif (count($achievingLabels) >= 2) {
// Natural join: "X and Y" for two, "X, Y, and Z" for three (only
// if the cap is ever raised).
if (count($achievingLabels) === 2) {
$joined = $achievingLabels[0].' '.__('stakeholder.ij.beat_arc_and').' '.$achievingLabels[1];
} else {
$joined = implode(', ', array_slice($achievingLabels, 0, -1))
.', '.__('stakeholder.ij.beat_arc_and').' '.end($achievingLabels);
}
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $joined).'.';
}
$arcStatement = trim($producingSentence.' '.$achievingSentence);
// Only the "Logic Model canvas" phrase links out — the surrounding nudge
// sentence stays plain text. Built once, injected via sprintf %s below.
$lmCanvasLink = '<a href="'.BASE_URL.'/logicmodelcanvas/showCanvas">'.e(__('stakeholder.ij.nudge_link')).'</a>';
@endphp
<div class="p4-wrap" data-active-lens="{{ $defaultLens }}" data-p4-lens-wrap>
{{-- Header --}}
<div class="p4-hd">
<div>
<div class="t">{{ __('stakeholder.ij.header_title') }}</div>
<div class="s">{{ __('stakeholder.ij.header_sub') }}</div>
</div>
</div>
{{-- Lens toggle shows only when >=2 lenses exist for the reader --}}
@if ($progressUnlocked || $impactUnlocked)
<div class="p4-lens" data-p4-lens>
<button type="button" class="lopt @if ($defaultLens === 'vision') is-active @endif" data-lens-target="vision">{{ __('stakeholder.ij.lens_vision') }}</button>
<button type="button" class="lopt @if ($defaultLens === 'progress') is-active @endif @if (! $progressUnlocked) locked @endif" data-lens-target="progress" @if (! $progressUnlocked) disabled @endif>
@if (! $progressUnlocked)<i class="fa fa-lock"></i>@endif
{{ __('stakeholder.ij.lens_progress') }}
</button>
<button type="button" class="lopt @if ($defaultLens === 'impact') is-active @endif @if (! $impactUnlocked) locked @endif" data-lens-target="impact" @if (! $impactUnlocked) disabled @endif>
@if (! $impactUnlocked)<i class="fa fa-lock"></i>@endif
{{ __('stakeholder.ij.lens_impact') }}
</button>
</div>
@endif
{{-- ── VISION lens: three beats. Complete on day one, by design.
Beat 1 (world today) and beat 3 (world delivered) carry the
same authored text as the STARTED/DIFFERENT bookends below
the block-level lens toggle means only one shows at a
time, so there is no duplication for the reader. --}}
<div class="p4-vision" data-lens-block="vision">
<div class="beat today">
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.beat_today_lb') }}</div>
@if ($startedText !== '')
<div class="txt">{{ $startedText }}</div>
@else
<div class="empty">
{{ __('stakeholder.ij.bookend_started_empty') }}
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
</div>
@endif
</div>
<div class="beat arc">
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.beat_arc_lb') }}</div>
@if ($arcStatement !== '')
<div class="statement">{{ $arcStatement }}</div>
@else
<div class="empty">
{{ __('stakeholder.ij.beat_arc_empty') }}
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.beat_arc_empty_nudge')), $lmCanvasLink) !!}</span>
</div>
@endif
</div>
<div class="beat delivered">
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.beat_delivered_lb') }}</div>
@if ($differentTitle !== '')
<div class="txt">{{ $differentTitle }}</div>
@if ($differentMeaning !== '')
<div class="meaning">{{ $differentMeaning }}</div>
@endif
@else
<div class="empty">
{{ __('stakeholder.ij.bookend_different_empty') }}
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
</div>
@endif
</div>
</div>
{{-- ── PROGRESS + IMPACT: the same bookends flanking the arc with
live tracks. Hidden entirely on the Vision lens by the
block-level toggle above. --}}
<div data-lens-block="tracks">
{{-- STARTED bookend authored problem text or honest empty state.
NO narrative dump. NO generated summary. --}}
<div class="p4-bookend started">
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.bookend_started') }}</div>
@if ($startedText !== '')
<div class="meaning" style="margin-top:0;">{{ $startedText }}</div>
@else
<div class="empty">
{{ __('stakeholder.ij.bookend_started_empty') }}
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
</div>
@endif
</div>
{{-- The Arc --}}
@if (count($outMetrics) > 0 || count($ocMetrics) > 0)
<div class="p4-arc">
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.arc_label') }}</div>
@foreach ([
['key'=>'outputs', 'items'=>$outMetrics, 'title'=>__('stakeholder.ij.arc_producing'), 'icon'=>'fa-boxes-stacked'],
['key'=>'outcomes', 'items'=>$ocMetrics, 'title'=>__('stakeholder.ij.arc_achieving'), 'icon'=>'fa-chart-line'],
] as $group)
@if (count($group['items']) === 0) @continue @endif
<div class="p4-mgroup {{ $group['key'] }}">
<div class="gh"><i class="fa {{ $group['icon'] }}"></i> {{ $group['title'] }}</div>
@foreach ($group['items'] as $m)
@php
$n = count($m['snapshots']);
$growthState = $n <= 1 ? 'baseline' : ($n === 2 ? 'before_after' : 'full_arc');
$hitTarget = $m['target'] > 0 && $m['current'] >= $m['target'];
$peak = max($m['target'], $m['current'], 1);
foreach ($m['snapshots'] as $s) $peak = max($peak, (float) $s['value']);
$barH = fn ($v) => max(4, min(50, (int) round(($v / $peak) * 50)));
$labelHasTarget = $labelContainsTarget($m['label'], $m['target'], $m['unit']);
@endphp
<div class="p4-metric">
<div class="mn">
@if ($m['meaning'] !== '')
{{-- Meaning leads. The metric is evidence.
"as measured by {label}" reads as the receipt
underneath same rule as Page 2's verdict + read
pattern, one level down. --}}
{{ $m['meaning'] }}
<span class="meaning">
{{ __('stakeholder.ij.as_measured_by') }} {{ $m['label'] }}
@if ($m['measuredBy'] !== '')
— {{ $m['measuredBy'] }}
@endif
</span>
@else
{{-- No authored meaning yet: today's behavior the
authored label leads, optional methodology below.
No regression. --}}
{{ $m['label'] }}
@if (! $labelHasTarget && $m['target'] > 0)
<span class="tgt">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
@endif
@if ($m['measuredBy'] !== '')
<span class="meaning">{{ __('stakeholder.ij.as_measured_by') }} {{ $m['measuredBy'] }}</span>
@endif
@endif
</div>
{{-- ONE persistent bar per metric. Track = the target
(always visible, same shape all lenses). Fill = current
progress, animates on lens change via CSS transition.
Vision fill 0%. Progress fill current/target. Impact
fill 100%. The morph IS the story. --}}
@php
// Cap the visual fill % — over-target still reads as full,
// "we did this" carries the over-delivery in the value label.
$progressPct = $m['target'] > 0
? min(100, ($m['current'] / $m['target']) * 100)
: 0;
// Fill values per lens — the JS uses these dataset attrs
// to swap width + label text on lens change.
$visionFill = 0;
$progressFill = $progressPct;
$impactFill = 100;
$visionLabel = __('stakeholder.ij.fill_lbl_vision');
$progressLabel = sprintf(__('stakeholder.ij.fill_lbl_progress'), $fmt($m['current'], $m['unit']));
$impactLabel = sprintf(__('stakeholder.ij.fill_lbl_impact'), $fmt(max($m['current'], $m['target']), $m['unit']));
@endphp
<div class="p4-arc-viz"
data-p4-bar
data-vision-fill="{{ $visionFill }}"
data-progress-fill="{{ $progressFill }}"
data-impact-fill="{{ $impactFill }}"
data-vision-lbl="{{ $visionLabel }}"
data-progress-lbl="{{ $progressLabel }}"
data-impact-lbl="{{ $impactLabel }}">
<div class="p4-scale">
<span class="p4-fill-lbl">{{
$defaultLens === 'vision' ? $visionLabel
: ($defaultLens === 'impact' ? $impactLabel : $progressLabel)
}}</span>
<span class="p4-target-lbl">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
</div>
<div class="p4-track">
<div class="p4-fill" style="width:{{
$defaultLens === 'vision' ? $visionFill
: ($defaultLens === 'impact' ? $impactFill : $progressFill)
}}%;"></div>
</div>
</div>
{{-- ─── VERDICTS tense-aware, encouragement via structure ── --}}
{{-- Vision: future tense. --}}
<span class="p4-verdict willbe" data-lens="vision">
<span class="sd"></span> {{ __('stakeholder.ij.v_will_be_measured') }}
</span>
{{-- Progress: present-becoming tense. One framing for both
units "N% of the way" reads truthfully for counts and
percents alike, and stays consistent down the column
regardless of metric type. --}}
{{-- One progress color for every in-progress row: the text is
uniformly "N% of the way", and the percentage itself conveys
how far along. A silent amber↔blue flip at a hidden 75%
threshold made identically-worded rows look different for no
visible reason, so the dot stays consistent down the column. --}}
<span class="p4-verdict @if ($hitTarget) hit @elseif ($n === 0) captured @else trending @endif" data-lens="progress">
<span class="sd"></span>
@if ($n === 0)
{{ __('stakeholder.ij.v_no_snapshots_yet') }}
@elseif ($hitTarget)
{{ __('stakeholder.ij.v_hit_target_progress') }}
@elseif ($m['target'] > 0)
{{ sprintf(__('stakeholder.ij.v_pct_of_way'), (int) round(($m['current'] / $m['target']) * 100)) }}
@else
{{ __('stakeholder.ij.v_trending') }}
@endif
</span>
{{-- Impact: past tense. Only truthful for hit-target rows. --}}
<span class="p4-verdict @if ($hitTarget) hit @else captured @endif" data-lens="impact">
<span class="sd"></span>
@if ($hitTarget)
{{ __('stakeholder.ij.v_we_did_this') }}
@else
{{ __('stakeholder.ij.v_not_yet_impact') }}
@endif
</span>
</div>
@endforeach
</div>
@endforeach
{{-- Structural encouragement only when the claim can be
reconstructed from what's on screen (§7 rule 8 / v6 Fix 2).
Progress-lens "one more completes the arc" needed a period
cadence the page doesn't render yet; dropped until we do. --}}
@if (! $progressUnlocked)
<div class="p4-chip" data-lens="vision"><i class="fa fa-circle-info"></i> {{ __('stakeholder.ij.chip_first_period') }}</div>
@endif
</div>
@else
<div class="p4-empty">
<div class="lb">{{ __('stakeholder.ij.no_metrics_title') }}</div>
<div class="s">{{ __('stakeholder.ij.no_metrics_hint') }}</div>
</div>
@endif
{{-- DIFFERENT bookend authored impact + supporting meaning.
Meaning is the emotional payload: WHY this matters to the
people it affects. Never generated. --}}
<div class="p4-bookend different">
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.bookend_different') }}</div>
@if ($differentTitle !== '')
<div class="txt">{{ $differentTitle }}</div>
@if ($differentMeaning !== '')
<div class="meaning">{{ $differentMeaning }}</div>
@endif
{{-- Tense hints one per lens, framing shifts with capture state. --}}
<span class="tense-hint" data-lens="vision">{{ __('stakeholder.ij.different_tense_vision') }}</span>
<span class="tense-hint" data-lens="progress">{{ __('stakeholder.ij.different_tense_progress') }}</span>
<span class="tense-hint" data-lens="impact">{{ __('stakeholder.ij.different_tense_impact') }}</span>
@else
<div class="empty">
{{ __('stakeholder.ij.bookend_different_empty') }}
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
</div>
@endif
</div>
</div>{{-- /[data-lens-block=tracks] --}}
</div>
<script>
(function () {
var wrap = document.querySelector('[data-p4-lens-wrap]');
var toggle = document.querySelector('[data-p4-lens]');
if (! wrap || ! toggle) return;
var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Morph a single bar to the target lens: swap fill width + label text.
// Width transition is CSS-driven; label crossfade is a quick opacity dip.
function morphBar (viz, lens) {
var fillEl = viz.querySelector('.p4-fill');
var lblEl = viz.querySelector('.p4-fill-lbl');
if (! fillEl || ! lblEl) return;
var pct = viz.getAttribute('data-' + lens + '-fill');
var lbl = viz.getAttribute('data-' + lens + '-lbl');
if (pct !== null) fillEl.style.width = pct + '%';
if (lbl !== null) {
if (reducedMotion) {
lblEl.textContent = lbl;
} else {
lblEl.style.opacity = '0';
setTimeout(function () { lblEl.textContent = lbl; lblEl.style.opacity = '1'; }, 140);
}
}
}
function switchLens (lens) {
var wasVision = wrap.getAttribute('data-active-lens') === 'vision';
wrap.setAttribute('data-active-lens', lens);
// Arrival — when leaving Vision for Progress/Impact, the tracks
// don't just appear (that would be a jump); they stagger in.
// Rows start hidden (.arriving), then .arrived is added on a
// stagger so the CSS transition plays. Reduced-motion → instant.
if (wasVision && lens !== 'vision' && ! reducedMotion) {
var rows = wrap.querySelectorAll('[data-lens-block="tracks"] .p4-metric');
rows.forEach(function (row) { row.classList.remove('arrived'); row.classList.add('arriving'); });
rows.forEach(function (row, i) {
setTimeout(function () {
row.classList.remove('arriving');
row.classList.add('arrived');
}, i * 80);
});
}
var bars = wrap.querySelectorAll('[data-p4-bar]');
bars.forEach(function (viz, i) {
// Stagger by 80ms so the morph reads as a sequence, not a jump.
var delay = reducedMotion ? 0 : (i * 80);
if (delay === 0) morphBar(viz, lens);
else setTimeout(function () { morphBar(viz, lens); }, delay);
});
}
toggle.addEventListener('click', function (e) {
var btn = e.target.closest('.lopt');
if (! btn || btn.classList.contains('locked') || btn.hasAttribute('disabled')) return;
var target = btn.getAttribute('data-lens-target');
if (! target) return;
toggle.querySelectorAll('.lopt').forEach(function (b) { b.classList.toggle('is-active', b === btn); });
switchLens(target);
});
})();
</script>
@endif

View File

@@ -0,0 +1,832 @@
{{--
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

View File

@@ -0,0 +1,626 @@
{{--
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>

View File

@@ -0,0 +1,104 @@
{{--
Stakeholder Report Page 4 (Programs & Narrative)
§5 page 4: Two columns program rows with RAG + completed count on the
left, status narrative from statusUpdates on the right. "Also this period"
(secondary closures) at the bottom.
Vars in:
$scope 'strategy' | 'program'
$programRows object[] (strategy scope; empty at program scope)
$programUpdates array<int,object[]> (byProject at strategy scope)
--}}
<div class="p4-two">
{{-- ── Programs column (strategy scope only) ─────────────────── --}}
<div>
<div class="p4-lbl">{{ $scope === 'strategy' ? __('stakeholder.programs.label_programs') : __('stakeholder.programs.label_child_projects') }}</div>
@if (count($programRows) === 0)
<div class="p4-empty">{{ $scope === 'strategy' ? __('stakeholder.programs.none') : __('stakeholder.programs.none_projects') }}</div>
@else
@foreach ($programRows as $row)
@php
$row = (array) $row;
// Status → dot color. programRows carries a status field from Marcel's
// buildProgramRows (worst-of-children rollup); values: green/yellow/red/null.
$status = (string) ($row['status'] ?? '');
$dotColor = match ($status) {
'green' => 'var(--rd-ok)',
'yellow' => 'var(--rd-warn)',
'red' => 'var(--rd-danger)',
default => 'var(--rd-text-4)',
};
$statusLabel = match ($status) {
'green' => __('stakeholder.programs.status_ontrack'),
'yellow' => __('stakeholder.programs.status_atrisk'),
'red' => __('stakeholder.programs.status_off'),
default => __('stakeholder.programs.status_none'),
};
$completedCt = (int) ($row['completedCount'] ?? 0);
@endphp
<div class="p4-prog">
<span class="pd" style="background:{{ $dotColor }}"></span>
<span class="pn">{{ $row['name'] ?? '' }}</span>
<span class="pm">{{ $statusLabel }} · {{ sprintf(__('stakeholder.programs.done_count'), $completedCt) }}</span>
</div>
@endforeach
@endif
</div>
{{-- ── Status narrative column ───────────────────────────────── --}}
<div>
<div class="p4-lbl">{{ __('stakeholder.programs.label_narrative') }}</div>
@php
// programUpdates is keyed by projectId. Flatten with a name lookup from
// programRows for the bold label per line.
$namesByProject = [];
foreach ($programRows as $row) {
$row = (array) $row;
$namesByProject[(int) ($row['id'] ?? 0)] = $row['name'] ?? '';
}
$flatUpdates = [];
foreach ($programUpdates as $pid => $updates) {
foreach ($updates as $update) {
$update = (object) $update;
$update->_projectName = $namesByProject[(int) $pid] ?? '';
$flatUpdates[] = $update;
}
}
// Sort newest first — Marcel returns in the same order per project;
// for cross-project flat display we re-sort by date desc.
usort($flatUpdates, fn ($a, $b) => strcmp((string) ($b->date ?? ''), (string) ($a->date ?? '')));
$flatUpdates = array_slice($flatUpdates, 0, 5); // top 5 for the packet view
@endphp
@if (count($flatUpdates) === 0)
<div class="p4-empty">{{ __('stakeholder.programs.no_updates') }}</div>
@else
@foreach ($flatUpdates as $u)
@php
$date = ! empty($u->dateParsed) ? $u->dateParsed->setToUserTimezone()->format('M j') : '';
$text = trim(strip_tags((string) ($u->text ?? '')));
// Truncate long updates — board views want the executive summary.
if (mb_strlen($text) > 220) $text = mb_substr($text, 0, 217).'…';
@endphp
<div class="p4-exec">
@if (! empty($u->_projectName))
<b>{{ $u->_projectName }}</b>
@endif
{{ $text }}
@if ($date !== '') <span class="ed">{{ $date }}</span> @endif
</div>
@endforeach
@endif
</div>
</div>
{{-- "Also this period" secondary closures beyond the peak-this-period hero.
Nomination surface for closures that could be attached to an outcome.
Requires the nomination pass that also feeds the p1 hero; render coming-soon
until that lands, to avoid pretending an empty state is a full one. --}}
<div class="p4-also">
<span class="al-lb">{{ __('stakeholder.programs.also_label') }}</span>
<span style="color:var(--rd-text-3);"> {{ __('stakeholder.programs.also_coming') }}</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{{--
Row of summary stat tiles with optional period-over-period deltas.
Expects:
$tiles: array of [
'label' => string,
'value' => string|int|float,
'tone' => 'default'|'danger' (danger colors the value red when > 0),
'delta' => null|['value' => float, 'goodWhenUp' => bool|null, 'vs' => string],
]
--}}
<div class="reportStatTiles">
@foreach ($tiles as $tile)
@php
$isDanger = ($tile['tone'] ?? 'default') === 'danger' && (float) $tile['value'] > 0;
$delta = $tile['delta'] ?? null;
$deltaClass = '';
if ($delta !== null && $delta['value'] != 0 && ($delta['goodWhenUp'] ?? null) !== null) {
$isGood = ($delta['value'] > 0) === $delta['goodWhenUp'];
$deltaClass = $isGood ? 'deltaGood' : 'deltaBad';
}
@endphp
<div class="reportStatTile">
<span class="tileLabel">{{ $tile['label'] }}</span>
<span class="tileValue @if ($isDanger) tileValueDanger @endif">{{ $tile['value'] }}</span>
@if ($delta !== null)
<span class="tileDelta {{ $deltaClass }}">
@if ($delta['value'] > 0) +{{ \Illuminate\Support\Number::format($delta['value'], maxPrecision: 1) }}
@elseif ($delta['value'] < 0) {{ \Illuminate\Support\Number::format(abs($delta['value']), maxPrecision: 1) }}
@else ±0
@endif
{{ $delta['vs'] }}
</span>
@endif
</div>
@endforeach
</div>

View File

@@ -0,0 +1,35 @@
{{--
Status narrative: the period's status updates as a human-readable feed, grouped by
project, newest first, colored by their green/yellow/red status.
Expects:
$updatesByProject: array<int, object[]> - projectId => status updates (engine output)
$summaries: array<int, object> - project summaries keyed by id (for names)
$showProjects: bool
$emptyText: string
--}}
@php
$showProjects = $showProjects ?? false;
$narrativeColors = ['green' => 'var(--green)', 'yellow' => 'var(--yellow)', 'red' => 'var(--red)'];
@endphp
@if (count($updatesByProject) === 0)
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
@else
<div class="reportStatusNarrative tw-flex tw-flex-col tw-gap-3">
@foreach ($updatesByProject as $projectId => $updates)
@if ($showProjects && isset($summaries[$projectId]))
<strong class="tw-mt-1">{{ $tpl->escape($summaries[$projectId]->name) }}</strong>
@endif
@foreach ($updates as $update)
<div class="reportStatusUpdate tw-pl-3 tw-py-1" style="border-left: 4px solid {{ $narrativeColors[$update->status] ?? 'var(--grey)' }};">
<div class="tw-text-xs tw-opacity-60">
{{ $tpl->escape(trim(($update->authorFirstname ?? '').' '.($update->authorLastname ?? ''))) }}
· {{ $update->dateParsed?->formatDateForUser() ?? '' }}
</div>
<div class="tw-text-sm">{!! $tpl->escapeMinimal($update->text) !!}</div>
</div>
@endforeach
@endforeach
</div>
@endif

View File

@@ -0,0 +1,34 @@
{{--
Colored project status pill (green/yellow/red from the latest status update).
Expects:
$status: string|null - green|yellow|red (null = no update yet)
$date: \Carbon\CarbonImmutable|null - when the status was posted (optional)
--}}
@php
$pillColors = [
'green' => 'var(--green)',
'yellow' => 'var(--yellow)',
'red' => 'var(--red)',
];
$pillLabels = [
'green' => __('label.status_on_track'),
'yellow' => __('label.status_at_risk'),
'red' => __('label.status_off_track'),
];
@endphp
@if (!empty($status) && isset($pillColors[$status]))
<span class="reportStatusPill tw-text-sm">
<span class="statusDot" style="background:{{ $pillColors[$status] }};"></span>
{{ $pillLabels[$status] }}
@if (!empty($date))
<span class="tw-opacity-60">· {{ $date->formatDateForUser() }}</span>
@endif
</span>
@else
<span class="reportStatusPill tw-text-sm tw-opacity-60">
<span class="statusDot" style="background:var(--grey);"></span>
{{ __('label.status_no_update') }}
</span>
@endif

View File

@@ -0,0 +1,46 @@
@extends($layout)
@section('content')
@php
$summary = $report['summaries'][$projectId] ?? null;
@endphp
<x-global::pageheader :icon="'fa fa-chart-bar'">
<div class="row">
<div class="col-lg-8">
<h5>{{ session('currentProjectClient') ? session('currentProjectClient') . ' // ' : '' }}{{ session('currentProjectName') }}</h5>
<h1>{!! __('headlines.status_report') !!}</h1>
</div>
<div class="col-lg-4" style="text-align: right;">
<x-global::forms.button tag="button" inputType="button" onclick="window.print();" class="btn-secondary hideOnPrint">
<i class="fa fa-print"></i> {{ __('label.print_report') }}
</x-global::forms.button>
</div>
</div>
</x-global::pageheader>
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="tw-flex tw-items-center tw-justify-between tw-flex-wrap tw-gap-2 tw-mb-4 hideOnPrint">
<ul class="tabs-list tw-m-0" style="display:inline-flex; gap: 4px;">
<li class="active"><a href="{{ BASE_URL }}/reports/project">{{ __('label.status_report_tab') }}</a></li>
<li><a href="{{ BASE_URL }}/reports/show">{{ __('label.delivery_metrics_tab') }}</a></li>
</ul>
<x-global::periodpicker
:period="$period"
:url="BASE_URL.'/reports/project'"
:hxUrl="BASE_URL.'/hx/reports/projectReport/get'"
target="#reportBody" />
</div>
@include('reports::partials.projectReportBody', ['report' => $report, 'period' => $period, 'projectId' => $projectId])
</div>
</div>
@endsection

View File

@@ -0,0 +1,296 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa fa-chart-bar"></span></div>
<div class="pagetitle">
<div class="row">
<div class="col-lg-8">
<h5>{{ session('currentProjectClient') . ' // ' . session('currentProjectName') }}</h5>
<h1>{!! __('headlines.reports') !!}</h1>
</div>
</div>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<ul class="tabs-list tw-mb-4" style="display:inline-flex; gap: 4px;">
<li><a href="{{ BASE_URL }}/reports/project">{{ __('label.status_report_tab') }}</a></li>
<li class="active"><a href="{{ BASE_URL }}/reports/show">{{ __('label.delivery_metrics_tab') }}</a></li>
</ul>
<div class="row">
<div class="col-lg-8">
<div class="row" id="yourToDoContainer">
<div class="col-md-12">
<h5 class="subtitle">{!! __('subtitles.summary') !!} @if ($fullReportLatest)({{ format($fullReportLatest['date'])->date() }})@endif </h5>
<div class="row">
<div class="col-md-3">
<div class="boxedHighlight">
<span class="headline">{!! __('label.planned_hours') !!}</span>
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_planned_hours'] != null){{ format($fullReportLatest['sum_planned_hours'])->decimal() }}@else{{ 0 }}@endif</span>
</div>
</div>
<div class="col-md-3">
<div class="boxedHighlight">
<span class="headline">{!! __('label.estimated_hours_remaining') !!}</span>
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_estremaining_hours'] != null){{ format($fullReportLatest['sum_estremaining_hours'])->decimal() }}@else{{ 0 }}@endif</span>
</div>
</div>
<div class="col-md-3">
<div class="boxedHighlight">
<span class="headline">{!! __('label.booked_hours') !!}</span>
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_logged_hours'] != null){{ format($fullReportLatest['sum_logged_hours'])->decimal() }}@else{{ 0 }}@endif</span>
</div>
</div>
<div class="col-md-3">
<div class="boxedHighlight">
<span class="headline">{!! __('label.open_todos') !!}</span>
<span class="value">
{{-- Open To-Dos is a COUNT of tickets (SUM of CASE WHEN
status = X THEN 1 in the repo), so it's inherently
an integer. ->decimal() rendered "1" as "1.00" —
read as a broken chart value in the audit. --}}
@if ($fullReportLatest !== false)
{{ (int) ($fullReportLatest['sum_open_todos'] + $fullReportLatest['sum_progres_todos']) }}
@else
{{ 0 }}
@endif
</span>
</div>
</div>
</div>
{{-- Hide the whole Sprint Burndown section when the project has
no sprints. Previously the outer guard was just `!== false`, so an
empty array (project without sprints) rendered the title + toggle
buttons + empty canvas with no chart underneath — read as broken
in the audit. --}}
@if ($allSprints !== false && count($allSprints) > 0)
<h5 class="subtitle">{!! __('subtitles.sprint_burndown') !!}</h5>
<br />
<span class="pull-left">
@if (true)
<select data-placeholder="{{ __('input.placeholders.filter_by_sprint') }}" title="{{ __('input.placeholders.filter_by_sprint') }}" name="sprint" class="mainSprintSelector" onchange="location.href='{{ BASE_URL }}/reports/show?sprint='+jQuery(this).val()" id="sprintSelect">
<option value="" >{!! __('input.placeholders.filter_by_sprint') !!}</option>
@php $dates = ''; @endphp
@foreach ($allSprints as $sprintRow)
<option value="{{ $sprintRow->id }}"
@if ($currentSprint !== false && $sprintRow->id == $currentSprint)
selected="selected"
@php $dates = sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()); @endphp
@endif
>{{ $tpl->escape($sprintRow->name) }}</option>
@endforeach
</select>
@endif
</span>
<div class="pull-right">
<div class="btn-group mt-1 mx-auto" role="group">
<x-global::forms.button tag="a" id="NumChartButtonSprint" class="btn-sm btn-secondary active chartButtons" link="javascript:void(0)">{!! __('label.num_tickets') !!}</x-global::forms.button>
<x-global::forms.button tag="a" id="EffortChartButtonSprint" class="btn-sm btn-secondary chartButtons" link="javascript:void(0)">{!! __('label.effort') !!}</x-global::forms.button>
<x-global::forms.button tag="a" id="HourlyChartButtonSprint" class="btn-sm btn-secondary chartButtons" link="javascript:void(0)">{!! __('label.hours') !!}</x-global::forms.button>
</div>
</div>
<div style="width:100%; height:350px;">
<canvas id="sprintBurndown"></canvas>
</div>
@endif
<div class="clearall"></div>
<br />
<br />
<h5 class="subtitle">{!! __('subtitles.cummulative_flow') !!}</h5>
<div class="pull-right">
<div class="btn-group mt-1 mx-auto" role="group">
<x-global::forms.button tag="a" id="NumChartButtonBacklog" class="btn-sm btn-secondary active backlogChartButtons" link="javascript:void(0)">{!! __('label.num_tickets') !!}</x-global::forms.button>
<x-global::forms.button tag="a" id="EffortChartButtonBacklog" class="btn-sm btn-secondary backlogChartButtons" link="javascript:void(0)">{!! __('label.effort') !!}</x-global::forms.button>
<x-global::forms.button tag="a" id="HourlyChartButtonBacklog" class="btn-sm btn-secondary backlogChartButtons" link="javascript:void(0)">{!! __('label.hours') !!}</x-global::forms.button>
</div>
</div>
<div style="width:100%; height:350px;">
<canvas id="backlogBurndown"></canvas>
</div>
<div class="clearall"></div>
<br />
<br />
</div>
</div>
</div>
<div class="col-lg-4">
<div class="row" id="projectProgressContainer">
<div class="col-md-12">
<h5 class="subtitle">{!! __('subtitles.project_progress') !!}</h5>
<div id="canvas-holder" style="width:100%; height:250px;">
<canvas id="chart-area" ></canvas>
</div>
<br /><br />
</div>
</div>
<div class="row" id="milestoneProgressContainer">
<div class="col-md-12">
<h5 class="subtitle">{!! __('headline.milestones') !!}</h5>
<ul class="sortableTicketList" >
@if (count($milestones) == 0)
<div class='center'><br /><h4>{!! __('headlines.no_milestones') !!}</h4>
{!! __('text.milestones_help_organize_projects') !!}<br /><br /><a href="{{ BASE_URL }}/tickets/roadmap">{!! __('links.goto_milestones') !!}</a>
@endif
@foreach ($milestones as $row)
<li class="ui-state-default" id="milestone_{{ $row->id }}" >
<div class="ticketBox fixed">
<div class="row">
<div class="col-md-12">
<strong><a href="{{ BASE_URL }}/tickets/editMilestone/{{ $row->id }}" class="milestoneModal">{{ $tpl->escape($row->headline) }}</a></strong>
</div>
</div>
<div class="row">
<div class="col-md-7">
{!! __('label.due') !!}
{{ format($row->editTo)->date(__('text.no_date_defined')) }}
</div>
<div class="col-md-5" style="text-align:right">
{!! sprintf(__('text.percent_complete'), $row->percentDone) !!}
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="progress">
<div class="progress-bar progress-bar-success" role="progressbar" aria-valuenow="{{ $row->percentDone }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ $row->percentDone }}%">
<span class="sr-only">{!! sprintf(__('text.percent_complete'), $row->percentDone) !!}</span>
</div>
</div>
</div>
</div>
</div>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
leantime.dashboardController.prepareHiddenDueDate();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initStatusDropdown();
leantime.dashboardController.initProgressChart("chart-area", {{ round($projectProgress['percent']) }}, {{ round((100 - $projectProgress['percent'])) }});
@if ($sprintBurndown !== false)
var sprintBurndownChart = leantime.dashboardController.initBurndown([@foreach ($sprintBurndown as $value)'{{ $value['date'] }}',@endforeach], [@foreach ($sprintBurndown as $value)'{{ round($value['plannedNum'], 2) }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualNum'] !== '')'{{ $value['actualNum'] }}',@endif @endforeach ]);
leantime.dashboardController.initChartButtonClick('HourlyChartButtonSprint', '{!! __('label.hours') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedHours'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualHours'] !== '')'{{ round($value['actualHours']) }}',@endif @endforeach ], sprintBurndownChart);
leantime.dashboardController.initChartButtonClick('EffortChartButtonSprint', '{!! __('label.effort') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedEffort'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualEffort'] !== '')'{{ $value['actualEffort'] }}',@endif @endforeach ], sprintBurndownChart);
leantime.dashboardController.initChartButtonClick('NumChartButtonSprint', '{!! __('label.num_tickets') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedNum'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualNum'] !== '')'{{ $value['actualNum'] }}',@endif @endforeach ], sprintBurndownChart);
@endif
@if ($backlogBurndown !== false)
var statusBurnupNum = [];
statusBurnupNum['open'] = {
'label': 'Open',
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualNum'] !== '')'{{ $value['open']['actualNum'] }}',@endif @endforeach]
};
statusBurnupNum['progress'] = {
'label': 'Progress',
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualNum'] !== '')'{{ $value['progress']['actualNum'] }}',@endif @endforeach]
};
statusBurnupNum['done'] = {
'label': 'Done',
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualNum'] !== '')'{{ $value['done']['actualNum'] }}',@endif @endforeach]
};
var backlogBurndown = leantime.dashboardController.initBacklogBurndown([@foreach ($backlogBurndown as $value)'{{ $value['date'] }}',@endforeach], statusBurnupNum);
var statusBurnupEffort = [];
statusBurnupEffort['open'] = {
'label': 'Open',
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualEffort'] !== '')'{{ $value['open']['actualEffort'] }}',@endif @endforeach]
};
statusBurnupEffort['progress'] = {
'label': 'Progress',
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualEffort'] !== '')'{{ $value['progress']['actualEffort'] }}',@endif @endforeach]
};
statusBurnupEffort['done'] = {
'label': 'Done',
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualEffort'] !== '')'{{ $value['done']['actualEffort'] }}',@endif @endforeach]
};
var statusBurnupHours = [];
statusBurnupHours['open'] = {
'label': 'Open',
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualHours'] !== '')'{{ $value['open']['actualHours'] }}',@endif @endforeach]
};
statusBurnupHours['progress'] = {
'label': 'Progress',
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualHours'] !== '')'{{ $value['progress']['actualHours'] }}',@endif @endforeach]
};
statusBurnupHours['done'] = {
'label': 'Done',
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualHours'] !== '')'{{ $value['done']['actualHours'] }}',@endif @endforeach]
};
leantime.dashboardController.initBacklogChartButtonClick('HourlyChartButtonBacklog', statusBurnupHours, '{!! __('label.hours') !!}', backlogBurndown);
leantime.dashboardController.initBacklogChartButtonClick('EffortChartButtonBacklog', statusBurnupEffort, '{!! __('label.effort') !!}', backlogBurndown);
leantime.dashboardController.initBacklogChartButtonClick('NumChartButtonBacklog', statusBurnupNum, '{!! __('label.num_tickets') !!}', backlogBurndown);
@endif
});
</script>
@endpush
@endonce
@endsection