{{-- 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. --}}
{{ __('stakeholder.rc.unit.show') }}
@endif {{-- ── Resources summary from ResourcesGateway ────────────────────── --}}
{{ __('stakeholder.rc.res_label') }} {{ __('stakeholder.rc.res_sub_live') }}
@if ($resourceSummary === null) {{-- No ResourcesGateway registered (plugin not installed / disabled). --}}
{{ __('stakeholder.rc.no_provider_title') }}
{{ __('stakeholder.rc.no_provider_hint') }}
@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
{{ $emptyTitle }}
{{ $emptyHint }}
@if ($emptyCtaHref !== null) {{ $emptyCtaLabel }} @endif
@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
{{-- People --}}
{{ __('stakeholder.rc.res_people') }}
{{ $activePeopleCount }}{{ __('stakeholder.rc.res_people_unit') }}
@if ($resourceSummary->totalCapacity > 0)
{{ (int) $capacityPct }}% {{ __('stakeholder.rc.res_capacity_used') }} · {{ round($resourceSummary->totalAllocated) }} / {{ round($resourceSummary->totalCapacity) }}h {{ __('stakeholder.rc.res_hours_weekly') }}
@else
{{ __('stakeholder.rc.res_no_capacity') }}
@endif
{{-- Budget --}}
{{ __('stakeholder.rc.res_budget') }}
@if ($totalBudgeted > 0)
{{ $moneyFmt($totalSpent) }}/ {{ $moneyFmt($totalBudgeted) }}
{{ (int) $budgetPct }}% {{ __('stakeholder.rc.res_spent') }} @if ($atRiskBudget > 0) · {{ sprintf(__('stakeholder.rc.res_at_risk'), $atRiskBudget) }} @endif
@else
{{ __('stakeholder.rc.res_no_budget') }}
@endif
{{-- Dependencies --}}
{{ __('stakeholder.rc.res_deps') }}
@if (count($dependencies) > 0)
{{ $depConfirmed }}/ {{ count($dependencies) }} {{ __('stakeholder.rc.res_deps_confirmed') }}
@if ($depConfirmed > 0){{ sprintf(__('stakeholder.rc.res_deps_confirmed_n'), $depConfirmed) }}@endif @if ($depTentative > 0){{ sprintf(__('stakeholder.rc.res_deps_tentative_n'), $depTentative) }}@endif
@else
{{ __('stakeholder.rc.res_no_deps') }}
@endif
@endif
{{-- ── 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
// 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
{{ __('stakeholder.rc.bd_label') }} @if ($useProgramRollup) {!! sprintf(__('stakeholder.rc.bd_sub_program'), count($perProgram)) !!} @else {!! sprintf(__('stakeholder.rc.bd_sub'), count($perProject)) !!} @endif
@if ($useProgramRollup) {{ __('stakeholder.rc.bd_col_program') }} @else {{ __('stakeholder.rc.bd_col_project') }} @endif
{{ __('stakeholder.rc.bd_col_people') }}
{{ __('stakeholder.rc.bd_col_budget') }}
{{ __('stakeholder.rc.bd_col_hours') }}
@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
{{ $prog['name'] }}
{{ __('projectType.program') }} · {{ sprintf(__('stakeholder.rc.bd_n_projects'), $prog['childCount']) }}
@if ($prog['hasPeople'])
{{ $prog['peopleCount'] }}{{ __('stakeholder.rc.res_people_unit') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($prog['hasBudget'])
{{ $moneyFmt($prog['spent']) }}/ {{ $moneyFmt($prog['budgeted']) }}
{{ (int) $pSpendPct }}% {{ __('stakeholder.rc.res_spent') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($prog['hrs'] > 0)
{{ round($prog['hrs']) }}h/wk
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@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
{{ $child['name'] }}
{{ __('projectType.'.$child['type']) }}
@if ($child['hasPeople'])
{{ $child['peopleCount'] }}{{ __('stakeholder.rc.res_people_unit') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($child['hasBudget'])
{{ $moneyFmt($child['spent']) }}/ {{ $moneyFmt($child['budgeted']) }}
{{ (int) $spendPct }}% {{ __('stakeholder.rc.res_spent') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($child['allocatedHrs'] > 0)
{{ round($child['allocatedHrs']) }}h/wk
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@endforeach
@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
{{ $row['name'] }}
{{ __('projectType.'.$row['type']) }}
@if ($row['hasPeople'])
{{ $row['peopleCount'] }}{{ __('stakeholder.rc.res_people_unit') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($row['hasBudget'])
{{ $moneyFmt($row['spent']) }}/ {{ $moneyFmt($row['budgeted']) }}
{{ (int) $spendPct }}% {{ __('stakeholder.rc.res_spent') }}
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@if ($row['allocatedHrs'] > 0)
{{ round($row['allocatedHrs']) }}h/wk
@else
{{ __('stakeholder.rc.bd_none') }}
@endif
@endforeach @endif
@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
{{ __('stakeholder.rc.cap.label') }} @if ($isRollup) {!! sprintf(__('stakeholder.rc.cap.sub_program'), count($capacityRows)) !!} @else {{ __('stakeholder.rc.cap.sub') }} @endif
@foreach ($capacityRows as $c) @include('reports::partials.stakeholder.capacity-card', ['c' => $c, 'verdictLabels' => $verdictLabels]) @endforeach
@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
{{ __('stakeholder.rc.dep.label') }} {!! sprintf(__('stakeholder.rc.dep.sub'), $depTotal, $depConfirmedCount, $depTentativeCount) !!}
@if ($urgent !== null) @php $urgentDays = $daysUntil($urgent->dueDate); @endphp
@if ($urgentDays !== null && $urgentDays >= 0) {{ sprintf(__('stakeholder.rc.dep.nearest_decision_in'), $urgentDays) }}: @else {{ __('stakeholder.rc.dep.nearest_decision') }}: @endif {{ $urgent->partnerName }}
{{ $fmtDate($urgent->dueDate) }} @if ($urgent->owner) · {{ __('stakeholder.rc.dep.owner') }} {{ $urgent->owner }} @endif
@endif
@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
@if ($d->confirmed) {{ __('stakeholder.rc.dep.confirmed') }} @else {{ __('stakeholder.rc.dep.tentative') }} @endif @if ($typeLbl !== '') {{ $typeLbl }} @endif
{{ $d->partnerName }}
@if (! $d->confirmed && $due !== null)
{{ __('stakeholder.rc.dep.decision_by') }} {{ $fmtDate($due) }} @if ($dueDays !== null) @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 @endif
@endif @if ($d->owner)
{{ __('stakeholder.rc.dep.owner') }} {{ $d->owner }}
@endif
@if ($d->notes)
{{ $d->notes }}
@endif @if ($ago !== null)
{{ sprintf(__('stakeholder.rc.dep.updated'), $ago) }}
@endif
@endforeach
@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
{{ __('stakeholder.rc.gaps_label') }} {{ __('stakeholder.rc.gaps_sub') }}
@if (count($gaps) === 0)
{{ __('stakeholder.rc.gap.none_headline') }}
{{ $isProgramScope ? __('stakeholder.rc.gap.none_detail_program') : __('stakeholder.rc.gap.none_detail_strategy') }}
@else @foreach ($gaps as $g)
{!! $g['headline'] !!}
@if (! empty($g['detail']))
{!! $g['detail'] !!}
@endif
@endforeach @endif
@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). --}}