OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -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
|
||||
80
app/Domain/Reports/Templates/partials/goalTable.blade.php
Normal file
80
app/Domain/Reports/Templates/partials/goalTable.blade.php
Normal 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
|
||||
101
app/Domain/Reports/Templates/partials/milestoneList.blade.php
Normal file
101
app/Domain/Reports/Templates/partials/milestoneList.blade.php
Normal 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
|
||||
@@ -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
|
||||
46
app/Domain/Reports/Templates/partials/outcome.blade.php
Normal file
46
app/Domain/Reports/Templates/partials/outcome.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
343
app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php
Normal file
343
app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php
Normal 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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
37
app/Domain/Reports/Templates/partials/statTiles.blade.php
Normal file
37
app/Domain/Reports/Templates/partials/statTiles.blade.php
Normal 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>
|
||||
@@ -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
|
||||
34
app/Domain/Reports/Templates/partials/statusPill.blade.php
Normal file
34
app/Domain/Reports/Templates/partials/statusPill.blade.php
Normal 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
|
||||
46
app/Domain/Reports/Templates/project.blade.php
Normal file
46
app/Domain/Reports/Templates/project.blade.php
Normal 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
|
||||
296
app/Domain/Reports/Templates/show.blade.php
Normal file
296
app/Domain/Reports/Templates/show.blade.php
Normal 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
|
||||
Reference in New Issue
Block a user