Files
Leantime/app/Domain/Reports/Templates/partials/stakeholder/page-resources.blade.php

1001 lines
50 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{--
Stakeholder Report Page 3 (Resources)
Pure resource story: aggregate per-project breakdown gaps & risks.
Reads ResourcesGateway (ResourceSummary value object). Three sections:
1. Aggregate cards the 30-second overview (People / Budget / Deps)
2. Per-project breakdown the "where's it going" drill-down, one row per project
3. Resource gaps & risks plain-English callouts of imbalances that need attention
The old LM-item→program linkage matrix moved off this page that question
belongs on the Logic Model read-out (Page 2), where board readers have
context for what "linkage" means.
Sizing note: board audiences skew older and reports often get projected or
printed. Cards and numbers are intentionally large this is the default,
not an accessibility toggle.
Vars in:
$resourceSummary null | \Leantime\Core\Resources\Models\ResourceSummary
$report array from ReportEngine::buildReport (has .summaries[] with id/name)
$scope 'strategy' | 'program'
$logicModel, $hasLM passed through but not used here anymore
--}}
@php
// Does this strategy/program actually have authored resource data?
// $resourceSummary is non-null whenever the provider (PgmPro) is installed —
// even with nothing authored — so a null check alone leaves a wall of empty
// "—" cards. Detect "installed but empty" here to drive the onboarding empty
// state and suppress the noisy empty sections below. (For a strategy the
// strategy's own board is always empty by design; data rolls up from its
// programs — so this is true whenever ANY program in scope has authored it.)
// Use the aggregates ResourceSummary already computed — no view-level loop
// over people/allocations. "Any active person" (allocation or capacity > 0)
// reduces to totalCapacity/totalAllocated being > 0.
$hasResourceData = $resourceSummary !== null && (
$resourceSummary->totalCapacity > 0
|| $resourceSummary->totalAllocated > 0
|| $resourceSummary->totalBudgeted > 0
|| count($resourceSummary->dependencies) > 0
);
@endphp
@if ($hasResourceData)
{{-- Hours / Days unit toggle. The server always renders hours (source of
truth); JS swaps any element with `data-hours` to a days display (÷ 8)
when the toggle is set. Preference stored in localStorage. --}}
<div class="p3-unit-toggle" data-lt-unit-toggle>
<span class="p3-unit-lbl">{{ __('stakeholder.rc.unit.show') }}</span>
<div class="p3-unit-pill">
<button type="button" class="p3-unit-btn is-active" data-unit="hours">{{ __('stakeholder.rc.unit.hours') }}</button>
<button type="button" class="p3-unit-btn" data-unit="days">{{ __('stakeholder.rc.unit.days') }}</button>
</div>
</div>
@endif
{{-- ── Resources summary from ResourcesGateway ────────────────────── --}}
<div class="p3-sec">
<div class="p3-sec-hd">
<span class="l">{{ __('stakeholder.rc.res_label') }}</span>
<span class="s">{{ __('stakeholder.rc.res_sub_live') }}</span>
</div>
@if ($resourceSummary === null)
{{-- No ResourcesGateway registered (plugin not installed / disabled). --}}
<div class="p3-res-strip">
<div class="icn"><i class="fa fa-people-arrows"></i></div>
<div class="cnt">
<div class="h">{{ __('stakeholder.rc.no_provider_title') }}</div>
<div class="d">{{ __('stakeholder.rc.no_provider_hint') }}</div>
</div>
</div>
@elseif (! $hasResourceData)
{{-- Provider installed, nothing authored yet. Resource data always comes
from the program(s) a strategy's own board stays empty by design —
so guide the reader to the next real action rather than showing a
wall of empty cards and sections. Three cases:
• program report → set up that program's allocation
strategy with no programs create a program first
strategy with programs open one and add resources --}}
@php
$programCount = count($programMeta ?? []);
if (($scope ?? '') === 'program') {
$emptyTitle = __('stakeholder.rc.empty_title');
$emptyHint = __('stakeholder.rc.empty_hint_program');
$emptyCtaHref = BASE_URL.'/pgmPro/resourceAllocation';
$emptyCtaLabel = __('stakeholder.rc.empty_cta');
} elseif ($programCount === 0) {
$emptyTitle = __('stakeholder.rc.empty_noprog_title');
$emptyHint = __('stakeholder.rc.empty_hint_noprog');
$emptyCtaHref = (int) ($projectId ?? 0) > 0 ? BASE_URL.'/projects/newProject?parent='.(int) $projectId : null;
$emptyCtaLabel = __('stakeholder.rc.empty_cta_create_program');
} else {
$emptyTitle = __('stakeholder.rc.empty_title');
$emptyHint = __('stakeholder.rc.empty_hint_strategy');
$emptyCtaHref = null;
$emptyCtaLabel = null;
}
@endphp
<div class="p3-res-strip">
<div class="icn"><i class="fa fa-people-arrows"></i></div>
<div class="cnt">
<div class="h">{{ $emptyTitle }}</div>
<div class="d">{{ $emptyHint }}</div>
</div>
@if ($emptyCtaHref !== null)
<a href="{{ $emptyCtaHref }}" class="p3-res-cta">{{ $emptyCtaLabel }} <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
@endif
</div>
@else
@php
$people = $resourceSummary->people;
$budget = $resourceSummary->budget;
$dependencies = $resourceSummary->dependencies;
$capacityPct = $resourceSummary->capacityUtilization();
$budgetPct = $resourceSummary->budgetUtilization();
$totalBudgeted = (float) $resourceSummary->totalBudgeted;
$totalSpent = (float) $resourceSummary->totalSpent;
$activePeopleCount = 0;
foreach ($people as $p) {
$anyAlloc = false;
foreach ($p->allocations as $hrs) { if ((float) $hrs > 0) { $anyAlloc = true; break; } }
if ($anyAlloc || $p->capacity > 0) $activePeopleCount++;
}
$atRiskBudget = 0;
foreach ($budget as $line) {
if ($line->budgeted > 0 && ($line->spent / $line->budgeted) >= 0.9) {
$atRiskBudget++;
}
}
$depConfirmed = 0;
$depTentative = 0;
foreach ($dependencies as $d) {
if ($d->confirmed) $depConfirmed++;
else $depTentative++;
}
$moneyFmt = function ($n) {
$n = (float) $n;
if ($n >= 1000000) return '$'.number_format($n / 1000000, 1).'M';
if ($n >= 1000) return '$'.number_format($n / 1000, $n >= 10000 ? 0 : 1).'K';
return '$'.number_format($n, 0);
};
@endphp
<div class="p3-res-grid">
{{-- People --}}
<div class="p3-rcard @if ($activePeopleCount === 0) empty @endif">
<div class="rhead">
<div class="ricn"><i class="fa fa-users"></i></div>
<div class="rlbl">{{ __('stakeholder.rc.res_people') }}</div>
</div>
<div class="rv">{{ $activePeopleCount }}<small>{{ __('stakeholder.rc.res_people_unit') }}</small></div>
@if ($resourceSummary->totalCapacity > 0)
<div class="rsub">
<strong>{{ (int) $capacityPct }}%</strong> {{ __('stakeholder.rc.res_capacity_used') }}
<span class="muted">· {{ round($resourceSummary->totalAllocated) }} / {{ round($resourceSummary->totalCapacity) }}h {{ __('stakeholder.rc.res_hours_weekly') }}</span>
</div>
<div class="bar ok"><i style="width:{{ min(100, (int) $capacityPct) }}%;"></i></div>
@else
<div class="rsub muted">{{ __('stakeholder.rc.res_no_capacity') }}</div>
@endif
</div>
{{-- Budget --}}
<div class="p3-rcard @if ($totalBudgeted === 0.0) empty @endif">
<div class="rhead">
<div class="ricn"><i class="fa fa-coins"></i></div>
<div class="rlbl">{{ __('stakeholder.rc.res_budget') }}</div>
</div>
@if ($totalBudgeted > 0)
<div class="rv">{{ $moneyFmt($totalSpent) }}<small>/ {{ $moneyFmt($totalBudgeted) }}</small></div>
<div class="rsub">
<strong>{{ (int) $budgetPct }}%</strong> {{ __('stakeholder.rc.res_spent') }}
@if ($atRiskBudget > 0) · <span class="risk">{{ sprintf(__('stakeholder.rc.res_at_risk'), $atRiskBudget) }}</span> @endif
</div>
<div class="bar spend @if ($budgetPct >= 100) over @elseif ($atRiskBudget > 0) at-risk @endif"><i style="width:{{ min(100, (int) $budgetPct) }}%;"></i></div>
@else
<div class="rv"></div>
<div class="rsub muted">{{ __('stakeholder.rc.res_no_budget') }}</div>
@endif
</div>
{{-- Dependencies --}}
<div class="p3-rcard @if (count($dependencies) === 0) empty @endif">
<div class="rhead">
<div class="ricn"><i class="fa fa-handshake"></i></div>
<div class="rlbl">{{ __('stakeholder.rc.res_deps') }}</div>
</div>
@if (count($dependencies) > 0)
<div class="rv">{{ $depConfirmed }}<small>/ {{ count($dependencies) }} {{ __('stakeholder.rc.res_deps_confirmed') }}</small></div>
<div class="rtail">
@if ($depConfirmed > 0)<span class="rp"><span class="dd ok"></span>{{ sprintf(__('stakeholder.rc.res_deps_confirmed_n'), $depConfirmed) }}</span>@endif
@if ($depTentative > 0)<span class="rp"><span class="dd warn"></span>{{ sprintf(__('stakeholder.rc.res_deps_tentative_n'), $depTentative) }}</span>@endif
</div>
@else
<div class="rv"></div>
<div class="rsub muted">{{ __('stakeholder.rc.res_no_deps') }}</div>
@endif
</div>
</div>
@endif
</div>
{{-- ── Per-project breakdown ──────────────────────────────────────────
The "where's it going?" table. At strategy scope with 5+ programs the
aggregate cards above are the 30-second read; this table answers the
first drill-down question: which program is hot, which is idle.
Skipped when there are 0-1 projects (aggregate is the same as the
breakdown, no point). --}}
@if ($resourceSummary !== null && $hasResourceData && count($resourceSummary->projectIds) > 1)
@php
// Build per-project name lookup from ReportEngine summaries.
$projectNames = [];
$projectTypes = [];
foreach (($report['summaries'] ?? []) as $s) {
$projectNames[(int) ($s->id ?? 0)] = (string) ($s->name ?? '');
$projectTypes[(int) ($s->id ?? 0)] = (string) ($s->type ?? 'project');
}
// Aggregate per-project people & budget from ResourceSummary.
$perProject = [];
foreach ($resourceSummary->projectIds as $pid) {
$pid = (int) $pid;
$perProject[$pid] = [
'id' => $pid,
'name' => $projectNames[$pid] ?? ('#'.$pid),
'type' => $projectTypes[$pid] ?? 'project',
'peopleCount' => 0,
'allocatedHrs' => 0.0,
'budgeted' => 0.0,
'spent' => 0.0,
'hasBudget' => false,
'hasPeople' => false,
];
}
foreach ($resourceSummary->people as $person) {
foreach ($person->allocations as $pid => $hrs) {
$pid = (int) $pid;
if (! isset($perProject[$pid])) continue;
if ((float) $hrs > 0) {
$perProject[$pid]['peopleCount']++;
$perProject[$pid]['allocatedHrs'] += (float) $hrs;
$perProject[$pid]['hasPeople'] = true;
}
}
}
foreach ($resourceSummary->budget as $line) {
$pid = (int) $line->projectId;
if (! isset($perProject[$pid])) continue;
$perProject[$pid]['budgeted'] += (float) $line->budgeted;
$perProject[$pid]['spent'] += (float) $line->spent;
if ($line->budgeted > 0) $perProject[$pid]['hasBudget'] = true;
}
// Drop container-only rows (strategy + program IDs that ride along in
// the resource walk but aren't real work projects) so the breakdown
// table only shows leaf projects — same filter idea as the capacity
// analyzer, keeps the two sections consistent.
$perProject = array_filter($perProject, fn ($r) => isset($projectNames[$r['id']]));
// Sort: authored projects first (any people or budget), then by
// budget spend % descending so the hottest surface first.
uasort($perProject, function ($a, $b) {
$aAny = $a['hasPeople'] || $a['hasBudget'];
$bAny = $b['hasPeople'] || $b['hasBudget'];
if ($aAny !== $bAny) return $aAny ? -1 : 1;
$aPct = $a['budgeted'] > 0 ? $a['spent'] / $a['budgeted'] : -1;
$bPct = $b['budgeted'] > 0 ? $b['spent'] / $b['budgeted'] : -1;
if ($aPct !== $bPct) return $bPct <=> $aPct;
return $b['peopleCount'] <=> $a['peopleCount'];
});
// Program-rollup: at strategy scope with a programChildMap, aggregate
// per-project rows into per-program rows. Row rendering uses <details>
// for native expand/collapse — child project rows show up inline.
$useProgramRollup = ($scope ?? '') === 'strategy'
&& ! empty($programChildMap)
&& count($programMeta ?? []) >= 1;
$perProgram = [];
if ($useProgramRollup) {
foreach ($programMeta as $progId => $progInfo) {
$childIds = $programChildMap[$progId] ?? [];
$peopleSet = []; // Union of person ids across children (unique count).
$hrs = 0.0;
$budgeted = 0.0;
$spent = 0.0;
foreach ($resourceSummary->people as $person) {
$touched = false;
foreach ($childIds as $cid) {
$h = (float) ($person->allocations[$cid] ?? 0.0);
if ($h > 0) {
$touched = true;
$hrs += $h;
}
}
if ($touched) $peopleSet[$person->itemId] = 1;
}
foreach ($childIds as $cid) {
$r = $perProject[$cid] ?? null;
if ($r === null) continue;
$budgeted += $r['budgeted'];
$spent += $r['spent'];
}
$children = array_values(array_filter(
array_map(fn ($cid) => $perProject[$cid] ?? null, $childIds),
fn ($r) => $r !== null,
));
usort($children, function ($a, $b) {
$aPct = $a['budgeted'] > 0 ? $a['spent'] / $a['budgeted'] : -1;
$bPct = $b['budgeted'] > 0 ? $b['spent'] / $b['budgeted'] : -1;
return $bPct <=> $aPct;
});
$perProgram[$progId] = [
'id' => $progId,
'name' => $progInfo['name'],
'peopleCount' => count($peopleSet),
'hrs' => $hrs,
'budgeted' => $budgeted,
'spent' => $spent,
'hasBudget' => $budgeted > 0,
'hasPeople' => count($peopleSet) > 0,
'children' => $children,
'childCount' => count($children),
];
}
// Sort programs same way projects are sorted.
uasort($perProgram, function ($a, $b) {
$aAny = $a['hasPeople'] || $a['hasBudget'];
$bAny = $b['hasPeople'] || $b['hasBudget'];
if ($aAny !== $bAny) return $aAny ? -1 : 1;
$aPct = $a['budgeted'] > 0 ? $a['spent'] / $a['budgeted'] : -1;
$bPct = $b['budgeted'] > 0 ? $b['spent'] / $b['budgeted'] : -1;
if ($aPct !== $bPct) return $bPct <=> $aPct;
return $b['peopleCount'] <=> $a['peopleCount'];
});
}
// Helper closure to render one budget cell (used by both program row and
// child project row so the two levels stay visually aligned).
$renderRow = function ($row, $isChild = false) use ($moneyFmt) {
return $row; // (no-op; rendering done inline below to keep Blade access simple)
};
@endphp
<div class="p3-sec">
<div class="p3-sec-hd">
<span class="l">{{ __('stakeholder.rc.bd_label') }}</span>
<span class="s">
@if ($useProgramRollup)
{!! sprintf(__('stakeholder.rc.bd_sub_program'), count($perProgram)) !!}
@else
{!! sprintf(__('stakeholder.rc.bd_sub'), count($perProject)) !!}
@endif
</span>
</div>
<div class="p3-bd">
<div class="p3-bd-row head">
<div class="p3-bd-cell">
@if ($useProgramRollup)
{{ __('stakeholder.rc.bd_col_program') }}
@else
{{ __('stakeholder.rc.bd_col_project') }}
@endif
</div>
<div class="p3-bd-cell">{{ __('stakeholder.rc.bd_col_people') }}</div>
<div class="p3-bd-cell">{{ __('stakeholder.rc.bd_col_budget') }}</div>
<div class="p3-bd-cell">{{ __('stakeholder.rc.bd_col_hours') }}</div>
</div>
@if ($useProgramRollup)
{{-- One row per program with its rolled-up numbers. Click to see
the child projects with their basic budget/people numbers.
The DEEP capacity-vs-demand story stays on the individual
Program report this expand is just the list. --}}
@foreach ($perProgram as $prog)
@php
$pSpendPct = $prog['budgeted'] > 0 ? min(999, ($prog['spent'] / $prog['budgeted']) * 100) : 0;
$pSpendClass = 'ok';
if ($pSpendPct >= 100) $pSpendClass = 'over';
elseif ($pSpendPct >= 90) $pSpendClass = 'at-risk';
@endphp
<details class="p3-bd-program">
<summary class="p3-bd-row program-row">
<div class="p3-bd-cell name-cell">
<div class="expand-chevron"><i class="fa fa-chevron-right"></i></div>
<div>
<div class="name">{{ $prog['name'] }}</div>
<div class="type">{{ __('projectType.program') }} · {{ sprintf(__('stakeholder.rc.bd_n_projects'), $prog['childCount']) }}</div>
</div>
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_people') }}">
@if ($prog['hasPeople'])
<div class="num">{{ $prog['peopleCount'] }}<small>{{ __('stakeholder.rc.res_people_unit') }}</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_budget') }}">
@if ($prog['hasBudget'])
<div class="num">{{ $moneyFmt($prog['spent']) }}<small>/ {{ $moneyFmt($prog['budgeted']) }}</small></div>
<div class="sublabel">{{ (int) $pSpendPct }}% {{ __('stakeholder.rc.res_spent') }}</div>
<div class="minibar spend {{ $pSpendClass }}"><i style="width:{{ min(100, (int) $pSpendPct) }}%;"></i></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_hours') }}">
@if ($prog['hrs'] > 0)
<div class="num"><span data-hours="{{ round($prog['hrs']) }}">{{ round($prog['hrs']) }}h</span><small>/wk</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
</summary>
@foreach ($prog['children'] as $child)
@php
$spendPct = $child['budgeted'] > 0 ? min(999, ($child['spent'] / $child['budgeted']) * 100) : 0;
$spendClass = 'ok';
if ($spendPct >= 100) $spendClass = 'over';
elseif ($spendPct >= 90) $spendClass = 'at-risk';
@endphp
<div class="p3-bd-row child-row">
<div class="p3-bd-cell name-cell">
<div class="child-indent"></div>
<div>
<div class="name">{{ $child['name'] }}</div>
<div class="type">{{ __('projectType.'.$child['type']) }}</div>
</div>
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_people') }}">
@if ($child['hasPeople'])
<div class="num">{{ $child['peopleCount'] }}<small>{{ __('stakeholder.rc.res_people_unit') }}</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_budget') }}">
@if ($child['hasBudget'])
<div class="num">{{ $moneyFmt($child['spent']) }}<small>/ {{ $moneyFmt($child['budgeted']) }}</small></div>
<div class="sublabel">{{ (int) $spendPct }}% {{ __('stakeholder.rc.res_spent') }}</div>
<div class="minibar spend {{ $spendClass }}"><i style="width:{{ min(100, (int) $spendPct) }}%;"></i></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_hours') }}">
@if ($child['allocatedHrs'] > 0)
<div class="num"><span data-hours="{{ round($child['allocatedHrs']) }}">{{ round($child['allocatedHrs']) }}h</span><small>/wk</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
</div>
@endforeach
</details>
@endforeach
@else
{{-- Flat per-project view: program scope, or strategy with no program map. --}}
@foreach ($perProject as $row)
@php
$spendPct = $row['budgeted'] > 0 ? min(999, ($row['spent'] / $row['budgeted']) * 100) : 0;
$spendClass = 'ok';
if ($spendPct >= 100) $spendClass = 'over';
elseif ($spendPct >= 90) $spendClass = 'at-risk';
@endphp
<div class="p3-bd-row">
<div class="p3-bd-cell name-cell">
<div class="name">{{ $row['name'] }}</div>
<div class="type">{{ __('projectType.'.$row['type']) }}</div>
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_people') }}">
@if ($row['hasPeople'])
<div class="num">{{ $row['peopleCount'] }}<small>{{ __('stakeholder.rc.res_people_unit') }}</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_budget') }}">
@if ($row['hasBudget'])
<div class="num">{{ $moneyFmt($row['spent']) }}<small>/ {{ $moneyFmt($row['budgeted']) }}</small></div>
<div class="sublabel">{{ (int) $spendPct }}% {{ __('stakeholder.rc.res_spent') }}</div>
<div class="minibar spend {{ $spendClass }}"><i style="width:{{ min(100, (int) $spendPct) }}%;"></i></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
<div class="p3-bd-cell" data-label="{{ __('stakeholder.rc.bd_col_hours') }}">
@if ($row['allocatedHrs'] > 0)
<div class="num">{{ round($row['allocatedHrs']) }}<small>h/wk</small></div>
@else
<div class="zero">{{ __('stakeholder.rc.bd_none') }}</div>
@endif
</div>
</div>
@endforeach
@endif
</div>
</div>
@endif
{{-- ── Capacity vs. demand per project (with sensitivity + rebalance) ──
The board-level "can this plan actually be delivered by these people in
this time?" answer. For each project, joins three independent estimates:
budgeted hours (planHours), effort (storypoints × conversion), and
capacity (people × weeks × allocation). Shows the sensitivity, not just a
flag. When the plan doesn't fit, lists the three rebalance levers with
specific numbers. --}}
@if (! empty($capacityAnalysis) && $hasResourceData)
@php
// Verdict ordering — worst first so board can scan top-down.
$verdictRank = ['critical' => 0, 'tight' => 1, 'balanced' => 2, 'buffer' => 3, 'no_capacity' => 4, 'no_work' => 5];
$verdictLabels = [
'critical' => __('stakeholder.rc.cap.v_critical'),
'tight' => __('stakeholder.rc.cap.v_tight'),
'balanced' => __('stakeholder.rc.cap.v_balanced'),
'buffer' => __('stakeholder.rc.cap.v_buffer'),
'no_work' => __('stakeholder.rc.cap.v_no_work'),
'no_capacity' => __('stakeholder.rc.cap.v_no_capacity'),
];
// At strategy scope, iterate program rollups (with children); otherwise
// fall back to flat per-project. The rollup is what a board consumes —
// per-project drill-down lives inside the expand.
$capacityRows = ! empty($capacityByProgram) ? $capacityByProgram : $capacityAnalysis;
uasort($capacityRows, fn ($a, $b) => ($verdictRank[$a['verdict']] ?? 9) <=> ($verdictRank[$b['verdict']] ?? 9));
$isRollup = ! empty($capacityByProgram);
@endphp
<div class="p3-sec">
<div class="p3-sec-hd">
<span class="l">{{ __('stakeholder.rc.cap.label') }}</span>
<span class="s">
@if ($isRollup)
{!! sprintf(__('stakeholder.rc.cap.sub_program'), count($capacityRows)) !!}
@else
{{ __('stakeholder.rc.cap.sub') }}
@endif
</span>
</div>
<div class="p3-cap-stack">
@foreach ($capacityRows as $c)
@include('reports::partials.stakeholder.capacity-card', ['c' => $c, 'verdictLabels' => $verdictLabels])
@endforeach
</div>
</div>
@endif
{{-- ── Dependencies ────────────────────────────────────────────────
External commitments the strategy relies on (partnerships, grants,
regulatory approvals). Rendered as its own section because a board packet
needs to see WHO is owning each risky dep and WHEN the decision lands —
not just a "N tentative" chip. Sort: tentative first (by dueDate ascending
so nearest-decision is at top), then confirmed. --}}
@if ($resourceSummary !== null && count($resourceSummary->dependencies) > 0)
@php
$depTotal = count($resourceSummary->dependencies);
$depConfirmedCount = 0;
$depTentativeCount = 0;
foreach ($resourceSummary->dependencies as $d) {
$d->confirmed ? $depConfirmedCount++ : $depTentativeCount++;
}
// Sort: tentative first, ordered by soonest dueDate (nulls last);
// then confirmed (most-recently-modified first).
$depsSorted = $resourceSummary->dependencies;
usort($depsSorted, function ($a, $b) {
if ($a->confirmed !== $b->confirmed) return $a->confirmed ? 1 : -1;
if (! $a->confirmed && ! $b->confirmed) {
if ($a->dueDate === null && $b->dueDate !== null) return 1;
if ($a->dueDate !== null && $b->dueDate === null) return -1;
return ($a->dueDate ?? '') <=> ($b->dueDate ?? '');
}
return ($b->lastModified ?? '') <=> ($a->lastModified ?? '');
});
// Find the most-urgent tentative dep with a dueDate for the risk banner.
$urgent = null;
$today = new \DateTimeImmutable('today');
foreach ($depsSorted as $d) {
if (! $d->confirmed && $d->dueDate !== null) {
$urgent = $d;
break;
}
}
$daysUntil = function (?string $iso) use ($today) {
if ($iso === null || $iso === '') return null;
try {
$d = new \DateTimeImmutable($iso);
return (int) $today->diff($d)->format('%r%a');
} catch (\Exception $e) { return null; }
};
$typeLabels = [
'government' => __('stakeholder.rc.dep.type_government'),
'corporate' => __('stakeholder.rc.dep.type_corporate'),
'nonprofit' => __('stakeholder.rc.dep.type_nonprofit'),
'academic' => __('stakeholder.rc.dep.type_academic'),
'community' => __('stakeholder.rc.dep.type_community'),
'vendor' => __('stakeholder.rc.dep.type_vendor'),
];
$fmtDate = function (?string $iso) {
if ($iso === null) return null;
try {
return (new \DateTimeImmutable($iso))->format('M j, Y');
} catch (\Exception $e) { return $iso; }
};
$fmtAgo = function (?string $iso) use ($today) {
if ($iso === null) return null;
try {
$d = new \DateTimeImmutable(substr($iso, 0, 10));
$days = (int) $today->diff($d)->format('%a');
if ($days === 0) return __('stakeholder.rc.dep.today');
if ($days === 1) return __('stakeholder.rc.dep.yesterday');
if ($days < 7) return sprintf(__('stakeholder.rc.dep.days_ago'), $days);
if ($days < 30) {
$weeks = (int) floor($days / 7);
return sprintf(__($weeks === 1 ? 'stakeholder.rc.dep.week_ago' : 'stakeholder.rc.dep.weeks_ago'), $weeks);
}
$months = (int) floor($days / 30);
return sprintf(__($months === 1 ? 'stakeholder.rc.dep.month_ago' : 'stakeholder.rc.dep.months_ago'), $months);
} catch (\Exception $e) { return null; }
};
@endphp
<div class="p3-sec">
<div class="p3-sec-hd">
<span class="l">{{ __('stakeholder.rc.dep.label') }}</span>
<span class="s">{!! sprintf(__('stakeholder.rc.dep.sub'), $depTotal, $depConfirmedCount, $depTentativeCount) !!}</span>
</div>
@if ($urgent !== null)
@php $urgentDays = $daysUntil($urgent->dueDate); @endphp
<div class="p3-dep-urgent {{ $urgentDays !== null && $urgentDays <= 30 ? 'soon' : '' }}">
<div class="ic"><i class="fa fa-clock"></i></div>
<div class="body">
<div class="hd">
@if ($urgentDays !== null && $urgentDays >= 0)
{{ sprintf(__('stakeholder.rc.dep.nearest_decision_in'), $urgentDays) }}:
@else
{{ __('stakeholder.rc.dep.nearest_decision') }}:
@endif
<strong>{{ $urgent->partnerName }}</strong>
</div>
<div class="meta">
{{ $fmtDate($urgent->dueDate) }}
@if ($urgent->owner) · {{ __('stakeholder.rc.dep.owner') }} <strong>{{ $urgent->owner }}</strong> @endif
</div>
</div>
</div>
@endif
<div class="p3-dep-grid">
@foreach ($depsSorted as $d)
@php
$typeKey = strtolower($d->type);
$typeLbl = $typeLabels[$typeKey] ?? ucfirst($typeKey);
$due = $d->dueDate;
$dueDays = $daysUntil($due);
$ago = $fmtAgo($d->lastModified);
@endphp
<div class="p3-dep {{ $d->confirmed ? 'confirmed' : 'tentative' }}">
<div class="p3-dep-hd">
<span class="status">
@if ($d->confirmed)
<i class="fa fa-circle-check"></i> {{ __('stakeholder.rc.dep.confirmed') }}
@else
<i class="fa fa-clock"></i> {{ __('stakeholder.rc.dep.tentative') }}
@endif
</span>
@if ($typeLbl !== '')
<span class="type-badge type-{{ $typeKey }}">{{ $typeLbl }}</span>
@endif
</div>
<div class="p3-dep-name">{{ $d->partnerName }}</div>
<div class="p3-dep-meta">
@if (! $d->confirmed && $due !== null)
<div class="row {{ $dueDays !== null && $dueDays <= 30 ? 'urgent' : '' }}">
<span class="lbl">{{ __('stakeholder.rc.dep.decision_by') }}</span>
<span class="val">
{{ $fmtDate($due) }}
@if ($dueDays !== null)
<span class="soft">
@if ($dueDays < 0) ({{ sprintf(__('stakeholder.rc.dep.days_overdue'), abs($dueDays)) }})
@elseif ($dueDays === 0) ({{ __('stakeholder.rc.dep.due_today') }})
@else ({{ sprintf(__('stakeholder.rc.dep.in_days'), $dueDays) }})
@endif
</span>
@endif
</span>
</div>
@endif
@if ($d->owner)
<div class="row">
<span class="lbl">{{ __('stakeholder.rc.dep.owner') }}</span>
<span class="val">{{ $d->owner }}</span>
</div>
@endif
</div>
@if ($d->notes)
<div class="p3-dep-notes">{{ $d->notes }}</div>
@endif
@if ($ago !== null)
<div class="p3-dep-foot">{{ sprintf(__('stakeholder.rc.dep.updated'), $ago) }}</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
{{-- ── Resource gaps & risks ────────────────────────────────────────
Observations that aren't per-project capacity math: budget-vs-authorized
imbalances, and (at program scope only) per-person over-allocation and
idle capacity. Individual hours are the wrong altitude for a strategy
report a portfolio audience reads at program/project scope; per-person
analysis belongs on the program report where a manager can act on it.
Capacity vs demand above already covers program-level tightness. --}}
@if ($resourceSummary !== null && $hasResourceData)
@php
$gaps = [];
$isProgramScope = ($scope ?? '') === 'program';
// Capacity vs. demand tightness — at STRATEGY scope, escalate the
// per-program tight/critical verdicts from CapacityAnalyzer into a
// gap row. That's the honest program-level altitude a portfolio
// reader acts on — matches what the Capacity block above says, so
// the two sections no longer read as contradictions. Prefers the
// rollup ($capacityByProgram) so we surface program lines, not per-
// leaf-project ones.
if (! $isProgramScope) {
$tightSource = ! empty($capacityByProgram) ? $capacityByProgram : ($capacityAnalysis ?? []);
foreach ((array) $tightSource as $capRow) {
$verdict = (string) ($capRow['verdict'] ?? '');
if (! in_array($verdict, ['tight', 'critical'], true)) continue;
$gap = (float) ($capRow['gap'] ?? 0);
if ($gap <= 0) continue;
$sev = $verdict === 'critical' ? 'red' : 'yellow';
$gaps[] = [
'sev' => $sev,
'icon' => 'fa-scale-unbalanced',
'headline' => sprintf(
__('stakeholder.rc.gap.program_tight'),
e((string) ($capRow['name'] ?? '')),
round($gap)
),
'detail' => sprintf(
__('stakeholder.rc.gap.program_tight_detail'),
// referenceDemand is what the gap/verdict were computed from (it
// switches to effort hours when planHours coverage is low) — the
// detail must cite the same demand number as the headline.
round((float) ($capRow['referenceDemand'] ?? ($capRow['budgetedHours'] ?? 0))),
round((float) ($capRow['availableHours'] ?? 0))
),
];
}
}
// Per-person over-allocation & idle capacity — PROGRAM SCOPE ONLY.
// On the strategy report these are wrong altitude: individual hour
// math is what a program manager needs, not what a portfolio reader
// needs. Duplicating them at strategy scope also reads as noise next
// to the program-level tightness read above.
if ($isProgramScope) {
$overPeople = [];
foreach ($resourceSummary->people as $p) {
if ($p->capacity > 0 && $p->totalAllocated() > $p->capacity) {
$overPeople[] = [
'name' => $p->displayName,
'over' => round($p->totalAllocated() - $p->capacity, 1),
'planned' => round($p->totalAllocated(), 1),
'capacity' => round($p->capacity, 1),
];
}
}
if (count($overPeople) === 1) {
$g = $overPeople[0];
$gaps[] = [
'sev' => 'red', 'icon' => 'fa-user-clock',
'headline' => sprintf(__('stakeholder.rc.gap.over_alloc_one'), e($g['name']), $g['over']),
'detail' => sprintf(__('stakeholder.rc.gap.over_alloc_one_detail'), $g['planned'], $g['capacity']),
];
} elseif (count($overPeople) > 1) {
$totalOver = array_sum(array_column($overPeople, 'over'));
$names = array_slice(array_column($overPeople, 'name'), 0, 3);
$moreN = max(0, count($overPeople) - 3);
$namesStr = implode(', ', array_map('e', $names)) . ($moreN > 0 ? sprintf(__('stakeholder.rc.gap.and_more'), $moreN) : '');
$gaps[] = [
'sev' => 'red', 'icon' => 'fa-user-clock',
'headline' => sprintf(__('stakeholder.rc.gap.over_alloc_many'), count($overPeople), round($totalOver, 1)),
'detail' => $namesStr,
];
}
// Idle capacity — people with capacity but essentially no allocation.
$idlePeople = [];
foreach ($resourceSummary->people as $p) {
if ($p->capacity > 0 && $p->totalAllocated() / $p->capacity < 0.2) {
$idlePeople[] = $p->displayName;
}
}
if (count($idlePeople) > 0) {
$names = array_slice($idlePeople, 0, 3);
$moreN = max(0, count($idlePeople) - 3);
$namesStr = implode(', ', array_map('e', $names)) . ($moreN > 0 ? sprintf(__('stakeholder.rc.gap.and_more'), $moreN) : '');
$gaps[] = [
'sev' => 'blue', 'icon' => 'fa-user-slash',
'headline' => sprintf(__('stakeholder.rc.gap.idle_capacity'), count($idlePeople)),
'detail' => $namesStr,
];
}
}
// Budget: over-budget and burn-risk per project. Reuses the same per-project
// aggregation as the breakdown table.
$overBudget = [];
$burnRisk = [];
$ghostBudget = []; // money authorized, nobody assigned
$unfundedWork = []; // people assigned, no budget line
$projectNamesForGaps = [];
foreach (($report['summaries'] ?? []) as $s) {
$projectNamesForGaps[(int) ($s->id ?? 0)] = (string) ($s->name ?? '');
}
// Rebuild per-project aggregates (mirror of the breakdown table).
$ppGap = [];
foreach ($resourceSummary->projectIds as $pid) {
$pid = (int) $pid;
$ppGap[$pid] = ['name' => $projectNamesForGaps[$pid] ?? ('#'.$pid), 'people' => 0, 'budgeted' => 0.0, 'spent' => 0.0];
}
foreach ($resourceSummary->people as $person) {
foreach ($person->allocations as $pid => $hrs) {
if ((float) $hrs > 0 && isset($ppGap[(int) $pid])) $ppGap[(int) $pid]['people']++;
}
}
foreach ($resourceSummary->budget as $line) {
if (! isset($ppGap[(int) $line->projectId])) continue;
$ppGap[(int) $line->projectId]['budgeted'] += (float) $line->budgeted;
$ppGap[(int) $line->projectId]['spent'] += (float) $line->spent;
}
foreach ($ppGap as $pid => $row) {
if ($row['budgeted'] > 0) {
$pct = $row['spent'] / $row['budgeted'];
if ($pct >= 1.0) {
$overBudget[] = ['name' => $row['name'], 'over' => $row['spent'] - $row['budgeted'], 'pct' => $pct];
} elseif ($pct >= 0.9) {
$burnRisk[] = ['name' => $row['name'], 'pct' => $pct, 'spent' => $row['spent'], 'budgeted' => $row['budgeted']];
}
if ($row['people'] === 0) {
$ghostBudget[] = ['name' => $row['name'], 'budgeted' => $row['budgeted']];
}
}
if ($row['people'] > 0 && $row['budgeted'] === 0.0) {
$unfundedWork[] = ['name' => $row['name'], 'people' => $row['people']];
}
}
foreach ($overBudget as $g) {
$gaps[] = [
'sev' => 'red', 'icon' => 'fa-dollar-sign',
'headline' => sprintf(__('stakeholder.rc.gap.over_budget'), e($g['name']), $moneyFmt($g['over'])),
'detail' => sprintf(__('stakeholder.rc.gap.over_budget_detail'), (int) ($g['pct'] * 100)),
];
}
foreach ($burnRisk as $g) {
$gaps[] = [
'sev' => 'yellow', 'icon' => 'fa-fire',
'headline' => sprintf(__('stakeholder.rc.gap.burn_risk'), e($g['name']), (int) ($g['pct'] * 100)),
'detail' => sprintf(__('stakeholder.rc.gap.burn_risk_detail'), $moneyFmt($g['spent']), $moneyFmt($g['budgeted'])),
];
}
foreach ($ghostBudget as $g) {
$gaps[] = [
'sev' => 'yellow', 'icon' => 'fa-ghost',
'headline' => sprintf(__('stakeholder.rc.gap.ghost_budget'), e($g['name']), $moneyFmt($g['budgeted'])),
'detail' => __('stakeholder.rc.gap.ghost_budget_detail'),
];
}
foreach ($unfundedWork as $g) {
$gaps[] = [
'sev' => 'blue', 'icon' => 'fa-hand-holding-dollar',
'headline' => sprintf(__('stakeholder.rc.gap.unfunded'), e($g['name']), $g['people']),
'detail' => __('stakeholder.rc.gap.unfunded_detail'),
];
}
// Tentative dependencies moved to their own Dependencies section above
// — surfacing them here would duplicate the same names in two places.
// Sort: red → yellow → blue, in insertion order within a severity.
$sevRank = ['red' => 0, 'yellow' => 1, 'blue' => 2];
usort($gaps, fn ($a, $b) => ($sevRank[$a['sev']] ?? 9) <=> ($sevRank[$b['sev']] ?? 9));
@endphp
<div class="p3-sec">
<div class="p3-sec-hd">
<span class="l">{{ __('stakeholder.rc.gaps_label') }}</span>
<span class="s">{{ __('stakeholder.rc.gaps_sub') }}</span>
</div>
<div class="p3-gaps">
@if (count($gaps) === 0)
<div class="p3-gap ok">
<div class="sev"><i class="fa fa-check"></i></div>
<div class="body">
<div class="headline">{{ __('stakeholder.rc.gap.none_headline') }}</div>
<div class="detail">{{ $isProgramScope ? __('stakeholder.rc.gap.none_detail_program') : __('stakeholder.rc.gap.none_detail_strategy') }}</div>
</div>
</div>
@else
@foreach ($gaps as $g)
<div class="p3-gap {{ $g['sev'] }}">
<div class="sev"><i class="fa {{ $g['icon'] }}"></i></div>
<div class="body">
<div class="headline">{!! $g['headline'] !!}</div>
@if (! empty($g['detail']))
<div class="detail">{!! $g['detail'] !!}</div>
@endif
</div>
</div>
@endforeach
@endif
</div>
</div>
@endif
{{-- Hours/Days toggle behavior. Preference persists via localStorage under
'lt.stakeholderReport.unit'. Every [data-hours] node keeps its
source-of-truth hour value in the attribute; the text is derived from
that on each toggle change (rendered as "Nh" or "Nd", 8h/day). --}}
<script>
(function () {
var LS_KEY = 'lt.stakeholderReport.unit';
var root = document.querySelector('[data-lt-unit-toggle]');
if (! root) return;
function applyUnit(unit) {
document.querySelectorAll('[data-hours]').forEach(function (el) {
var hrs = parseFloat(el.getAttribute('data-hours'));
if (isNaN(hrs)) return;
if (unit === 'days') {
var d = Math.round((hrs / 8) * 10) / 10;
var display = (d === Math.round(d) ? d.toFixed(0) : d.toFixed(1)) + 'd';
el.textContent = display;
} else {
el.textContent = Math.round(hrs) + 'h';
}
});
root.querySelectorAll('.p3-unit-btn').forEach(function (btn) {
btn.classList.toggle('is-active', btn.getAttribute('data-unit') === unit);
});
}
var initial = 'hours';
try { initial = localStorage.getItem(LS_KEY) || 'hours'; } catch (e) {}
if (initial !== 'hours') applyUnit(initial);
root.addEventListener('click', function (e) {
var btn = e.target.closest('.p3-unit-btn');
if (! btn) return;
var unit = btn.getAttribute('data-unit');
try { localStorage.setItem(LS_KEY, unit); } catch (e2) {}
applyUnit(unit);
});
})();
</script>