Files
Leantime/app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php

344 lines
18 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 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>