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

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

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
use Leantime\Domain\Reports\Services\ReportEngine;
/**
* Project status report: period-filtered milestones (accomplished / in flight / coming up),
* outcome narratives, goals, effort and status updates for the current project. The sibling
* /reports/show screen keeps the sprint delivery metrics (burndown, cumulative flow).
*/
class Project extends Controller
{
private ReportEngine $reportEngine;
private ProjectService $projectService;
public function init(ReportEngine $reportEngine, ProjectService $projectService): void
{
$this->reportEngine = $reportEngine;
$this->projectService = $projectService;
session(['lastPage' => BASE_URL.'/reports/project']);
}
/**
* Renders the project status report for the requested period (default: this quarter).
*
* @param array<string, mixed> $params
*/
#[RequiresPermission(ReportsPermissions::VIEW)]
public function get(array $params): \Symfony\Component\HttpFoundation\Response
{
// Drill-down links from plan/strategy rollups target a specific project; switch the
// session project when the user has access (mirrors EditMilestone's behavior).
// `id` is the Frontcontroller's path-segment param, so /reports/project/123 works too.
$requestedProjectId = (int) ($params['projectId'] ?? $params['id'] ?? 0);
if (
$requestedProjectId > 0
&& $requestedProjectId !== (int) session('currentProject')
&& $this->projectService->isUserAssignedToProject((int) session('userdata.id'), $requestedProjectId)
) {
$this->projectService->changeCurrentSessionProject($requestedProjectId);
}
$projectId = (int) session('currentProject');
if ($projectId === 0) {
return Frontcontroller::redirect(BASE_URL.'/dashboard/home');
}
$period = ReportPeriod::fromRequest($params);
$report = $this->reportEngine->buildReport([$projectId], $period);
$this->tpl->assign('projectId', $projectId);
$this->tpl->assign('period', $period);
$this->tpl->assign('report', $report);
return $this->tpl->display('reports.project');
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Leantime\Domain\Reports\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
use Leantime\Domain\Reports\Services\Reports as ReportService;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class Show extends Controller
{
private ProjectService $projectService;
private SprintService $sprintService;
private TicketService $ticketService;
private ReportService $reportService;
/**
* @throws BindingResolutionException
*/
public function init(
ProjectService $projectService,
SprintService $sprintService,
TicketService $ticketService,
ReportService $reportService
): void {
// Authorization lives on the action attributes (reports.view, session project) — the
// Frontcontroller enforces them BEFORE the controller is instantiated, so init() (and the
// dailyIngestion() it triggers) never runs for a denied user. Replaces the legacy
// editor+ authOrRedirect (maintainer-approved readonly+ loosening: the page only
// aggregates data readonly members already see item-by-item).
$this->projectService = $projectService;
$this->sprintService = $sprintService;
$this->ticketService = $ticketService;
session(['lastPage' => BASE_URL.'/reports/show']);
$this->reportService = $reportService;
$this->reportService->dailyIngestion();
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(ReportsPermissions::VIEW)]
public function get(array $params): Response
{
$currentProject = (int) session('currentProject');
// Project Progress
$this->tpl->assign('projectProgress', $this->projectService->getProjectProgress($currentProject));
$this->tpl->assign('currentProjectName', $this->projectService->getProjectName($currentProject));
// Sprint Burndown
$requestedSprintId = isset($params['sprint']) ? (int) $params['sprint'] : null;
$allSprints = $this->sprintService->getAllSprints($currentProject);
$sprintBurndown = $this->reportService->getSprintBurndownForReport($currentProject, $requestedSprintId);
$this->tpl->assign('sprintBurndown', $sprintBurndown['chart']);
if ($allSprints !== false && count($allSprints) > 0) {
$this->tpl->assign('currentSprint', $sprintBurndown['currentSprintId']);
}
$this->tpl->assign('backlogBurndown', $this->sprintService->getCummulativeReport($currentProject));
$this->tpl->assign('allSprints', $allSprints);
$this->tpl->assign('fullReport', $this->reportService->getFullReport($currentProject));
$this->tpl->assign('fullReportLatest', $this->reportService->getRealtimeReport($currentProject, ''));
$this->tpl->assign('states', $this->ticketService->getStatusLabels());
// Milestones. getAllMilestones no longer computes percentDone in the query, so backfill each
// milestone's completion the same way the Roadmap/timeline does — otherwise the report shows
// every milestone at 0% (#3624).
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $currentProject]);
$allProjectMilestones = $this->ticketService->getBulkMilestoneProgress($allProjectMilestones);
$this->tpl->assign('milestones', $allProjectMilestones);
return $this->tpl->display('reports.show');
}
#[RequiresPermission(ReportsPermissions::VIEW)]
public function post($params): Response
{
return Frontcontroller::redirect(BASE_URL.'/dashboard/show');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
/**
* Inline outcome & impact capture on the report screens: saves the narrative onto the
* milestone and re-renders the outcome block in place.
*/
class Outcome extends HtmxController
{
protected static string $view = 'reports::partials.outcome';
private TicketService $ticketService;
public function init(TicketService $ticketService): void
{
$this->ticketService = $ticketService;
}
/**
* Persists the outcome narrative. patchTicket() authorizes editor+ against the
* milestone's own project, so a smuggled milestone id can't cross projects.
*/
public function save(): void
{
$milestoneId = (int) ($_POST['milestoneId'] ?? 0);
$outcomeImpact = trim((string) ($_POST['outcomeImpact'] ?? ''));
$this->ticketService->patchTicket($milestoneId, ['outcomeImpact' => $outcomeImpact]);
$milestone = $this->ticketService->getTicket($milestoneId);
$this->tpl->assign('milestone', $milestone);
$this->tpl->assign('canEdit', true);
$this->tpl->setNotification($this->language->__('notifications.outcome_saved'), 'success');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Reports\Services\ReportEngine;
/**
* Re-renders the project report body when the period filter changes (the page shell with
* header and period picker stays in place).
*/
class ProjectReport extends HtmxController
{
protected static string $view = 'reports::partials.projectReportBody';
private ReportEngine $reportEngine;
public function init(ReportEngine $reportEngine): void
{
$this->reportEngine = $reportEngine;
}
/**
* Builds the report body for the requested period. Authorization happens in the engine
* (reports.view per project) — an unauthorized project yields an empty report.
*/
public function get(): void
{
$projectId = (int) session('currentProject');
$period = ReportPeriod::fromRequest($this->incomingRequest->query->all());
$this->tpl->assign('projectId', $projectId);
$this->tpl->assign('period', $period);
$this->tpl->assign('report', $this->reportEngine->buildReport([$projectId], $period));
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Models;
use Carbon\CarbonImmutable;
/**
* Value object describing the reporting timeframe of a report screen.
*
* All boundaries are held in UTC (db timezone). Quarter presets are resolved against the
* user's timezone so "this quarter" matches the user's calendar, then converted to UTC.
*/
final class ReportPeriod
{
public const PRESET_LAST_QUARTER = 'lastQuarter';
public const PRESET_THIS_QUARTER = 'thisQuarter';
public const PRESET_NEXT_QUARTER = 'nextQuarter';
public const PRESET_CUSTOM = 'custom';
private function __construct(
public readonly CarbonImmutable $from,
public readonly CarbonImmutable $to,
public readonly string $preset,
) {}
/**
* Builds a period from request parameters.
*
* Accepts either a `preset` (lastQuarter|thisQuarter|nextQuarter) or a custom range via
* `from`/`to` in the user's date format and timezone. Falls back to the current quarter
* when nothing (or nothing parseable) was provided.
*
* @param array<string, mixed> $params Request parameters
*/
public static function fromRequest(array $params): self
{
$preset = (string) ($params['preset'] ?? '');
if ($preset === self::PRESET_LAST_QUARTER) {
return self::lastQuarter();
}
if ($preset === self::PRESET_NEXT_QUARTER) {
return self::nextQuarter();
}
if ($preset === self::PRESET_CUSTOM || (! empty($params['from']) && ! empty($params['to']))) {
try {
$from = dtHelper()->parseUserDateTime((string) $params['from'], 'start')->setToDbTimezone();
$to = dtHelper()->parseUserDateTime((string) $params['to'], 'end')->setToDbTimezone();
if ($from->lessThanOrEqualTo($to)) {
return new self($from, $to, self::PRESET_CUSTOM);
}
} catch (\Exception $e) {
// Unparseable custom range: fall through to the default preset.
}
}
return self::thisQuarter();
}
/**
* The current quarter in the user's calendar.
*/
public static function thisQuarter(): self
{
$now = dtHelper()->userNow();
return new self(
$now->startOfQuarter()->setToDbTimezone(),
$now->endOfQuarter()->setToDbTimezone(),
self::PRESET_THIS_QUARTER,
);
}
/**
* The previous full quarter in the user's calendar.
*/
public static function lastQuarter(): self
{
$anchor = dtHelper()->userNow()->subQuarterNoOverflow();
return new self(
$anchor->startOfQuarter()->setToDbTimezone(),
$anchor->endOfQuarter()->setToDbTimezone(),
self::PRESET_LAST_QUARTER,
);
}
/**
* The next quarter in the user's calendar.
*/
public static function nextQuarter(): self
{
$anchor = dtHelper()->userNow()->addQuarterNoOverflow();
return new self(
$anchor->startOfQuarter()->setToDbTimezone(),
$anchor->endOfQuarter()->setToDbTimezone(),
self::PRESET_NEXT_QUARTER,
);
}
/**
* The equivalent preceding period, used for period-over-period deltas. For quarter presets
* this is the previous quarter; for custom ranges the same number of days directly before.
*/
public function priorPeriod(): self
{
if ($this->preset !== self::PRESET_CUSTOM) {
$anchor = $this->from->setToUserTimezone()->subQuarterNoOverflow();
return new self(
$anchor->startOfQuarter()->setToDbTimezone(),
$anchor->endOfQuarter()->setToDbTimezone(),
self::PRESET_CUSTOM,
);
}
$lengthInDays = (int) $this->from->diffInDays($this->to) + 1;
return new self(
$this->from->subDays($lengthInDays),
$this->to->subDays($lengthInDays),
self::PRESET_CUSTOM,
);
}
/**
* End of the "coming up" horizon: two full quarters beyond the period end.
*/
public function upcomingHorizon(): CarbonImmutable
{
return $this->to->setToUserTimezone()->addQuartersNoOverflow(2)->endOfQuarter()->setToDbTimezone();
}
/**
* Whether the given UTC datetime falls inside the period.
*/
public function contains(CarbonImmutable $dateTime): bool
{
return $dateTime->betweenIncluded($this->from, $this->to);
}
/**
* Human readable label, e.g. "Q2 2026 · Apr 1 Jun 30, 2026".
*/
public function label(): string
{
$userFrom = $this->from->setToUserTimezone();
$userTo = $this->to->setToUserTimezone();
$range = $userFrom->formatDateForUser().' '.$userTo->formatDateForUser();
// A range spanning exactly one calendar quarter gets the quarter shorthand prefix.
if (
$userFrom->equalTo($userFrom->startOfQuarter())
&& $userTo->equalTo($userTo->endOfQuarter())
&& $userFrom->isSameQuarter($userTo)
) {
return 'Q'.$userFrom->quarter.' '.$userFrom->year.' · '.$range;
}
return $range;
}
/**
* Query string carrying the period across filter swaps and drill-down links.
*/
public function toQueryString(): string
{
if ($this->preset !== self::PRESET_CUSTOM) {
return http_build_query(['preset' => $this->preset]);
}
return http_build_query([
'preset' => self::PRESET_CUSTOM,
'from' => $this->from->setToUserTimezone()->formatDateForUser(),
'to' => $this->to->setToUserTimezone()->formatDateForUser(),
]);
}
/**
* Period start formatted for db comparisons (UTC, Y-m-d H:i:s).
*/
public function fromDbString(): string
{
return $this->from->format('Y-m-d H:i:s');
}
/**
* Period end formatted for db comparisons (UTC, Y-m-d H:i:s).
*/
public function toDbString(): string
{
return $this->to->format('Y-m-d H:i:s');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Leantime\Domain\Reports\Models;
class Reports
{
public $sprintId;
public $projectId;
public $date;
public $sum_todos;
public $sum_open_todos;
public $sum_progres_todos;
public $sum_closed_todos;
public $sum_planned_hours;
public $sum_estremaining_hours;
public $sum_logged_hours;
public $sum_points;
public $sum_points_done;
public $sum_points_progress;
public $sum_points_open;
public $sum_todos_xs;
public $sum_todos_s;
public $sum_todos_m;
public $sum_todos_l;
public $sum_todos_xl;
public $sum_todos_xxl;
public $sum_todos_none;
public $tickets;
public $daily_avg_hours_booked_todo;
public $daily_avg_hours_booked_point;
public $daily_avg_hours_planned_todo;
public $daily_avg_hours_planned_point;
public $daily_avg_hours_remaining_point;
public $daily_avg_hours_remaining_todo;
public $sum_teammembers;
public function __construct() {}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Leantime\Domain\Reports\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Reports permission vocabulary — a single project-scoped standard verb.
*
* Project reports (burndown charts, cumulative flow, ticket-status history) only aggregate data
* a project member can already read item-by-item (tickets, sprints, milestones are all
* readonly-visible), so the standard `view` verb is the sole capability and auto-grants to
* readonly+ through the matrix in {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions}
* with NO matrix edit. (Maintainer-approved loosening: the legacy /reports/show page gate was
* editor+, which guarded nothing the lower roles couldn't already see.)
*
* The domain's system-level methods (cron ingestion and telemetry) are deliberately NOT part of
* this vocabulary: they run with no session user and are not RPC-exposed (de-@api'd), so there is
* nothing to grant.
*/
final class ReportsPermissions implements ProvidesPermissions
{
/** View a project's reports (burndown, cumulative flow, status history). Readonly+. */
public const VIEW = 'reports.view';
public function domain(): string
{
return 'reports';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View project reports', true),
];
}
}

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
/**
* Bulk, session-free queries backing the report engine.
*
* Every method takes an explicit set of project ids plus the requesting user's context
* (user id, client id) so the engine can serve project, plan, strategy and — later —
* company-level reports without depending on session('currentProject'). The access
* predicate mirrors Tickets\Repositories\Tickets::getAllMilestones: assigned via
* zp_relationuserproject, open to all, open to the user's client, or requestor role >= 40.
*/
class ReportEngine
{
private ConnectionInterface $connection;
private DatabaseHelper $dbHelper;
public function __construct(DbCore $db, DatabaseHelper $dbHelper)
{
$this->connection = $db->getConnection();
$this->dbHelper = $dbHelper;
}
/**
* All milestones of the given projects the requesting user may see. No date filtering —
* the service buckets rows into completed/in-progress/overdue/upcoming.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getMilestonesForProjects(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_tickets')
->select([
'zp_tickets.id',
'zp_tickets.headline',
'zp_tickets.description',
'zp_tickets.outcomeImpact',
'zp_tickets.date',
'zp_tickets.projectId',
'zp_tickets.status',
'zp_tickets.editFrom',
'zp_tickets.editTo',
'zp_tickets.modified',
'zp_projects.name as projectName',
])
->selectRaw("'milestone' AS ".$this->dbHelper->wrapColumn('type'))
->selectRaw("CASE WHEN (zp_tickets.tags IS NULL OR zp_tickets.tags = '') THEN 'var(--grey)' ELSE zp_tickets.tags END AS tags")
->leftJoin('zp_projects', 'zp_tickets.projectId', '=', 'zp_projects.id')
->where('zp_tickets.type', '=', 'milestone')
->whereIn('zp_tickets.projectId', $projectIds)
->orderBy('zp_tickets.editTo');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* Status-change history rows for the given tickets, oldest first. The service derives
* completion dates from the latest transition into a DONE-type status.
*
* @param int[] $ticketIds
* @return array<int, object> Rows with ticketId, changeValue (new status id), dateModified
*/
public function getStatusHistoryForTickets(array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return $this->connection->table('zp_tickethistory')
->select(['ticketId', 'changeValue', 'dateModified'])
->whereIn('ticketId', $ticketIds)
->where('changeType', '=', 'status')
->orderBy('dateModified')
->get()
->all();
}
/**
* Due-date change history ("toDate") for the given tickets within a window, oldest first.
* Feeds the commitment-integrity view (milestones pushed out of the period).
*
* @param int[] $ticketIds
* @return array<int, object> Rows with ticketId, changeValue (new due date), dateModified
*/
public function getDueDateChangesForTickets(array $ticketIds, string $fromDb, string $toDb): array
{
if ($ticketIds === []) {
return [];
}
return $this->connection->table('zp_tickethistory')
->select(['ticketId', 'changeValue', 'dateModified'])
->whereIn('ticketId', $ticketIds)
->where('changeType', '=', 'toDate')
->whereBetween('dateModified', [$fromDb, $toDb])
->orderBy('dateModified')
->get()
->all();
}
/**
* Non-milestone tickets belonging to the given milestones (for key-task lists and
* milestone progress computation).
*
* @param int[] $milestoneIds
* @return array<int, object>
*/
public function getTasksForMilestones(array $milestoneIds): array
{
if ($milestoneIds === []) {
return [];
}
return $this->connection->table('zp_tickets')
->select([
'id',
'headline',
'status',
'projectId',
'milestoneid',
'storypoints',
'priority',
'editTo',
'dateToFinish',
])
->whereIn('milestoneid', $milestoneIds)
->where('type', '<>', 'milestone')
->orderBy('milestoneid')
->get()
->all();
}
/**
* Project status updates (comments on module "project" carrying a green/yellow/red status)
* within the given window, newest first.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getStatusUpdatesForProjects(array $projectIds, string $fromDb, string $toDb, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_comment')
->select([
'zp_comment.id',
'zp_comment.moduleId as projectId',
'zp_comment.text',
'zp_comment.date',
'zp_comment.status',
'zp_user.firstname as authorFirstname',
'zp_user.lastname as authorLastname',
'zp_user.profileId as authorProfileId',
])
->leftJoin('zp_user', 'zp_comment.userId', '=', 'zp_user.id')
->leftJoin('zp_projects', 'zp_comment.moduleId', '=', 'zp_projects.id')
->where('zp_comment.module', '=', 'project')
->whereIn('zp_comment.moduleId', $projectIds)
->whereBetween('zp_comment.date', [$fromDb, $toDb])
->orderByDesc('zp_comment.date');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* The most recent status update per project regardless of period (feeds the status pill).
*
* @param int[] $projectIds
* @return array<int, object> Keyed by projectId
*/
public function getLatestStatusUpdateForProjects(array $projectIds): array
{
if ($projectIds === []) {
return [];
}
$latest = $this->connection->table('zp_comment')
->selectRaw('moduleId, MAX(date) as maxDate')
->where('module', '=', 'project')
->whereIn('moduleId', $projectIds)
->groupBy('moduleId');
$rows = $this->connection->table('zp_comment')
->select([
'zp_comment.moduleId as projectId',
'zp_comment.text',
'zp_comment.date',
'zp_comment.status',
'zp_user.firstname as authorFirstname',
'zp_user.lastname as authorLastname',
])
->joinSub($latest, 'latest', function ($join) {
$join->on('zp_comment.moduleId', '=', 'latest.moduleId')
->on('zp_comment.date', '=', 'latest.maxDate');
})
->leftJoin('zp_user', 'zp_comment.userId', '=', 'zp_user.id')
->where('zp_comment.module', '=', 'project')
->get()
->all();
$byProject = [];
foreach ($rows as $row) {
$byProject[(int) $row->projectId] = $row;
}
return $byProject;
}
/**
* Goals (goal-board canvas items) of the given projects.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getGoalsForProjects(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.title',
'zp_canvas_items.description',
'zp_canvas_items.status',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.setting',
'zp_canvas_items.kpi',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.canvasId',
'zp_canvas.projectId',
'zp_canvas.title as boardTitle',
])
->join('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->leftJoin('zp_projects', 'zp_canvas.projectId', '=', 'zp_projects.id')
->where('zp_canvas.type', '=', 'goalcanvas')
->where('zp_canvas_items.box', '=', 'goal')
->whereIn('zp_canvas.projectId', $projectIds)
->orderBy('zp_canvas.projectId')
->orderBy('zp_canvas_items.sortindex');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* Hours logged against the given projects within a window, grouped by project and
* milestone (tasks roll up to their milestone; unassigned work groups under 0).
*
* @param int[] $projectIds
* @return array<int, object> Rows with projectId, milestoneId, loggedHours
*/
public function getHoursLoggedForProjects(array $projectIds, string $fromDb, string $toDb): array
{
if ($projectIds === []) {
return [];
}
return $this->connection->table('zp_timesheets')
->selectRaw('zp_tickets.projectId AS '.$this->dbHelper->wrapColumn('projectId'))
->selectRaw('COALESCE(zp_tickets.milestoneid, 0) AS '.$this->dbHelper->wrapColumn('milestoneId'))
->selectRaw('SUM(zp_timesheets.hours) AS '.$this->dbHelper->wrapColumn('loggedHours'))
->join('zp_tickets', 'zp_timesheets.ticketId', '=', 'zp_tickets.id')
->whereIn('zp_tickets.projectId', $projectIds)
->whereBetween('zp_timesheets.workDate', [$fromDb, $toDb])
->groupBy('zp_tickets.projectId')
->groupByRaw('COALESCE(zp_tickets.milestoneid, 0)')
->get()
->all();
}
/**
* Core metadata of the given projects the requesting user may see.
*
* @param int[] $projectIds
* @return array<int, object> Keyed by project id
*/
public function getProjectsMeta(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_projects')
->select([
'zp_projects.id',
'zp_projects.name',
'zp_projects.details',
'zp_projects.clientId',
'zp_projects.state',
'zp_projects.start',
'zp_projects.end',
'zp_projects.type',
'zp_projects.parent',
'zp_clients.name as clientName',
])
->leftJoin('zp_clients', 'zp_projects.clientId', '=', 'zp_clients.id')
->whereIn('zp_projects.id', $projectIds);
$this->applyAccessPredicate($query, $userId, $clientId);
$byId = [];
foreach ($query->get()->all() as $row) {
$byId[(int) $row->id] = $row;
}
return $byId;
}
/**
* Applies the standard project access predicate: project assigned to the user, open to
* everyone, open to the user's client, or the requesting user is admin/owner (role >= 40).
* The query must already join zp_projects.
*/
private function applyAccessPredicate(Builder $query, int $userId, int $clientId): void
{
$query->leftJoin('zp_user as requestor', function ($join) use ($userId) {
$join->on('requestor.id', '=', $this->connection->raw((string) $userId));
});
$query->where(function ($q) use ($userId, $clientId) {
$q->whereIn('zp_projects.id', function ($subquery) use ($userId) {
$subquery->select('projectId')
->from('zp_relationuserproject')
->where('zp_relationuserproject.userId', $userId);
})
->orWhere('zp_projects.psettings', 'all')
->orWhere(function ($q2) use ($clientId) {
$q2->where('zp_projects.psettings', 'clients')
->where('zp_projects.clientId', $clientId);
})
->orWhere('requestor.role', '>=', 40);
});
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Leantime\Domain\Reports\Repositories;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Reports\Models\Reports as ReportsModel;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
class Reports
{
private ConnectionInterface $db;
private DatabaseHelper $dbHelper;
/**
* __construct - get database connection
*/
public function __construct(DbCore $db, DatabaseHelper $dbHelper)
{
$this->db = $db->getConnection();
$this->dbHelper = $dbHelper;
}
/**
* Run ticket report for a project and optionally a sprint
*
* @throws BindingResolutionException
*/
public function runTicketReport(int $projectId, string $sprintId): array|bool
{
$ticketRepo = app()->make(TicketRepository::class);
$statusGroupsSQL = $ticketRepo->getStatusListGroupedByType($projectId);
// Parse status groups from SQL format to arrays for cross-database compatibility
$statusGroups = $this->dbHelper->parseStatusGroups($statusGroupsSQL);
// Build cross-database compatible date expression
$yesterdayDate = $this->dbHelper->yesterdayDate();
// Build cross-database compatible string aggregation
$ticketIds = $this->dbHelper->stringAggregate('zp_tickets.id');
// Build query with query builder
$query = $this->db->table('zp_tickets');
// Select sprint column or -1 based on whether sprintId is provided
if ($sprintId !== '') {
$query->selectRaw('sprint AS '.$this->dbHelper->wrapColumn('sprintId'));
} else {
$query->selectRaw('-1 AS '.$this->dbHelper->wrapColumn('sprintId'));
}
// Build the select statement with cross-database functions
$query->selectRaw($this->dbHelper->wrapColumn('projectId'))
->selectRaw("{$yesterdayDate} AS date")
->selectRaw('COUNT(DISTINCT zp_tickets.id) AS sum_todos')
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['NEW'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_open_todos'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['INPROGRESS'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_progres_todos'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['DONE'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_closed_todos'
)
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') AS sum_planned_hours')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') AS sum_estremaining_hours')
->selectRaw('SUM(zp_tickets.storypoints) AS sum_points')
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['NEW'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_open'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['INPROGRESS'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_progress'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['DONE'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_done'
)
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 1 THEN 1 ELSE 0 END) AS sum_todos_xs')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 2 THEN 1 ELSE 0 END) AS sum_todos_s')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 3 THEN 1 ELSE 0 END) AS sum_todos_m')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 5 THEN 1 ELSE 0 END) AS sum_todos_l')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 8 THEN 1 ELSE 0 END) AS sum_todos_xl')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 13 THEN 1 ELSE 0 END) AS sum_todos_xxl')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints IS NULL OR zp_tickets.storypoints = 0 THEN 1 ELSE 0 END) AS sum_todos_none')
->selectRaw("{$ticketIds} AS tickets")
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') / COUNT(zp_tickets.id) AS daily_avg_hours_planned_todo')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') / NULLIF(SUM(zp_tickets.storypoints), 0) AS daily_avg_hours_planned_point')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') / COUNT(zp_tickets.id) AS daily_avg_hours_remaining_todo')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') / NULLIF(SUM(zp_tickets.storypoints), 0) AS daily_avg_hours_remaining_point')
->where('projectId', $projectId)
->where('zp_tickets.type', '<>', 'subtask')
->where('zp_tickets.type', '<>', 'milestone');
// Add sprint filter if provided
if ($sprintId !== '') {
$query->where('sprint', $sprintId)
->groupBy('projectId', 'sprint');
} else {
$query->groupBy('projectId');
}
$result = $query->first();
$valuesTickets = $result ? (array) $result : false;
$storyPoints = isset($valuesTickets['sum_points']) && $valuesTickets['sum_points'] > 0
? $valuesTickets['sum_points']
: 1;
// Timesheet Reports using query builder
$timesheetQuery = $this->db->table('zp_tickets')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) AS DECIMAL(10,2)), 2) AS sum_logged_hours')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) / NULLIF(COUNT(DISTINCT zp_tickets.id), 0) AS DECIMAL(10,2)), 2) AS daily_avg_hours_booked_todo')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) / ? AS DECIMAL(10,2)), 2) AS daily_avg_hours_booked_point', [$storyPoints])
->leftJoin('zp_timesheets', 'zp_tickets.id', '=', 'zp_timesheets.ticketId')
->where('projectId', $projectId)
->where('zp_tickets.type', '<>', 'subtask')
->where('zp_tickets.type', '<>', 'milestone');
if ($sprintId !== '') {
$timesheetQuery->where('sprint', $sprintId)
->groupBy('projectId', 'sprint');
} else {
$timesheetQuery->groupBy('projectId');
}
$timesheetResult = $timesheetQuery->first();
$valueTimesheets = $timesheetResult ? (array) $timesheetResult : false;
// Number of users
$projectService = app()->make(ProjectRepository::class);
$users = $projectService->getUsersAssignedToProject($projectId);
$numberOfUsers = is_array($users) ? count($users) : 0;
if (is_array($valuesTickets) && is_array($valueTimesheets)) {
$values = array_merge($valuesTickets, $valueTimesheets);
$values['sum_teammembers'] = $numberOfUsers;
} else {
$values = false;
}
return $values;
}
public function checkLastReportEntries(int $projectId): false|array
{
$results = $this->db->table('zp_stats')
->whereRaw($this->dbHelper->isYesterday('date'))
->where('projectId', $projectId)
->limit(2)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function addReport(array|object $report): void
{
$report = (object) $report;
$this->db->table('zp_stats')->insert([
'sprintId' => $report->sprintId,
'projectId' => $report->projectId,
'date' => $report->date,
'sum_todos' => $report->sum_todos,
'sum_open_todos' => $report->sum_open_todos,
'sum_progres_todos' => $report->sum_progres_todos,
'sum_closed_todos' => $report->sum_closed_todos,
'sum_planned_hours' => $report->sum_planned_hours,
'sum_estremaining_hours' => $report->sum_estremaining_hours,
'sum_logged_hours' => $report->sum_logged_hours,
'sum_points' => $report->sum_points,
'sum_points_done' => $report->sum_points_done,
'sum_points_progress' => $report->sum_points_progress,
'sum_points_open' => $report->sum_points_open,
'sum_todos_xs' => $report->sum_todos_xs,
'sum_todos_s' => $report->sum_todos_s,
'sum_todos_m' => $report->sum_todos_m,
'sum_todos_l' => $report->sum_todos_l,
'sum_todos_xl' => $report->sum_todos_xl,
'sum_todos_xxl' => $report->sum_todos_xxl,
'sum_todos_none' => $report->sum_todos_none,
'tickets' => $report->tickets,
'daily_avg_hours_booked_todo' => $report->daily_avg_hours_booked_todo,
'daily_avg_hours_booked_point' => $report->daily_avg_hours_booked_point,
'daily_avg_hours_planned_todo' => $report->daily_avg_hours_planned_todo,
'daily_avg_hours_planned_point' => $report->daily_avg_hours_planned_point,
'daily_avg_hours_remaining_point' => $report->daily_avg_hours_remaining_point,
'daily_avg_hours_remaining_todo' => $report->daily_avg_hours_remaining_todo,
'sum_teammembers' => $report->sum_teammembers,
]);
}
/**
* @return array
*/
public function getSprintReport(int $sprint): array|false
{
$results = $this->db->table('zp_stats')
->where('sprintId', $sprint)
->orderBy('date', 'asc')
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function getBacklogReport(int $project): false|array
{
$results = $this->db->table('zp_stats')
->where('projectId', $project)
->where('sprintId', 0)
->orderBy('date', 'asc')
->limit(95)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function getFullReport(int $project): false|array
{
$results = $this->db->table('zp_stats')
->select(
'date',
$this->db->raw('SUM(sum_todos) AS sum_todos'),
$this->db->raw('SUM(sum_open_todos) AS sum_open_todos'),
$this->db->raw('SUM(sum_progres_todos) AS sum_progres_todos'),
$this->db->raw('SUM(sum_closed_todos) AS sum_closed_todos'),
$this->db->raw('SUM(sum_planned_hours) AS sum_planned_hours'),
$this->db->raw('SUM(sum_estremaining_hours) AS sum_estremaining_hours'),
$this->db->raw('ROUND(CAST(SUM(sum_logged_hours) AS DECIMAL(10,2)), 2) AS sum_logged_hours'),
$this->db->raw('SUM(sum_points) AS sum_points'),
$this->db->raw('SUM(sum_points_done) AS sum_points_done'),
$this->db->raw('SUM(sum_points_progress) AS sum_points_progress'),
$this->db->raw('SUM(sum_points_open) AS sum_points_open'),
$this->db->raw('SUM(sum_todos_xs) AS sum_todos_xs'),
$this->db->raw('SUM(sum_todos_s) AS sum_todos_s'),
$this->db->raw('SUM(sum_todos_m) AS sum_todos_m'),
$this->db->raw('SUM(sum_todos_l) AS sum_todos_l'),
$this->db->raw('SUM(sum_todos_xl) AS sum_todos_xl'),
$this->db->raw('SUM(sum_todos_xxl) AS sum_todos_xxl'),
$this->db->raw('SUM(sum_todos_none) AS sum_todos_none'),
$this->db->raw('SUM('.$this->dbHelper->castAs('tickets', 'integer').') AS tickets'),
$this->db->raw('SUM(daily_avg_hours_booked_todo) AS daily_avg_hours_booked_todo'),
$this->db->raw('SUM(daily_avg_hours_booked_point) AS daily_avg_hours_booked_point'),
$this->db->raw('SUM(daily_avg_hours_planned_todo) AS daily_avg_hours_planned_todo'),
$this->db->raw('SUM(daily_avg_hours_planned_point) AS daily_avg_hours_planned_point'),
$this->db->raw('SUM(daily_avg_hours_remaining_point) AS daily_avg_hours_remaining_point'),
$this->db->raw('SUM(daily_avg_hours_remaining_todo) AS daily_avg_hours_remaining_todo')
)
->where('projectId', $project)
->where(function ($query) {
$query->where('sprintId', '<', 1)
->orWhereNull('sprintId');
})
->groupBy('date')
->orderBy('date', 'desc')
->limit(120)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
}

View File

@@ -0,0 +1,483 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Services;
use Leantime\Core\Resources\Models\ResourceSummary;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepo;
/**
* Three-way capacity analysis for the stakeholder report's "Resource gaps &
* risks" section.
*
* For each project in a plan, joins three independent estimates of the work
* ahead and compares them against the capacity ResourceSummary already
* exposes:
*
* 1. Budgeted hours — sum(planHours) on open tickets. Explicit but often blank.
* 2. Effort hours — sum(storypoints) × hoursPerPoint on open tickets. Always
* populated when sizing is used; a t-shirt/fibonacci proxy.
* 3. Available hours — sum(person allocation to this project) × weeks in the
* project's active window (or the report period as fallback).
*
* The output tells the board *how much* things are off, not just *that* they
* are — sensitivity, not a flag. Recommendation triples ("extend by X weeks",
* "add Y people", "cut Z points of scope") land alongside so the discussion
* has levers, not just a colored dot.
*
* Trust signal: when budgeted and effort disagree materially (or coverage is
* low), the analyzer says so. A resourcing decision shouldn't hinge on
* numbers the team hasn't kept clean.
*/
final class CapacityAnalyzer
{
/**
* Rough hours per fibonacci story point. A half-day-per-point default:
* Effort labels are t-shirt sizes (XS=1, S=2, M=3, L=5, XL=8, XXL=13);
* treating a point as ~4h puts M (3pts) at 12h — about a day and a
* half — which matches how most Leantime teams size in practice.
*/
public const DEFAULT_HOURS_PER_POINT = 4.0;
/**
* Trust bands for coverage (share of tickets that have planHours filled).
*/
private const COVERAGE_HIGH = 0.75;
private const COVERAGE_LOW = 0.30;
/**
* Divergence threshold: when budgeted and effort disagree by more than
* this ratio, we call the estimate unreliable.
*/
private const DIVERGENCE_THRESHOLD = 0.4;
public function __construct(
private readonly TicketsRepo $ticketsRepo,
) {}
/**
* Analyzes capacity for each project in the plan.
*
* @param int[] $projectIds
* @param array<int, string> $projectNames projectId => display name (from ReportEngine summaries)
* @return array<int, array{
* projectId: int, name: string,
* openTicketCount: int, coverage: float, budgetedHours: float,
* effortPoints: float, effortHours: float, hoursPerPoint: float,
* divergence: float, trustSignal: string,
* peopleCount: int, weeklyHoursToProject: float,
* weeksInWindow: int, availableHours: float,
* gapVsBudgeted: float, gapVsEffort: float, gap: float,
* verdict: string,
* recommendations: array{extendWeeks: int, addPeople: int, cutHours: float, cutPoints: float}
* }>
*/
public function analyzeProjects(
array $projectIds,
ReportPeriod $period,
ResourceSummary $resources,
array $projectNames = [],
float $hoursPerPoint = self::DEFAULT_HOURS_PER_POINT,
): array {
$out = [];
$weeksInPeriod = $this->weeksBetween($period->from, $period->to);
// Resolve the set of projects we'll actually analyze first (respecting
// the reportable-only skip below), then pull every project's tickets in
// ONE query instead of one round-trip per project.
$idsToAnalyze = [];
foreach ($projectIds as $pid) {
$pid = (int) $pid;
// Skip projects the report doesn't consider "reportable" — those
// that fell out of the resource walk but aren't in ReportEngine's
// summaries (typically program containers themselves, not real
// work projects). Only analyze what the report tracks.
if (! empty($projectNames) && ! isset($projectNames[$pid])) {
continue;
}
$idsToAnalyze[] = $pid;
}
$ticketsByProject = $this->ticketsRepo->getAllByProjectIds($idsToAnalyze);
foreach ($idsToAnalyze as $pid) {
$rawTickets = $ticketsByProject[$pid] ?? [];
// Normalize — the repo hydrates rows into Tickets model objects; we
// want plain array rows for uniform key access downstream.
$tickets = array_map(static fn ($t) => (array) $t, $rawTickets);
// Filter to open tickets only — DONE work doesn't need capacity. DONE is
// resolved per project from the status labels (custom statuses can mark
// any id as done), not from the default 0/-1 convention.
$doneStatuses = $this->doneStatusesForProject($pid);
$openTickets = array_values(array_filter($tickets, fn ($t) => ! $this->isDone($t, $doneStatuses)));
[$budgetedHours, $ticketsWithBudget] = $this->sumBudgetedHours($openTickets);
[$effortPoints, $ticketsWithEffort] = $this->sumEffort($openTickets);
$effortHours = $effortPoints * $hoursPerPoint;
$openCount = count($openTickets);
$coverage = $openCount > 0 ? $ticketsWithBudget / $openCount : 0.0;
// Divergence between the two estimates. Only meaningful when both are non-trivial.
$divergence = ($budgetedHours > 0 && $effortHours > 0)
? abs($budgetedHours - $effortHours) / max($budgetedHours, $effortHours)
: 0.0;
$trustSignal = $this->trustSignal($coverage, $divergence, $budgetedHours, $effortHours);
// Capacity side — from ResourceSummary. Sum weekly allocation to THIS project only.
$peopleCount = 0;
$weeklyHoursToProject = 0.0;
foreach ($resources->people as $person) {
$hrs = (float) ($person->allocations[$pid] ?? 0.0);
if ($hrs > 0) {
$peopleCount++;
$weeklyHoursToProject += $hrs;
}
}
$availableHours = $weeklyHoursToProject * $weeksInPeriod;
// The "reference demand" for a gap: prefer whichever estimate we trust more.
// When trust is 'effort' (coverage is low), use the effort projection;
// when it's 'budgeted', use the explicit hours; when mixed, use the
// higher of the two (conservative — surface risk, don't hide it).
$referenceDemand = match ($trustSignal) {
'budgeted' => $budgetedHours,
'effort' => $effortHours,
default => max($budgetedHours, $effortHours),
};
$gap = $referenceDemand - $availableHours;
$verdict = $this->verdict($gap, $availableHours, $referenceDemand);
$recommendations = $gap > 0 && $weeklyHoursToProject > 0
? $this->recommend($gap, $weeklyHoursToProject, $peopleCount, $weeksInPeriod, $hoursPerPoint)
: ['extendWeeks' => 0, 'addPeople' => 0, 'cutHours' => 0.0, 'cutPoints' => 0.0];
// Skip noise rows: projects walked by the resource gateway but with
// nothing meaningful to say (typically program containers themselves,
// which have no direct tickets or allocations). Reporting these as
// "no capacity / no work" is just clutter.
if ($openCount === 0 && $peopleCount === 0 && $budgetedHours === 0.0 && $effortHours === 0.0) {
continue;
}
$out[$pid] = [
'projectId' => $pid,
'name' => $projectNames[$pid] ?? ('#'.$pid),
'openTicketCount' => $openCount,
'coverage' => $coverage,
'budgetedHours' => $budgetedHours,
'ticketsWithBudget' => $ticketsWithBudget,
'effortPoints' => $effortPoints,
'ticketsWithEffort' => $ticketsWithEffort,
'effortHours' => $effortHours,
'hoursPerPoint' => $hoursPerPoint,
'divergence' => $divergence,
'trustSignal' => $trustSignal,
'peopleCount' => $peopleCount,
'weeklyHoursToProject' => $weeklyHoursToProject,
'weeksInWindow' => $weeksInPeriod,
'availableHours' => $availableHours,
'referenceDemand' => $referenceDemand,
'gapVsBudgeted' => $budgetedHours - $availableHours,
'gapVsEffort' => $effortHours - $availableHours,
'gap' => $gap,
'verdict' => $verdict,
'recommendations' => $recommendations,
];
}
return $out;
}
/**
* Rolls per-project capacity rows into per-program aggregates for the
* strategy report. Every derived field (coverage, divergence, verdict,
* recommendations) is recomputed against the aggregate — a program that
* has three tight projects reads as tight overall, not "critical because
* one child was critical".
*
* @param array<int, array<string, mixed>> $projectRows Output of analyzeProjects()
* @param array<int, int[]> $programChildMap programId => [childProjectIds]
* @param array<int, array{id:int, name:string}> $programMeta
* @return array<int, array<string, mixed>> One row per program with an added 'children' key
*/
public function aggregateByProgram(
array $projectRows,
array $programChildMap,
array $programMeta,
\Leantime\Core\Resources\Models\ResourceSummary $resources,
ReportPeriod $period,
float $hoursPerPoint = self::DEFAULT_HOURS_PER_POINT,
): array {
$out = [];
$weeksInPeriod = $this->weeksBetween($period->from, $period->to);
foreach ($programMeta as $progId => $progInfo) {
$childIds = $programChildMap[$progId] ?? [];
if ($childIds === []) {
continue;
}
$children = array_values(array_filter(
array_map(fn ($cid) => $projectRows[$cid] ?? null, $childIds),
fn ($r) => $r !== null,
));
$openTicketCount = 0;
$ticketsWithBudget = 0;
$ticketsWithEffort = 0;
$budgetedHours = 0.0;
$effortPoints = 0.0;
foreach ($children as $c) {
$openTicketCount += (int) $c['openTicketCount'];
$ticketsWithBudget += (int) $c['ticketsWithBudget'];
$ticketsWithEffort += (int) $c['ticketsWithEffort'];
$budgetedHours += (float) $c['budgetedHours'];
$effortPoints += (float) $c['effortPoints'];
}
$effortHours = $effortPoints * $hoursPerPoint;
$coverage = $openTicketCount > 0 ? $ticketsWithBudget / $openTicketCount : 0.0;
$divergence = ($budgetedHours > 0 && $effortHours > 0)
? abs($budgetedHours - $effortHours) / max($budgetedHours, $effortHours)
: 0.0;
$trustSignal = $this->trustSignal($coverage, $divergence, $budgetedHours, $effortHours);
// Capacity aggregation: unique people across the program (a person
// on two child projects still counts once).
//
// Supply is each person's CAPACITY, not the hours already
// allocated here. Allocation answers "what have we committed",
// capacity answers "what could we actually do" — and only the
// second one can say whether there is room. Summing allocations
// made the two halves of the report disagree: the headline tile
// reads allocated/capacity while this block read demand against
// allocated, so a program with real headroom and nothing booked
// yet reported no_capacity.
//
// A person's capacity is not dedicated to this program, so
// commitments to projects OUTSIDE it are deducted. Only projects
// inside this ResourceSummary are visible, so for a strategy
// report that means siblings within the same strategy; work on
// other strategies is not visible here and this therefore reads
// as an upper bound.
$childIdSet = array_flip(array_map('intval', $childIds));
$peopleSet = [];
$weeklyCapacityToProgram = 0.0;
foreach ($resources->people as $person) {
$touched = false;
$committedElsewhere = 0.0;
foreach ($person->allocations as $allocPid => $allocHrs) {
$allocHrs = (float) $allocHrs;
if ($allocHrs <= 0) {
continue;
}
if (isset($childIdSet[(int) $allocPid])) {
$touched = true;
} else {
$committedElsewhere += $allocHrs;
}
}
if ($touched) {
$peopleSet[$person->itemId] = 1;
$weeklyCapacityToProgram += max(0.0, $person->capacity - $committedElsewhere);
}
}
$peopleCount = count($peopleSet);
$availableHours = $weeklyCapacityToProgram * $weeksInPeriod;
// recommend() reasons in weekly hours; keep its input consistent
// with the supply figure the verdict was derived from.
$weeklyHoursToProgram = $weeklyCapacityToProgram;
$referenceDemand = match ($trustSignal) {
'budgeted' => $budgetedHours,
'effort' => $effortHours,
default => max($budgetedHours, $effortHours),
};
$gap = $referenceDemand - $availableHours;
$verdict = $this->verdict($gap, $availableHours, $referenceDemand);
$recommendations = $gap > 0 && $weeklyHoursToProgram > 0
? $this->recommend($gap, $weeklyHoursToProgram, $peopleCount, $weeksInPeriod, $hoursPerPoint)
: ['extendWeeks' => 0, 'addPeople' => 0, 'cutHours' => 0.0, 'cutPoints' => 0.0];
// Order child project rows worst-first for the expand view.
$verdictRank = ['critical' => 0, 'tight' => 1, 'balanced' => 2, 'buffer' => 3, 'no_capacity' => 4, 'no_work' => 5];
usort($children, fn ($a, $b) => ($verdictRank[$a['verdict']] ?? 9) <=> ($verdictRank[$b['verdict']] ?? 9));
$out[$progId] = [
'projectId' => $progId,
'name' => $progInfo['name'],
'isProgram' => true,
'childCount' => count($children),
'children' => $children,
'openTicketCount' => $openTicketCount,
'coverage' => $coverage,
'budgetedHours' => $budgetedHours,
'ticketsWithBudget' => $ticketsWithBudget,
'effortPoints' => $effortPoints,
'ticketsWithEffort' => $ticketsWithEffort,
'effortHours' => $effortHours,
'hoursPerPoint' => $hoursPerPoint,
'divergence' => $divergence,
'trustSignal' => $trustSignal,
'peopleCount' => $peopleCount,
'weeklyHoursToProject' => $weeklyHoursToProgram,
'weeksInWindow' => $weeksInPeriod,
'availableHours' => $availableHours,
'referenceDemand' => $referenceDemand,
'gapVsBudgeted' => $budgetedHours - $availableHours,
'gapVsEffort' => $effortHours - $availableHours,
'gap' => $gap,
'verdict' => $verdict,
'recommendations' => $recommendations,
];
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $tickets
* @return array{0: float, 1: int} [totalHours, ticketsWithNonZeroPlanHours]
*/
private function sumBudgetedHours(array $tickets): array
{
$sum = 0.0;
$withBudget = 0;
foreach ($tickets as $t) {
$ph = (float) ($t['planHours'] ?? 0);
if ($ph > 0) {
$sum += $ph;
$withBudget++;
}
}
return [$sum, $withBudget];
}
/**
* @param array<int, array<string, mixed>> $tickets
* @return array{0: float, 1: int} [totalPoints, ticketsWithNonZeroPoints]
*/
private function sumEffort(array $tickets): array
{
$sum = 0.0;
$withEffort = 0;
foreach ($tickets as $t) {
$sp = (float) ($t['storypoints'] ?? 0);
if ($sp > 0) {
$sum += $sp;
$withEffort++;
}
}
return [$sum, $withEffort];
}
/**
* @param int[] $doneStatuses Status ids whose statusType is DONE in the ticket's project
*/
private function isDone(array $t, array $doneStatuses): bool
{
return in_array((int) ($t['status'] ?? 0), $doneStatuses, true);
}
/**
* DONE-type status ids for a project from its (cached) status label settings —
* covers custom statuses with positive ids, plus the default 0/-1 pair.
*
* @return int[]
*/
private function doneStatusesForProject(int $projectId): array
{
$doneStatuses = [];
foreach ($this->ticketsRepo->getStateLabels($projectId) as $statusId => $label) {
if (($label['statusType'] ?? '') === 'DONE') {
$doneStatuses[] = (int) $statusId;
}
}
return $doneStatuses;
}
private function weeksBetween(\DateTimeInterface $from, \DateTimeInterface $to): int
{
$seconds = $to->getTimestamp() - $from->getTimestamp();
return max(1, (int) ceil($seconds / (60 * 60 * 24 * 7)));
}
/**
* 'budgeted' → coverage is high AND estimates agree. Trust the explicit hours.
* 'effort' → coverage is low. Fall back on story-point projection.
* 'mixed' → coverage okay but estimates disagree meaningfully. Flag the ambiguity.
*/
private function trustSignal(float $coverage, float $divergence, float $budgeted, float $effort): string
{
if ($budgeted === 0.0 && $effort === 0.0) {
return 'none';
}
if ($coverage < self::COVERAGE_LOW) {
return $effort > 0 ? 'effort' : 'budgeted';
}
if ($coverage >= self::COVERAGE_HIGH && $divergence < self::DIVERGENCE_THRESHOLD) {
return 'budgeted';
}
return 'mixed';
}
private function verdict(float $gap, float $available, float $demand): string
{
if ($demand === 0.0) {
return 'no_work';
}
if ($available === 0.0) {
return 'no_capacity';
}
$ratio = $gap / $available;
if ($ratio > 0.25) {
return 'critical';
}
if ($ratio > 0) {
return 'tight';
}
if ($ratio < -0.5) {
return 'buffer';
}
return 'balanced';
}
/**
* Compute the three levers: extend timeline / add people / cut scope.
*
* @return array{extendWeeks: int, addPeople: int, cutHours: float, cutPoints: float}
*/
private function recommend(
float $gapHours,
float $weeklyHoursToProject,
int $peopleCount,
int $weeksInWindow,
float $hoursPerPoint,
): array {
$avgWeeklyPerPerson = $peopleCount > 0 ? $weeklyHoursToProject / $peopleCount : 20.0;
return [
'extendWeeks' => (int) ceil($gapHours / max(1.0, $weeklyHoursToProject)),
'addPeople' => (int) ceil($gapHours / max(1.0, $weeksInWindow * $avgWeeklyPerPerson)),
'cutHours' => $gapHours,
'cutPoints' => $hoursPerPoint > 0 ? $gapHours / $hoursPerPoint : 0.0,
];
}
}

View File

@@ -0,0 +1,737 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Services;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reports\Models\ReportPeriod;
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
use Leantime\Domain\Reports\Repositories\ReportEngine as ReportEngineRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
/**
* Shared, period-aware reporting engine feeding the project, plan and strategy report screens.
*
* All methods operate on explicit project-id sets so higher levels (plan = child projects,
* strategy = descendant projects, later company = all projects) compose the same building
* blocks. Ids are filtered to projects the requesting user may view before any query runs,
* and the repository re-applies the SQL access predicate — defense in depth.
*/
class ReportEngine extends BaseService
{
/**
* A project with no status update for this many days counts as silent/stale.
*/
private const STALE_AFTER_DAYS = 30;
public function __construct(
private ReportEngineRepository $reportEngineRepository,
private TicketRepository $ticketRepository,
private ProjectService $projectService,
private GoalcanvasService $goalService,
) {}
/**
* Builds the full report view model for a set of projects in one pass: needs-attention,
* milestone buckets, slippage, goals, status updates, effort, prior-period deltas and
* summary stats. This is the single entry point the report screens consume.
*
* @param int[] $projectIds
* @return array<string, mixed>
*
* @api
*/
public function buildReport(array $projectIds, ReportPeriod $period): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
$summaries = $this->getProjectSummaries($projectIds);
$milestoneReport = $this->getMilestoneReportForProjects($projectIds, $period);
$goalReport = $this->getGoalReportForProjects($projectIds);
$statusUpdates = $this->getStatusUpdatesForProjects($projectIds, $period);
$effort = $this->getEffortForProjects($projectIds, $period);
$priorPeriod = $period->priorPeriod();
$priorEffort = $this->getEffortForProjects($projectIds, $priorPeriod);
$completedPrior = count(array_filter(
$milestoneReport['allDone'],
fn (object $milestone) => $milestone->completedOn !== null
&& $priorPeriod->contains($milestone->completedOn)
));
$completedCount = count($milestoneReport['completed']);
$deltas = [
'completedPrior' => $completedPrior,
'completedDelta' => $completedCount - $completedPrior,
'hoursPrior' => $priorEffort['total'],
'hoursDelta' => round($effort['total'] - $priorEffort['total'], 2),
'priorPeriodLabel' => $priorPeriod->label(),
];
$needsAttention = $this->buildNeedsAttention($summaries, $milestoneReport, $goalReport);
return [
'period' => $period,
'projectIds' => $projectIds,
'summaries' => $summaries,
'milestones' => $milestoneReport,
'goals' => $goalReport,
'statusUpdates' => $statusUpdates,
'effort' => $effort,
'deltas' => $deltas,
'needsAttention' => $needsAttention,
'stats' => [
'completed' => $completedCount,
// "In flight" = everything actively being worked, overdue included — matches
// the report screens' In-flight sections, which merge both buckets.
'inFlight' => count($milestoneReport['inProgress']) + count($milestoneReport['overdue']),
'overdue' => count($milestoneReport['overdue']),
'upcoming' => count($milestoneReport['upcoming']),
'goalsOnTrack' => $goalReport['counts']['ontrack'],
'goalsTotal' => count($goalReport['goals']),
'hoursLogged' => $effort['total'],
],
];
}
/**
* Buckets the projects' milestones for the period.
*
* - completed: DONE status and completed inside the period (completion date derived from
* the ticket status-change history, falling back to due date, then last modified)
* - inProgress: not done, scheduled to overlap the period (or unscheduled), not overdue
* - overdue: not done and due date in the past
* - upcoming: starting after the period, within the next two quarters, grouped by quarter
* - allDone: every done milestone with its completion date (feeds prior-period deltas)
* - slippage: due dates pushed out of the period + milestones added mid-period
*
* @param int[] $projectIds
* @return array<string, mixed>
*
* @api
*/
public function getMilestoneReportForProjects(array $projectIds, ReportPeriod $period): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
[$userId, $clientId] = $this->requestContext();
$milestones = $this->reportEngineRepository->getMilestonesForProjects($projectIds, $userId, $clientId);
$doneStatusesByProject = $this->getDoneStatusesByProject($projectIds);
$tasksByMilestone = $this->groupTasksByMilestone(
$this->reportEngineRepository->getTasksForMilestones(array_map(fn ($m) => (int) $m->id, $milestones))
);
$now = CarbonImmutable::now('UTC');
$horizon = $period->upcomingHorizon();
$completed = [];
$inProgress = [];
$overdue = [];
$upcoming = [];
$allDone = [];
$doneMilestoneIds = [];
foreach ($milestones as $milestone) {
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
$doneMilestoneIds[] = (int) $milestone->id;
}
}
$completionDates = $this->deriveCompletionDates($doneMilestoneIds, $milestones, $doneStatusesByProject);
foreach ($milestones as $milestone) {
$milestoneId = (int) $milestone->id;
$milestone->startDate = $this->parseDbDateOrNull($milestone->editFrom);
$milestone->dueDate = $this->parseDbDateOrNull($milestone->editTo);
$milestone->tags = $this->sanitizeCssColor((string) $milestone->tags);
$milestone->taskStats = $this->buildTaskStats(
$tasksByMilestone[$milestoneId] ?? [],
$doneStatusesByProject[(int) $milestone->projectId] ?? []
);
$milestone->keyTasks = $this->pickKeyTasks(
$tasksByMilestone[$milestoneId] ?? [],
$doneStatusesByProject[(int) $milestone->projectId] ?? []
);
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
$milestone->completedOn = $completionDates[$milestoneId] ?? null;
$milestone->percentDone = 100.0;
$allDone[] = $milestone;
if ($milestone->completedOn !== null && $period->contains($milestone->completedOn)) {
$completed[] = $milestone;
}
continue;
}
$milestone->completedOn = null;
$milestone->percentDone = $this->calculateMilestoneProgress(
$tasksByMilestone[$milestoneId] ?? [],
$doneStatusesByProject[(int) $milestone->projectId] ?? []
);
if ($milestone->dueDate !== null && $milestone->dueDate->lessThan($now)) {
$overdue[] = $milestone;
continue;
}
if ($milestone->startDate !== null && $milestone->startDate->greaterThan($period->to)) {
if ($milestone->startDate->lessThanOrEqualTo($horizon)) {
$milestone->quarterLabel = $this->quarterLabel($milestone->startDate);
$upcoming[] = $milestone;
}
continue;
}
// Overlapping the period or entirely unscheduled: active work.
$inProgress[] = $milestone;
}
usort($completed, fn ($a, $b) => ($b->completedOn?->getTimestamp() ?? 0) <=> ($a->completedOn?->getTimestamp() ?? 0));
usort($overdue, fn ($a, $b) => ($a->dueDate?->getTimestamp() ?? 0) <=> ($b->dueDate?->getTimestamp() ?? 0));
usort($upcoming, fn ($a, $b) => ($a->startDate?->getTimestamp() ?? 0) <=> ($b->startDate?->getTimestamp() ?? 0));
$slippage = $this->buildSlippage($milestones, $doneStatusesByProject, $period);
return [
'completed' => $completed,
'inProgress' => $inProgress,
'overdue' => $overdue,
'upcoming' => $upcoming,
'upcomingByQuarter' => $this->groupByQuarter($upcoming),
'allDone' => $allDone,
'slippage' => $slippage,
];
}
/**
* Goals of the given projects with metric progress and roll-up values resolved.
*
* @param int[] $projectIds
* @return array{goals: array<int, object>, byProject: array<int, array<int, object>>, counts: array<string, int>}
*
* @api
*/
public function getGoalReportForProjects(array $projectIds): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
[$userId, $clientId] = $this->requestContext();
$goals = $this->reportEngineRepository->getGoalsForProjects($projectIds, $userId, $clientId);
$byProject = [];
$counts = ['ontrack' => 0, 'atrisk' => 0, 'miss' => 0];
// Milestone name(s) come from the tracked_by edge graph (replacing the
// removed stale milestoneId-column join). Hydrate them for the whole
// goal set in ONE batched pass up front to avoid an N+1 across large
// project sets.
$milestonesByGoal = $this->goalService->getMilestonesForGoals(
array_map(static fn ($goal) => (int) $goal->id, $goals)
);
foreach ($goals as $goal) {
if ($goal->setting === 'linkAndReport') {
$goal->currentValue = $this->goalService->getChildGoalsForReporting((int) $goal->id);
}
$goal->goalProgress = $this->calculateGoalProgress($goal);
$goalMilestones = $milestonesByGoal[(int) $goal->id] ?? [];
$goal->milestoneHeadline = implode(', ', array_map(static fn ($m) => (string) $m['headline'], $goalMilestones));
$statusKey = str_replace('status_', '', (string) $goal->status);
if (array_key_exists($statusKey, $counts)) {
$counts[$statusKey]++;
}
$byProject[(int) $goal->projectId][] = $goal;
}
return [
'goals' => $goals,
'byProject' => $byProject,
'counts' => $counts,
];
}
/**
* Status updates posted within the period, grouped by project, newest first.
*
* @param int[] $projectIds
* @param int $limitPerProject 0 = no limit
* @return array<int, array<int, object>> projectId => updates
*
* @api
*/
public function getStatusUpdatesForProjects(array $projectIds, ReportPeriod $period, int $limitPerProject = 0): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
[$userId, $clientId] = $this->requestContext();
$updates = $this->reportEngineRepository->getStatusUpdatesForProjects(
$projectIds,
$period->fromDbString(),
$period->toDbString(),
$userId,
$clientId
);
$byProject = [];
foreach ($updates as $update) {
$projectId = (int) $update->projectId;
if ($limitPerProject > 0 && count($byProject[$projectId] ?? []) >= $limitPerProject) {
continue;
}
$update->dateParsed = $this->parseDbDateOrNull($update->date);
$byProject[$projectId][] = $update;
}
return $byProject;
}
/**
* Summary header data per project: name, one-line description, progress, latest status
* update (the status pill), staleness and timeline.
*
* @param int[] $projectIds
* @return array<int, object> Keyed by project id
*
* @api
*/
public function getProjectSummaries(array $projectIds): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
[$userId, $clientId] = $this->requestContext();
$meta = $this->reportEngineRepository->getProjectsMeta($projectIds, $userId, $clientId);
$latestUpdates = $this->reportEngineRepository->getLatestStatusUpdateForProjects(array_keys($meta));
$now = CarbonImmutable::now('UTC');
foreach ($meta as $projectId => $project) {
$project->descriptionExcerpt = $this->excerpt((string) ($project->details ?? ''));
$project->progress = Cache::remember(
'reportengine.progress.'.$projectId,
600,
fn () => $this->projectService->getProjectProgress($projectId)
);
$latest = $latestUpdates[$projectId] ?? null;
$project->latestStatus = $latest?->status ?: null;
$project->latestStatusDate = $latest !== null ? $this->parseDbDateOrNull($latest->date) : null;
$project->latestStatusText = $latest !== null ? $this->excerpt((string) $latest->text) : null;
$project->isStale = $project->latestStatusDate === null
|| $project->latestStatusDate->lessThan($now->subDays(self::STALE_AFTER_DAYS));
}
return $meta;
}
/**
* Hours logged in the period, totaled and broken down by project and milestone.
*
* @param int[] $projectIds
* @return array{total: float, byProject: array<int, float>, byMilestone: array<int, float>}
*
* @api
*/
public function getEffortForProjects(array $projectIds, ReportPeriod $period): array
{
$projectIds = $this->filterAuthorizedProjects($projectIds);
$rows = $this->reportEngineRepository->getHoursLoggedForProjects(
$projectIds,
$period->fromDbString(),
$period->toDbString()
);
$total = 0.0;
$byProject = [];
$byMilestone = [];
foreach ($rows as $row) {
$hours = (float) $row->loggedHours;
$total += $hours;
$byProject[(int) $row->projectId] = ($byProject[(int) $row->projectId] ?? 0.0) + $hours;
if ((int) $row->milestoneId > 0) {
$byMilestone[(int) $row->milestoneId] = ($byMilestone[(int) $row->milestoneId] ?? 0.0) + $hours;
}
}
return [
'total' => round($total, 2),
'byProject' => $byProject,
'byMilestone' => $byMilestone,
];
}
/**
* Restricts a set of project ids to those the requesting user may view reports for.
*
* @param int[] $projectIds
* @return int[]
*/
private function filterAuthorizedProjects(array $projectIds): array
{
$projectIds = array_values(array_unique(array_map('intval', $projectIds)));
return array_values(array_filter(
$projectIds,
fn (int $projectId) => $projectId > 0 && $this->can(ReportsPermissions::VIEW, $projectId)
));
}
/**
* The requesting user's id and client id, used by the repository access predicate. Reports
* always run in a request context (web, HTMX or authenticated API — all populate session
* userdata); cron/system paths must not call the engine.
*
* @return array{0: int, 1: int}
*/
private function requestContext(): array
{
return [
(int) (session('userdata.id') ?? -1),
(int) (session('userdata.clientId') ?? -1),
];
}
/**
* DONE-type status ids per project, from the (cached) project status label settings.
*
* @param int[] $projectIds
* @return array<int, int[]>
*/
private function getDoneStatusesByProject(array $projectIds): array
{
$map = [];
foreach ($projectIds as $projectId) {
$doneStatuses = [];
foreach ($this->ticketRepository->getStateLabels($projectId) as $statusId => $label) {
if (($label['statusType'] ?? '') === 'DONE') {
$doneStatuses[] = (int) $statusId;
}
}
$map[$projectId] = $doneStatuses;
}
return $map;
}
private function hasDoneStatus(object $milestone, array $doneStatusesByProject): bool
{
return in_array((int) $milestone->status, $doneStatusesByProject[(int) $milestone->projectId] ?? [], true);
}
/**
* Completion dates for done milestones: the latest status-history transition into a
* DONE-type status; for rows predating history coverage the due date, then last modified.
*
* @param int[] $doneMilestoneIds
* @param array<int, object> $milestones
* @return array<int, ?CarbonImmutable> milestoneId => completion date
*/
private function deriveCompletionDates(array $doneMilestoneIds, array $milestones, array $doneStatusesByProject): array
{
$milestonesById = [];
foreach ($milestones as $milestone) {
$milestonesById[(int) $milestone->id] = $milestone;
}
$latestDoneTransition = [];
foreach ($this->reportEngineRepository->getStatusHistoryForTickets($doneMilestoneIds) as $row) {
$ticketId = (int) $row->ticketId;
$milestone = $milestonesById[$ticketId] ?? null;
if ($milestone === null) {
continue;
}
$doneStatuses = $doneStatusesByProject[(int) $milestone->projectId] ?? [];
if (in_array((int) $row->changeValue, $doneStatuses, true)) {
// Rows arrive oldest-first, so the last hit per ticket wins.
$latestDoneTransition[$ticketId] = $row->dateModified;
}
}
$completionDates = [];
foreach ($doneMilestoneIds as $milestoneId) {
$milestone = $milestonesById[$milestoneId];
$completionDates[$milestoneId] = $this->parseDbDateOrNull($latestDoneTransition[$milestoneId] ?? null)
?? $this->parseDbDateOrNull($milestone->editTo)
?? $this->parseDbDateOrNull($milestone->modified);
}
return $completionDates;
}
/**
* Commitment integrity: milestones whose due date was pushed past the period end during
* the period, and milestones created mid-period.
*
* @param array<int, object> $milestones
* @return array{pushedOut: array<int, object>, addedMidPeriod: array<int, object>}
*/
private function buildSlippage(array $milestones, array $doneStatusesByProject, ReportPeriod $period): array
{
$candidates = [];
foreach ($milestones as $milestone) {
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
continue;
}
$dueDate = $milestone->dueDate ?? $this->parseDbDateOrNull($milestone->editTo);
if ($dueDate !== null && $dueDate->greaterThan($period->to)) {
$candidates[(int) $milestone->id] = $milestone;
}
}
$changes = $this->reportEngineRepository->getDueDateChangesForTickets(
array_keys($candidates),
$period->fromDbString(),
$period->toDbString()
);
$moveCounts = [];
foreach ($changes as $change) {
$moveCounts[(int) $change->ticketId] = ($moveCounts[(int) $change->ticketId] ?? 0) + 1;
}
$pushedOut = [];
foreach ($moveCounts as $milestoneId => $moves) {
$milestone = $candidates[$milestoneId];
$milestone->dueDateMoves = $moves;
$pushedOut[] = $milestone;
}
$addedMidPeriod = [];
foreach ($milestones as $milestone) {
$created = $this->parseDbDateOrNull($milestone->date);
if ($created !== null && $created->greaterThan($period->from) && $created->lessThanOrEqualTo($period->to)) {
$addedMidPeriod[] = $milestone;
}
}
return [
'pushedOut' => $pushedOut,
'addedMidPeriod' => $addedMidPeriod,
];
}
/**
* Attention items across the project set: red/yellow projects, silent projects, overdue
* milestones and at-risk goals — the "what needs my intervention" block.
*
* @param array<int, object> $summaries
* @return array<string, array>
*/
private function buildNeedsAttention(array $summaries, array $milestoneReport, array $goalReport): array
{
$statusAlerts = [];
$staleProjects = [];
foreach ($summaries as $project) {
if ((int) $project->state === -1) {
continue; // Closed projects don't need status nudging.
}
if (in_array($project->latestStatus, ['red', 'yellow'], true)) {
$statusAlerts[] = $project;
}
if ($project->isStale) {
$staleProjects[] = $project;
}
}
$goalsAtRisk = array_values(array_filter(
$goalReport['goals'],
fn (object $goal) => in_array($goal->status, ['status_atrisk', 'status_miss'], true)
));
return [
'statusAlerts' => $statusAlerts,
'staleProjects' => $staleProjects,
'overdueMilestones' => $milestoneReport['overdue'],
'goalsAtRisk' => $goalsAtRisk,
];
}
/**
* Weighted milestone progress over its tasks. The weights MUST stay in sync with
* {@see \Leantime\Domain\Tickets\Services\Tickets::getMilestoneProgress} — this bulk
* variant exists so report rollups don't re-query tasks per milestone.
*
* @param array<int, object> $tasks
* @param int[] $doneStatuses
*/
private function calculateMilestoneProgress(array $tasks, array $doneStatuses): float
{
$priorityFactor = [1 => 2, 2 => 1.75, 3 => 1.5, 4 => 1.25, 5 => 1];
$totalScore = 0.0;
$doneScore = 0.0;
foreach ($tasks as $task) {
$effort = empty($task->storypoints) ? 3 : (float) $task->storypoints;
$priority = empty($task->priority) ? 3 : (int) $task->priority;
$score = $effort * ($priorityFactor[$priority] ?? 1);
$totalScore += $score;
if (in_array((int) $task->status, $doneStatuses, true)) {
$doneScore += $score;
}
}
if ($totalScore === 0.0) {
return 0.0;
}
return $doneScore / $totalScore * 100;
}
/**
* Metric progress percent, mirroring the goal dashboard math in
* {@see \Leantime\Domain\Goalcanvas\Services\Goalcanvas::getCanvasItemsById}.
*/
private function calculateGoalProgress(object $goal): float
{
$total = (float) $goal->endValue - (float) $goal->startValue;
if ($total == 0.0) {
return 0.0;
}
$progress = (float) $goal->currentValue - (float) $goal->startValue;
return min(100, max(0, round($progress / $total, 2) * 100));
}
/**
* @param array<int, object> $tasks
* @param int[] $doneStatuses
* @return array{done: int, total: int}
*/
private function buildTaskStats(array $tasks, array $doneStatuses): array
{
$done = 0;
foreach ($tasks as $task) {
if (in_array((int) $task->status, $doneStatuses, true)) {
$done++;
}
}
return ['done' => $done, 'total' => count($tasks)];
}
/**
* Up to five tasks per milestone for the drill-down list, completed work first.
*
* @param array<int, object> $tasks
* @param int[] $doneStatuses
* @return array<int, object>
*/
private function pickKeyTasks(array $tasks, array $doneStatuses, int $limit = 5): array
{
usort($tasks, function (object $a, object $b) use ($doneStatuses) {
$aDone = in_array((int) $a->status, $doneStatuses, true) ? 0 : 1;
$bDone = in_array((int) $b->status, $doneStatuses, true) ? 0 : 1;
return $aDone <=> $bDone;
});
foreach ($tasks as $task) {
$task->isDone = in_array((int) $task->status, $doneStatuses, true);
}
return array_slice($tasks, 0, $limit);
}
/**
* @param array<int, object> $tasks
* @return array<int, array<int, object>> milestoneId => tasks
*/
private function groupTasksByMilestone(array $tasks): array
{
$grouped = [];
foreach ($tasks as $task) {
$grouped[(int) $task->milestoneid][] = $task;
}
return $grouped;
}
/**
* @param array<int, object> $upcoming Milestones sorted by start date
* @return array<string, array<int, object>> "Q3 2026" => milestones
*/
private function groupByQuarter(array $upcoming): array
{
$grouped = [];
foreach ($upcoming as $milestone) {
$grouped[$milestone->quarterLabel][] = $milestone;
}
return $grouped;
}
private function quarterLabel(CarbonImmutable $date): string
{
$userDate = $date->setToUserTimezone();
return 'Q'.$userDate->quarter.' '.$userDate->year;
}
/**
* Restricts a milestone color (the repurposed tags column) to safe CSS color forms —
* hex, var(--token) or a bare color keyword. The templates inject it into inline
* styles, so arbitrary text must not pass through.
*/
private function sanitizeCssColor(string $color): string
{
$color = trim($color);
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $color) === 1
|| preg_match('/^var\(--[a-zA-Z0-9-]+\)$/', $color) === 1
|| preg_match('/^[a-zA-Z]+$/', $color) === 1
) {
return $color;
}
return 'var(--grey)';
}
/**
* Parses a db datetime string, treating null/empty/zero dates as absent.
*/
private function parseDbDateOrNull(mixed $dbDate): ?CarbonImmutable
{
if (empty($dbDate) || str_starts_with((string) $dbDate, '0000-00-00')) {
return null;
}
try {
return dtHelper()->parseDbDateTime((string) $dbDate);
} catch (\Exception $e) {
return null;
}
}
/**
* Plain-text one-liner from a possibly-HTML field.
*/
private function excerpt(string $html, int $length = 140): string
{
$text = trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length - 1).'…';
}
}

View File

@@ -0,0 +1,594 @@
<?php
namespace Leantime\Domain\Reports\Services;
use DateTime;
use DateTimeZone;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Configuration\AppSettings as AppSettingCore;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Reactions\Repositories\Reactions;
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
use Leantime\Domain\Setting\Services\Setting as SettingsService;
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
/**
* Reports service: per-project report aggregation (burndown, ticket-status history) plus the
* system-level daily-ingestion and telemetry tasks.
*
* Authorization model: the three by-projectId @api reads carry a dispatch
* #[RequiresPermission(reports.view, projectIdParam: 'projectId')] gate, authorized against the
* REQUESTED project (closes the cross-project RPC IDOR). The system/cron methods (ingestion,
* telemetry) are NOT @api — they run from the scheduler with no session user and must never be
* RPC-reachable.
*
* @api
*/
class Reports extends BaseService
{
private AppSettingCore $appSettings;
private EnvironmentCore $config;
private ProjectRepository $projectRepository;
private SprintRepository $sprintRepository;
private ReportRepository $reportRepository;
private SettingsService $settings;
private TicketRepository $ticketRepository;
private SprintService $sprintService;
public function __construct(
AppSettingCore $appSettings,
EnvironmentCore $config,
ProjectRepository $projectRepository,
SprintRepository $sprintRepository,
ReportRepository $reportRepository,
SettingsService $settings,
TicketRepository $ticketRepository,
SprintService $sprintService
) {
$this->appSettings = $appSettings;
$this->config = $config;
$this->projectRepository = $projectRepository;
$this->sprintRepository = $sprintRepository;
$this->reportRepository = $reportRepository;
$this->settings = $settings;
$this->ticketRepository = $ticketRepository;
$this->sprintService = $sprintService;
}
/**
* Resolves which sprint burndown to display on the reports page.
*
* Mirrors the legacy controller selection order exactly:
* 1. An explicitly requested sprint id (from the query string).
* 2. Otherwise the project's current sprint.
* 3. Otherwise the first available sprint.
*
* The returned 'currentSprintId' preserves the original behaviour:
* when a sprint id is explicitly requested it is echoed back as-is
* (even if the sprint cannot be loaded); when falling back to the
* current/first sprint the resolved sprint object's id is used.
* When the project has no sprints at all, both values are false.
*
* @param int $projectId Project to resolve the burndown for.
* @param int|null $requestedSprintId Sprint id explicitly requested by the user, or null.
* @return array{chart: false|array, currentSprintId: int|false} Burndown chart data and the resolved sprint id.
*
* @api
*/
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
public function getSprintBurndownForReport(int $projectId, ?int $requestedSprintId): array
{
$allSprints = $this->sprintService->getAllSprints($projectId);
if (count($allSprints) === 0) {
return ['chart' => false, 'currentSprintId' => false];
}
$sprintChart = false;
if ($requestedSprintId !== null) {
$sprintObject = $this->sprintService->getSprint($requestedSprintId);
if ($sprintObject) {
$sprintChart = $this->sprintService->getSprintBurndown($sprintObject);
}
return ['chart' => $sprintChart, 'currentSprintId' => $requestedSprintId];
}
$currentSprint = $this->sprintService->getCurrentSprintId($projectId);
if ($currentSprint !== false && $currentSprint !== 'all') {
$sprintObject = $this->sprintService->getSprint((int) $currentSprint);
if ($sprintObject) {
$sprintChart = $this->sprintService->getSprintBurndown($sprintObject);
return ['chart' => $sprintChart, 'currentSprintId' => $sprintObject->id];
}
return ['chart' => $sprintChart, 'currentSprintId' => false];
}
$sprintChart = $this->sprintService->getSprintBurndown($allSprints[0]);
return ['chart' => $sprintChart, 'currentSprintId' => $allSprints[0]->id];
}
/**
* Runs the daily report ingestion for the session's current project.
*
* Not @api: an internal web-path helper (called by the gated Reports\Controllers\Show after
* dispatch enforcement). It reads session('currentProject'), so an RPC caller would have no
* meaningful project binding — and it was needlessly RPC-exposed before.
*
* @throws BindingResolutionException
*/
public function dailyIngestion(): void
{
$this->runIngestionForProject(session('currentProject'));
}
protected function runIngestionForProject(int $projectId): void
{
if (Cache::has('dailyReports-'.$projectId) === false || Cache::get('dailyReports-'.$projectId) < dtHelper()->dbNow()->endOfDay()) {
// Check if the dailyingestion cycle was executed already. There should be one entry for backlog and one entry for current sprint (unless there is no current sprint
// Get current Sprint Id, if no sprint available, dont run the sprint burndown
$lastEntries = $this->reportRepository->checkLastReportEntries($projectId);
// If we receive 2 entries we have a report already. If we have one entry then we ran the backlog one and that means there was no current sprint.
if (count($lastEntries) == 0) {
$currentSprint = $this->sprintRepository->getCurrentSprint($projectId);
if ($currentSprint !== false) {
$sprintReport = $this->reportRepository->runTicketReport($projectId, $currentSprint->id);
if ($sprintReport !== false) {
$this->reportRepository->addReport($sprintReport);
}
}
$backlogReport = $this->reportRepository->runTicketReport($projectId, '');
if ($backlogReport !== false) {
$this->reportRepository->addReport($backlogReport);
Cache::put('dailyReports-'.$projectId, dtHelper()->dbNow()->endOfDay(), 14400); // 4hours
}
}
}
}
public function cronDailyIngestion(): void
{
$projects = $this->projectRepository->getAll();
foreach ($projects as $project) {
$this->runIngestionForProject($project['id']);
}
}
/**
* Returns a project's stored report history, authorized against the requested project.
*
* $projectId is typed int so the param is REQUIRED and non-null: a JSON-RPC caller cannot
* pass null to make PermissionEnforcer::resolveProjectId() fall back to the session project
* (its isset() check treats explicit null as absent), which would dodge the per-target gate.
*
* @api
*/
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
public function getFullReport(int $projectId): false|array
{
return $this->reportRepository->getFullReport($projectId);
}
/**
* Computes a project's current ticket report, authorized against the requested project.
* The repository scopes by BOTH projectId and sprint, so a foreign sprint id yields no rows.
*
* $projectId is typed int for the same reason as getFullReport() — it keeps the dispatch gate
* bound to the requested project (no null → session fallback). $sprintId stays mixed because
* the empty string is the meaningful "backlog" selector.
*
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
public function getRealtimeReport(int $projectId, $sprintId): array|bool
{
return $this->reportRepository->runTicketReport($projectId, $sprintId);
}
/**
* Builds the anonymous telemetry payload (instance-wide usage counts + company GUID).
*
* Not @api: system-level data source for sendAnonymousTelemetry() only. Exposing it over
* JSON-RPC let any authenticated user read instance-wide aggregates (user/project/feature
* counts) — reconnaissance, not a user capability.
*/
public function getAnonymousTelemetry(
IdeaRepository $ideaRepository,
UserRepository $userRepository,
ClientRepository $clientRepository,
CommentRepository $commentsRepository,
TimesheetRepository $timesheetRepo,
BlueprintsRepository $blueprintsRepo
): array {
// Get anonymous company guid
$companyId = $this->settings->getCompanyId();
self::dispatch_event('beforeTelemetrySend', ['companyId' => $companyId]);
$companyLang = $this->settings->getSetting('companysettings.language');
if ($companyLang != '' && $companyLang !== false) {
$currentLanguage = $companyLang;
} else {
$currentLanguage = $this->config->language;
}
$projectStatusCount = $this->getProjectStatusReport();
$taskSentiment = $this->generateTicketReactionsReport();
$telemetry = [
'date' => '',
'companyId' => $companyId,
'env' => 'oss',
'version' => $this->appSettings->appVersion,
'language' => $currentLanguage,
'numUsers' => $userRepository->getNumberOfUsers(),
'lastUserLogin' => $userRepository->getLastLogin(),
'numProjects' => $this->projectRepository->getNumberOfProjects(null, 'project'),
'numProjectsGreen' => $projectStatusCount['green'] ?? 0,
'numProjectsYellow' => $projectStatusCount['yellow'] ?? 0,
'numProjectsRed' => $projectStatusCount['red'] ?? 0,
'numProjectsNone' => $projectStatusCount['none'] ?? 0,
'numStrategies' => $this->projectRepository->getNumberOfProjects(null, 'strategy'),
'numPrograms' => $this->projectRepository->getNumberOfProjects(null, 'program'),
'numClients' => $clientRepository->getNumberOfClients(),
'numComments' => $commentsRepository->countComments(),
'numMilestones' => $this->ticketRepository->getNumberOfMilestones(),
'numTickets' => $this->ticketRepository->getNumberOfAllTickets(),
'numBoards' => $ideaRepository->getNumberOfBoards(),
'numIdeaItems' => $ideaRepository->getNumberOfIdeas(),
'numHoursBooked' => $timesheetRepo->getHoursBooked(),
'numResearchBoards' => $blueprintsRepo->getNumberOfBoards(null, 'leancanvas'),
'numResearchItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'leancanvas'),
'numRetroBoards' => $blueprintsRepo->getNumberOfBoards(null, 'retroscanvas'),
'numRetroItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'retroscanvas'),
'numGoalBoards' => $blueprintsRepo->getNumberOfBoards(null, 'goalcanvas'),
'numGoalItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'goalcanvas'),
'numValueCanvasBoards' => $blueprintsRepo->getNumberOfBoards(null, 'valuecanvas'),
'numValueCanvasItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'valuecanvas'),
'numMinEmpathyBoards' => $blueprintsRepo->getNumberOfBoards(null, 'minempathycanvas'),
'numMinEmpathyItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'minempathycanvas'),
'numOBMBoards' => $blueprintsRepo->getNumberOfBoards(null, 'obmcanvas'),
'numOBMItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'obmcanvas'),
'numSWOTBoards' => $blueprintsRepo->getNumberOfBoards(null, 'swotcanvas'),
'numSWOTItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'swotcanvas'),
'numSBBoards' => $blueprintsRepo->getNumberOfBoards(null, 'sbcanvas'),
'numSBItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'sbcanvas'),
'numRISKSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'riskscanvas'),
'numRISKSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'riskscanvas'),
'numEABoards' => $blueprintsRepo->getNumberOfBoards(null, 'eacanvas'),
'numEAItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'eacanvas'),
'numINSIGHTSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'insightscanvas'),
'numINSIGHTSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'insightscanvas'),
'numWikiBoards' => $blueprintsRepo->getNumberOfBoards(null, 'wiki'),
'numWikiItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'wiki'),
'numTaskSentimentAngry' => $taskSentiment['🤬'] ?? 0,
'numTaskSentimentDisgust' => $taskSentiment['🤢'] ?? 0,
'numTaskSentimentUnhappy' => $taskSentiment['🙁'] ?? 0,
'numTaskSentimentNeutral' => $taskSentiment['😐'] ?? 0,
'numTaskSentimentHappy' => $taskSentiment['🙂'] ?? 0,
'numTaskSentimentLove' => $taskSentiment['😍'] ?? 0,
'numTaskSentimenUnicorn' => $taskSentiment['🦄'] ?? 0,
'serverSoftware' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
'phpUname' => php_uname(),
'isDocker' => $this->isRunningInDocker(),
'phpSapiName' => php_sapi_name(),
'phpOs' => PHP_OS,
];
$telemetry = self::dispatch_filter('beforeReturnTelemetry', $telemetry);
return $telemetry;
}
/**
* Sends the daily anonymous telemetry ping (throttled to once a day, opt-out respected).
*
* Not @api: a system task invoked by the scheduler (register.php cron, no session user) and
* the dashboard's lazy trigger — never a user-invokable RPC method (it makes an outbound
* HTTP request).
*
* @throws BindingResolutionException
*/
public function sendAnonymousTelemetry(): bool|PromiseInterface
{
// Only send once a day
$allowTelemetry = app('config')->allowTelemetry ?? true;
if ($allowTelemetry === true) {
$date_utc = new DateTime('now', new DateTimeZone('UTC'));
$today = $date_utc->format('Y-m-d');
$lastUpdate = $this->settings->getSetting('companysettings.telemetry.lastUpdate');
if ($lastUpdate != $today) {
$telemetry = app()->call([$this, 'getAnonymousTelemetry']);
$telemetry['date'] = $today;
// Do the curl
$httpClient = new Client;
try {
$data_string = json_encode($telemetry);
$promise = $httpClient->postAsync('https://telemetry.leantime.io', [
'form_params' => [
'telemetry' => $data_string,
],
// Short connect timeout so an offline/air-gapped server (or a
// CI runner with no egress) fails fast instead of blocking the
// dashboard's Welcome widget — and saturating PHP-FPM workers —
// for minutes. The previous 480s total timeout hung the page
// when telemetry was unreachable. (#3372/#3373)
'connect_timeout' => 2,
'timeout' => 5,
])->then(function ($response) use ($today) {
$this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today);
});
return $promise;
} catch (\Exception $e) {
Log::error($e);
return false;
}
}
}
return false;
}
/**
* Opts the whole instance out of telemetry (writes companysettings.telemetry.active=false).
*
* Not @api: an instance-wide settings MUTATION. It is invoked internally by the admin-gated
* company-settings save (Setting::saveCompanySettings) — over JSON-RPC it previously let ANY
* authenticated user flip the company-wide telemetry setting.
*
* @return false|void
*
* @throws Exception
*/
public function optOutTelemetry()
{
$date_utc = new DateTime('now', new DateTimeZone('UTC'));
$today = $date_utc->format('Y-m-d');
$companyId = $this->settings->getCompanyId();
$telemetry = [
'date' => '',
'companyId' => $companyId,
'version' => $this->appSettings->appVersion,
'language' => '',
'numUsers' => 0,
'lastUserLogin' => 0,
'numProjects' => 0,
'numClients' => 0,
'numComments' => 0,
'numMilestones' => 0,
'numTickets' => 0,
'numBoards' => 0,
'numIdeaItems' => 0,
'numHoursBooked' => 0,
'numResearchBoards' => 0,
'numResearchItems' => 0,
'numRetroBoards' => 0,
'numRetroItems' => 0,
'numGoalBoards' => 0,
'numGoalItems' => 0,
'numValueCanvasBoards' => 0,
'numValueCanvasItems' => 0,
'numMinEmpathyBoards' => 0,
'numMinEmpathyItems' => 0,
'numOBMBoards' => 0,
'numOBMItems' => 0,
'numSWOTBoards' => 0,
'numSWOTItems' => 0,
'numSBBoards' => 0,
'numSBItems' => 0,
'numRISKSBoards' => 0,
'numRISKSItems' => 0,
'numEABoards' => 0,
'numEAItems' => 0,
'numINSIGHTSBoards' => 0,
];
$telemetry['date'] = $today;
// Do the curl
$httpClient = new Client;
try {
$data_string = json_encode($telemetry);
$promise = $httpClient->postAsync('https://telemetry.leantime.io', [
'form_params' => [
'telemetry' => $data_string,
],
'timeout' => 5,
])->then(function ($response) use ($today) {
$this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today);
session(['skipTelemetry' => true]);
});
} catch (\Exception $e) {
report($e);
session(['skipTelemetry' => true]);
return false;
}
$this->settings->saveSetting('companysettings.telemetry.active', false);
session(['skipTelemetry' => true]);
try {
$promise->wait();
} catch (\Exception $e) {
report($e);
}
}
/**
* Counts ALL projects in the instance by status color — a telemetry data source.
*
* Not @api: instance-wide aggregation with no project scope; over JSON-RPC it disclosed the
* whole company's project-health summary to any authenticated user.
*
* @return array
*
* @throws Exception
*/
public function getProjectStatusReport()
{
$projectStatus = $this->projectRepository->getAll();
$statusList = ['green' => 0, 'yellow' => 0, 'red' => 0, 'none' => 0];
foreach ($projectStatus as $project) {
if (isset($statusList[$project['status']])) {
$statusList[$project['status']]++;
} else {
$statusList['none']++;
}
}
return $statusList;
}
public function generateTicketReactionsReport()
{
$reactionsRepo = app()->make(Reactions::class);
$collectedReactions = $reactionsRepo->getReactionsByModule('ticketSentiment');
$reactions = [
'🤬' => 0,
'🤢' => 0,
'🙁' => 0,
'😐' => 0,
'🙂' => 0,
'😍' => 0,
'🦄' => 0,
'other' => 0,
];
foreach ($collectedReactions as $reaction) {
if (isset($reactions[$reaction['reaction']])) {
$reactions[$reaction['reaction']] = $reactions[$reaction['reaction']] + $reaction['reactionCount'];
}
}
return $reactions;
}
/**
* Checks if Leantime is running in a Docker environment
* Uses multiple detection methods and handles errors gracefully
*/
private function isRunningInDocker(): bool
{
// Method 1: Check for /.dockerenv file
try {
if (is_file('/.dockerenv')) {
return true;
}
} catch (\Exception $e) {
// Silently fail if file access is restricted
}
// Method 2: Check for Docker-specific environment variables
if (getenv('DOCKER_CONTAINER') !== false || getenv('IS_DOCKER') !== false) {
return true;
}
// Method 3: Check cgroup info (works on Linux hosts)
try {
return strpos(file_get_contents('/proc/1/cgroup'), 'docker') !== false;
} catch (\Exception $e) {
return false; // Return false if all detection methods fail
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,832 @@
{{--
Stakeholder Report Page 2 (Logic Model read-out)
Fully rewritten against the punch-list. Behavior contracts:
1. Goal-as-truth. If an item has any linked goal (projectLinks entry with
linked_entity_type='goal'), the goal record supplies BOTH current and
target values. The item description is display label only never
parsed for a number in that case. Items with no goal link show no
denominator and no percent.
2. Guard: if aggregate target <= 0 or current/target > 5, drop the ratio
(impossible math never reaches a funder) and Log it.
3. Templated read: max 2 lines per stage, one going-well + one exception,
materiality-weighted at 20%. Vocabulary is fixed no freeform prose.
4. Belief line is ONE sentence, ≤220 chars, built from first 3 activities
+ first impact.
5. Risk box: one weakest health badge, single sentence, single period.
6. Layout: max-width 900px, min-width:0 on every grid child, no
horizontal blowout at 1280px.
7. Status color map is fixed at-risk is white bg + inset ring, never cream.
Vars in:
$logicModel null | {canvasId, narrative, stageProgress, healthBadges,
coverageMatrix, projectLinks, linkedGoals, projectMeta}
$hasLM bool
$scope 'strategy' | 'program'
--}}
@php
// Empty-state trigger is keyed on ITEM COUNT, not just board existence.
// A blank Logic Model board (0 items) makes $hasLM true but leaves the
// read-out hollow; the reverse flow itself creates a board on start, so
// gating only on board-existence would strand users in an empty skeleton.
// Treat "no board" and "board with no items" identically — both land on
// the populate-from-your-work empty-state below.
$lmStages = $hasLM ? ($logicModel['coverageMatrix']['stages'] ?? []) : [];
$lmHasContent = false;
foreach ($lmStages as $lmStage) {
if (count($lmStage['items'] ?? []) > 0) {
$lmHasContent = true;
break;
}
}
// Existing linked work in this strategy, for the empty-state's "we found
// your work" line. programRows carry type + projectCount; program rows
// count as programs, and projectCount sums to total leaf projects (direct
// + under programs). Empty at program scope, so the line self-hides there.
$lmProgramCount = 0;
$lmProjectCount = 0;
foreach ($programRows ?? [] as $lmRow) {
// programRows carries stdClass rows (object[], like page-programs), so
// cast before array access to avoid "Cannot use object as array".
$lmRow = (array) $lmRow;
if (($lmRow['type'] ?? '') === 'program') {
$lmProgramCount++;
}
$lmProjectCount += (int) ($lmRow['projectCount'] ?? 0);
}
// Bold the counts so the totals read as totals. Every piece is server-side
// (ints + translated nouns, both e()-escaped), so the {!! !!} render below
// carries no user input.
$lmFoundParts = [];
if ($lmProgramCount > 0) {
$lmFoundParts[] = '<b>'.$lmProgramCount.'</b> '.e(__($lmProgramCount === 1 ? 'stakeholder.lm.found_program' : 'stakeholder.lm.found_programs'));
}
if ($lmProjectCount > 0) {
$lmFoundParts[] = '<b>'.$lmProjectCount.'</b> '.e(__($lmProjectCount === 1 ? 'stakeholder.lm.found_project' : 'stakeholder.lm.found_projects'));
}
$lmFoundStr = implode('<span class="sep">·</span>', $lmFoundParts);
@endphp
@if (! $lmHasContent)
<div class="p2-wrap">
<div class="p2-lm-emptyzone">
<div class="p2-lm-empty">
<span class="ic"><i class="fa fa-diagram-project" aria-hidden="true"></i></span>
<h2 class="t">{{ __('stakeholder.lm.empty_title') }}</h2>
<div class="b">{{ __('stakeholder.lm.empty_body') }}</div>
@if (($scope ?? '') === 'strategy')
@if ($lmFoundStr !== '')
<div class="found">
<i class="fa fa-circle-check" aria-hidden="true"></i>
<span>{!! sprintf(e(__('stakeholder.lm.empty_found')), $lmFoundStr) !!}</span>
</div>
@endif
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="cta">
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.lm.empty_cta') }}
</a>
<div class="hint">{{ __('stakeholder.lm.empty_hint') }}</div>
@endif
</div>
</div>
</div>
@else
@php
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
$columns = $logicModel['coverageMatrix']['columns'] ?? [];
$unaligned = $logicModel['coverageMatrix']['unalignedColumns'] ?? [];
$healthBadges = $logicModel['healthBadges'] ?? [];
$projectLinks = $logicModel['projectLinks'] ?? [];
$linkedGoals = $logicModel['linkedGoals'] ?? [];
$projectMeta = $logicModel['projectMeta'] ?? [];
$activityItems = $stages['activities']['items'] ?? [];
$outputItems = $stages['outputs']['items'] ?? [];
$outcomeItems = $stages['outcomes']['items'] ?? [];
$impactItems = $stages['impact']['items'] ?? [];
$isEvaluated = fn ($item) => isset($projectLinks[(int) (((array) $item)['id'] ?? 0)])
&& count($projectLinks[(int) (((array) $item)['id'] ?? 0)]) > 0;
// Belief line — templated, ≤220 chars.
$activityDescs = array_slice(
array_values(array_filter(array_map(fn ($it) => trim((string) (((array) $it)['description'] ?? '')), $activityItems))),
0, 3
);
$activitiesSummary = '';
if (count($activityDescs) > 0) {
$lowered = array_map(fn ($s) => (mb_strtolower(mb_substr($s, 0, 1)) . mb_substr($s, 1)), $activityDescs);
$activitiesSummary = count($lowered) === 1
? $lowered[0]
: (count($lowered) === 2
? $lowered[0] . ' and ' . $lowered[1]
: $lowered[0] . ', ' . $lowered[1] . ', and ' . $lowered[2]);
}
$impactSummary = '';
if (count($impactItems) > 0) {
$impactSummary = trim((string) (((array) $impactItems[0])['description'] ?? ''));
// lowercase first char for grammar
if ($impactSummary !== '') $impactSummary = mb_strtolower(mb_substr($impactSummary, 0, 1)) . mb_substr($impactSummary, 1);
}
$beliefLine = '';
if ($activitiesSummary !== '' && $impactSummary !== '') {
$beliefLine = sprintf(__('stakeholder.lm.belief_full'), $activitiesSummary, $impactSummary);
} elseif ($activitiesSummary !== '') {
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
}
if (mb_strlen($beliefLine) > 220 && $activitiesSummary !== '') {
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
}
// Infer the item's expected metric unit ONCE from its authored label.
// Cached in $itemExpectedUnit so render never regexes. `%` → percent,
// `$` → currency, anything else → number. This is the label's contract;
// goals whose metricType disagrees are dropped from the item's rollup
// (rather than silently pretending unrelated units are the same thing).
$itemExpectedUnit = [];
$inferItemUnit = function (string $desc): string {
$d = trim($desc);
if ($d === '') return 'number';
if (str_contains($d, '%')) return 'percent';
if (str_contains($d, '$')) return 'currency';
return 'number';
};
foreach (array_merge($outputItems, $outcomeItems, $activityItems, $impactItems) as $it) {
$iid = (int) (((array) $it)['id'] ?? 0);
if ($iid > 0) $itemExpectedUnit[$iid] = $inferItemUnit((string) (((array) $it)['description'] ?? ''));
}
// Goal-based aggregate for an item. Only goals whose unit matches the
// item's expected unit contribute — mismatched goals are logged and
// dropped (safety over silent lying). Returns null when no matching
// goals exist, or the ratio is impossible.
$itemGoalAggregate = function ($item) use ($projectLinks, $linkedGoals, $itemExpectedUnit) {
$itemId = (int) (((array) $item)['id'] ?? 0);
$expectedUnit = $itemExpectedUnit[$itemId] ?? 'number';
$links = $projectLinks[$itemId] ?? [];
$goals = [];
$mismatched = 0;
foreach ($links as $link) {
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
$gid = (int) ($link['linked_entity_id'] ?? 0);
if (! isset($linkedGoals[$gid])) continue;
$g = $linkedGoals[$gid];
$gUnit = ($g['metricType'] ?? 'number') === 'percent' ? 'percent'
: (($g['metricType'] ?? 'number') === 'currency' ? 'currency' : 'number');
if ($gUnit !== $expectedUnit) {
$mismatched++;
\Illuminate\Support\Facades\Log::info('page2: unit mismatch on link', [
'itemId' => $itemId, 'expected' => $expectedUnit,
'goalId' => $gid, 'goalUnit' => $gUnit,
]);
continue;
}
$goals[] = $g;
}
if (count($goals) === 0) return null;
$current = array_sum(array_column($goals, 'currentValue'));
$end = array_sum(array_column($goals, 'endValue'));
$hasTarget = $end > 0;
$ratio = $hasTarget ? ($current / $end) : null;
if ($ratio !== null && $ratio > 5) {
\Illuminate\Support\Facades\Log::warning('page2: implausible ratio', ['itemId' => $itemId, 'current' => $current, 'target' => $end]);
$hasTarget = false;
}
$anyRisk = false; $allOnTrack = true;
foreach ($goals as $g) {
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') $anyRisk = true;
if ($g['status'] !== 'status_ontrack') $allOnTrack = false;
}
return [
'goals' => $goals,
'current' => $current,
'end' => $end,
'hasTarget' => $hasTarget,
'pct' => $hasTarget ? min(100, (int) round($ratio * 100)) : 0,
'metricType' => $expectedUnit,
'ragClass' => $anyRisk ? 'risk' : ($allOnTrack ? 'ok' : 'wip'),
'mismatched' => $mismatched,
];
};
// Format a metric respecting its type (percent vs number).
$fmtMetric = function ($value, string $metricType) {
$value = (float) $value;
if ($metricType === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
if ($value == floor($value)) return number_format($value, 0, '.', ',');
return number_format($value, 1, '.', ',');
};
// Strip leading numeric token from label when it's redundant with a goal
// aggregate. "1,200 screenings completed" → "screenings completed".
$stripLeadingNumber = function (string $desc): string {
if (preg_match('/^[\d][\d,\.]*(?:%|\+|)?\s+(.+)$/u', trim($desc), $m)) {
return trim($m[1]);
}
return trim($desc);
};
// Stage-level aggregate — total contributions across items in the stage.
$stageAgg = function (array $items) use ($isEvaluated, $itemGoalAggregate) {
$total = 0.0; $end = 0.0; $anyRisk = false; $rolled = [];
foreach ($items as $item) {
if (! $isEvaluated($item)) continue;
$a = $itemGoalAggregate($item);
if ($a === null) continue;
$itemId = (int) (((array) $item)['id'] ?? 0);
$rolled[$itemId] = $a;
$total += $a['current'];
$end += $a['end'];
if ($a['ragClass'] === 'risk') $anyRisk = true;
}
$pct = $end > 0 ? min(100, (int) round(($total / $end) * 100)) : 0;
return ['rolled' => $rolled, 'pct' => $pct, 'anyRisk' => $anyRisk, 'total' => $total, 'end' => $end];
};
// Per-program contribution across a stage — for Show-the-breakdown and
// the templated read. Returns {contributors, unresolvedShare}.
//
// A goal is UNRESOLVED when its owning project is missing, has no
// parent program, or is itself the strategy — those are never
// contributors (a strategy is not its own program; a null bucket is
// not a colleague). Unresolved value is tracked separately so the
// page can render an honest data-quality note without personifying
// the null bucket as a named entity.
$stageProgramRollup = function (array $items) use ($isEvaluated, $itemGoalAggregate, $projectMeta) {
$perProgram = [];
$unresolvedCurrent = 0.0;
$unresolvedEnd = 0.0;
foreach ($items as $item) {
if (! $isEvaluated($item)) continue;
$a = $itemGoalAggregate($item);
if ($a === null) continue;
$itemId = (int) (((array) $item)['id'] ?? 0);
$itemDesc = trim((string) (((array) $item)['description'] ?? ''));
foreach ($a['goals'] as $g) {
$pid = $g['projectId'];
$progId = $g['programId'];
// Unresolved: no project, no program, or the "project" is
// actually a strategy row (which happens when a goal lives
// on the strategy's own canvas).
$isUnresolved = ($pid === null)
|| ($progId === null)
|| (($g['projectType'] ?? '') === 'strategy');
if ($isUnresolved) {
$unresolvedCurrent += $g['currentValue'];
$unresolvedEnd += $g['endValue'];
continue;
}
$key = $progId;
if (! isset($perProgram[$key])) {
$perProgram[$key] = [
'id' => $progId,
'name' => $projectMeta[$pid]['programName'] ?? ('#'.$progId),
'current' => 0.0,
'end' => 0.0,
'anyRisk' => false,
'allOnTrack' => true,
'projects' => [],
'riskItems' => [], // {id, description} for citation in exception line
];
}
$perProgram[$key]['current'] += $g['currentValue'];
$perProgram[$key]['end'] += $g['endValue'];
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') {
$perProgram[$key]['anyRisk'] = true;
// Track the ITEM this contributor caused risk on — the read
// uses it to make exception lines cite what's off.
$alreadyTracked = false;
foreach ($perProgram[$key]['riskItems'] as $ri) {
if ($ri['id'] === $itemId) { $alreadyTracked = true; break; }
}
if (! $alreadyTracked) {
$perProgram[$key]['riskItems'][] = ['id' => $itemId, 'description' => $itemDesc];
}
}
if ($g['status'] !== 'status_ontrack') $perProgram[$key]['allOnTrack'] = false;
// Project row aggregation inside program. Skip strategy-typed
// projects (defense-in-depth: already filtered above).
if (($g['projectType'] ?? '') === 'strategy') continue;
$found = false;
foreach ($perProgram[$key]['projects'] as &$pj) {
if ($pj['id'] === $pid) {
$pj['current'] += $g['currentValue'];
$pj['end'] += $g['endValue'];
if ($g['status'] === 'status_atrisk') $pj['ragClass'] = 'risk';
$found = true;
break;
}
}
unset($pj);
if (! $found) {
$perProgram[$key]['projects'][] = [
'id' => $pid,
'name' => $projectMeta[$pid]['name'] ?? ('#'.$pid),
'current' => $g['currentValue'],
'end' => $g['endValue'],
'ragClass' => $g['status'] === 'status_atrisk' ? 'risk' : ($g['status'] === 'status_ontrack' ? 'ok' : 'wip'),
];
}
}
}
// Renormalize shares over RESOLVED contributors only. Unresolved
// value doesn't dilute the lead's share — it's tracked separately
// as a data-quality signal.
$resolvedTotal = array_sum(array_column($perProgram, 'current'));
foreach ($perProgram as &$prog) {
$prog['pct'] = $resolvedTotal > 0 ? (int) round(($prog['current'] / $resolvedTotal) * 100) : 0;
$prog['ragClass'] = $prog['anyRisk'] ? 'risk' : ($prog['allOnTrack'] ? 'ok' : 'wip');
$prog['statusWord'] = $prog['anyRisk'] ? __('stakeholder.lm.status_word_behind') : ($prog['allOnTrack'] ? __('stakeholder.lm.status_word_ontrack') : __('stakeholder.lm.status_word_ramping'));
usort($prog['projects'], fn ($a, $b) => $b['current'] <=> $a['current']);
}
unset($prog);
uasort($perProgram, fn ($a, $b) => $b['pct'] <=> $a['pct']);
$grandTotal = $resolvedTotal + $unresolvedCurrent;
$unresolvedShare = $grandTotal > 0 ? (int) round(($unresolvedCurrent / $grandTotal) * 100) : 0;
return [
'contributors' => array_values($perProgram),
'unresolvedShare' => $unresolvedShare,
'hasUnresolved' => $unresolvedCurrent > 0 || $unresolvedEnd > 0,
];
};
// Templated read — max 2 lines. Vocabulary is fixed per punch-list §2.
// BRANCHES ON CONTRIBUTOR COUNT:
// 1 → single-contributor sentence (scoped to the stage — "on outputs
// here", not bare "on this" — so cross-card contradictions read
// as legitimate scope differences)
// 2 → lead is carrying + second adds the rest
// 3+ → lead + weighted exception, materiality threshold 20%
// Exception lines CITE THE ITEM the contributor is off on — the row
// status and the read reconcile via the item name.
$renderRead = function (array $programs, string $scopeLabel) {
$n = count($programs);
if ($n === 0) return [];
$statusLead = fn ($p) => match ($p['ragClass']) {
'ok' => '<span class="g">' . e($p['statusWord']) . '</span>',
'risk' => '<span class="r">' . e($p['statusWord']) . '</span>',
default => '<span class="w">' . e($p['statusWord']) . '</span>',
};
// ── Single-contributor branch: no shares, no driver language.
// Scoped ("outputs here" / "outcomes here") so a program appearing
// in both cards with different statuses reads as legitimate scope,
// not contradiction.
if ($n === 1) {
$only = $programs[0];
$type = $only['ragClass'] === 'risk' ? 'risk' : ($only['ragClass'] === 'ok' ? 'good' : 'watch');
return [[
'type' => $type,
'html' => sprintf(
__('stakeholder.lm.read_only_program_scoped'),
e($only['name']),
e($scopeLabel),
$statusLead($only)
),
]];
}
$lead = $programs[0];
$second = $programs[1] ?? null;
$lines = [];
// Helper: name the specific item this contributor is off on.
// Cites the first risk item; if none, empty string skips the
// "on X" clause.
$riskItemName = function (array $prog): string {
$items = $prog['riskItems'] ?? [];
return count($items) > 0 ? (string) ($items[0]['description'] ?? '') : '';
};
$exceptionLine = function (array $prog, bool $critical) use ($statusLead, $riskItemName) {
$item = $riskItemName($prog);
$onClause = $item !== '' ? sprintf(__('stakeholder.lm.read_on_item_clause'), e($item)) : '';
$tmpl = $critical ? 'stakeholder.lm.read_critical_cited' : 'stakeholder.lm.read_watch_cited';
// Order: NAME is STATUS_WORD on ITEM — but at X% ...
// statusLead comes BEFORE onClause; onClause carries its own
// leading space so tokens don't run together.
return [
'type' => $critical ? 'risk' : 'watch',
'html' => '<span class="lead-label">' . e($critical ? __('stakeholder.lm.critical_label') : __('stakeholder.lm.watch_label')) . ':</span> '
. sprintf(
__($tmpl),
e($prog['name']),
$statusLead($prog),
$onClause,
$prog['pct']
),
];
};
// ── Two-contributor branch: lead + second.
// If second is the exception, DROP the "adds the rest" clause —
// otherwise it names the second twice (benign in line 1, behind
// in line 2). Never two lines about the same entity.
if ($n === 2) {
$secondIsException = $second['ragClass'] !== 'ok';
$goingWellHtml = sprintf(
__('stakeholder.lm.read_going_well'),
e($lead['name']),
$lead['pct'],
$statusLead($lead)
);
if (! $secondIsException) {
$goingWellHtml .= sprintf(__('stakeholder.lm.read_second_clause'), e($second['name']));
}
$lines[] = ['type' => 'good', 'html' => $goingWellHtml];
if ($secondIsException) {
$lines[] = $exceptionLine($second, $second['pct'] >= 20);
}
return $lines;
}
// ── 3+ contributor branch: lead + weighted exception.
$exception = null;
foreach ($programs as $p) {
if ($p['ragClass'] !== 'ok') { $exception = $p; break; }
}
$leadIsException = $exception !== null && $exception['id'] === $lead['id'];
if (! $leadIsException) {
$lines[] = [
'type' => 'good',
'html' => sprintf(
__('stakeholder.lm.read_going_well'),
e($lead['name']),
$lead['pct'],
$statusLead($lead)
),
];
}
if ($exception !== null) {
$lines[] = $exceptionLine($exception, $exception['pct'] >= 20 || $leadIsException);
}
return $lines;
};
// Weakest fragile link for the Risk block — one, not four.
$fragileLink = null;
$riskPref = ['risk' => 0, 'warning' => 1];
foreach ($healthBadges as $badge) {
$s = $badge['health_status'] ?? '';
if (! isset($riskPref[$s])) continue;
if ($fragileLink === null || $riskPref[$s] < $riskPref[$fragileLink['health_status']]) {
$fragileLink = $badge;
continue;
}
// Tie-break on risk_level (higher = worse).
if ($s === $fragileLink['health_status'] && ((int) ($badge['risk_level'] ?? 0)) > ((int) ($fragileLink['risk_level'] ?? 0))) {
$fragileLink = $badge;
}
}
@endphp
<div class="p2-wrap">
<div class="p2-subhead">{{ __('stakeholder.lm.page_subhead') }}</div>
{{-- Belief one templated sentence --}}
<div class="p2-believe">
<span class="lb">{{ __('stakeholder.lm.believe_label') }}</span>
@if ($beliefLine !== '')
{{ $beliefLine }}
@else
{{ __('stakeholder.lm.belief_empty') }}
@endif
</div>
{{-- Stage card renderer one call for Outputs, one for Outcomes. --}}
@foreach ([
['key' => 'outputs', 'items' => $outputItems, 'frame' => __('stakeholder.lm.frame_producing_outputs'), 'icon' => 'fa-boxes-stacked', 'cls' => 'outputs'],
['key' => 'outcomes', 'items' => $outcomeItems, 'frame' => __('stakeholder.lm.frame_achieving_outcomes'), 'icon' => 'fa-chart-line', 'cls' => 'outcomes'],
] as $stage)
@php
$stageItems = array_values(array_filter($stage['items'], $isEvaluated));
if (count($stageItems) === 0) continue;
$agg = $stageAgg($stage['items']);
$rollupResult = $stageProgramRollup($stage['items']);
$rollup = $rollupResult['contributors'];
$scopeLabel = $stage['key'] === 'outputs' ? __('stakeholder.lm.scope_outputs') : __('stakeholder.lm.scope_outcomes');
$readLns = $renderRead($rollup, $scopeLabel);
// Badge derives from the READ's outcome, not from item-level rollup.
// This makes the badge the read's headline — a reader can't catch
// the card contradicting itself (v4 Fix 4).
// No resolved contributors → "no evidence yet"
// Any Critical line → "At risk"
// Any Watch line only → "In progress"
// All going-well → "On track"
$readTypes = array_column($readLns, 'type');
if (count($rollup) === 0) {
$badgeCls = 'pending';
$badgeLbl = __('stakeholder.lm.no_evidence_yet');
} elseif (in_array('risk', $readTypes, true)) {
$badgeCls = 'risk';
$badgeLbl = __('stakeholder.lm.verdict_at_risk');
} elseif (in_array('watch', $readTypes, true)) {
$badgeCls = 'wip';
$badgeLbl = __('stakeholder.lm.verdict_in_progress');
} else {
$badgeCls = 'ok';
$badgeLbl = __('stakeholder.lm.verdict_on_track');
}
@endphp
<div class="p2-stage {{ $stage['cls'] }}">
<div class="stage-hd">
<div class="frame"><i class="fa {{ $stage['icon'] }}"></i> {{ $stage['frame'] }}</div>
<span class="p2-vbadge {{ $badgeCls }}"><span class="sd"></span> {{ $badgeLbl }}</span>
</div>
@if (count($readLns) > 0)
<div class="p2-read">
@foreach ($readLns as $ln)
<div class="p2-readline {{ $ln['type'] }}">
<span class="dot"></span>
<div>{!! $ln['html'] !!}</div>
</div>
@endforeach
</div>
@elseif ($rollupResult['hasUnresolved'])
{{-- No resolved contributors, only unresolved: don't render a
read; render the not-linked note alone. --}}
<div class="p2-unresolved">{{ __('stakeholder.lm.not_linked_note') }}</div>
@endif
@if (count($readLns) > 0 && $rollupResult['unresolvedShare'] >= 10)
{{-- Data-quality note, muted, non-prose. Not a bullet, not in
the read's voice. --}}
<div class="p2-unresolved">{{ sprintf(__('stakeholder.lm.unresolved_note'), $rollupResult['unresolvedShare']) }}</div>
@endif
<div class="p2-rows">
@foreach ($stageItems as $item)
@php
$itemId = (int) (((array) $item)['id'] ?? 0);
$arr = (array) $item;
// Label is ALWAYS the authored description, verbatim
// — never prefixed, never regex-parsed for a number.
$label = trim((string) ($arr['description'] ?? ''));
// Aggregate carries only unit-matched goals (see
// itemGoalAggregate). When present, we can safely
// render the current value as its own element beside
// the label — no splice.
$a = $agg['rolled'][$itemId] ?? null;
$currentDisplay = '';
if ($a !== null) {
// Row status = goal RAG (same as the contributor
// rollup that feeds the badge). Not a pct
// threshold — a row and its card must agree.
if ($a['ragClass'] === 'risk') {
$rowCls = 'risk'; $rowLbl = __('stakeholder.lm.status_at_risk');
} elseif ($a['ragClass'] === 'ok') {
$rowCls = 'ok'; $rowLbl = __('stakeholder.lm.status_on_track');
} else {
$rowCls = 'wip'; $rowLbl = __('stakeholder.lm.status_in_progress');
}
$currentDisplay = sprintf(__('stakeholder.lm.so_far'), $fmtMetric($a['current'], $a['metricType']));
} else {
$rowCls = 'pending';
$rowLbl = __('stakeholder.lm.status_pending');
}
@endphp
@php
// Provenance for the row status — surfaced as a
// tooltip on hover so the reader can trace it back to
// a person and date without instructional prose on
// the page. Format: "Set by {Author} · {Date}". Falls
// back to a generic note when author/date is missing.
$statusBasis = '';
if ($a !== null && count($a['goals']) > 0) {
$sourceGoal = null;
foreach ($a['goals'] as $ag) {
if ($sourceGoal === null || (string) ($ag['modified'] ?? '') > (string) ($sourceGoal['modified'] ?? '')) {
$sourceGoal = $ag;
}
}
$author = trim((string) ($sourceGoal['authorName'] ?? ''));
$dateStr = '';
$mod = (string) ($sourceGoal['modified'] ?? '');
if ($mod !== '') {
try { $dateStr = (new \DateTimeImmutable($mod))->format('M j'); } catch (\Exception $e) {}
}
if ($author !== '' && $dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who_when'), $author, $dateStr);
elseif ($author !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who'), $author);
elseif ($dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_when'), $dateStr);
}
@endphp
<div class="p2-row">
<div class="p2-row-title">
{{ $label }}
@if ($currentDisplay !== '')
<span class="p2-row-value">{{ $currentDisplay }}</span>
@endif
</div>
<span class="p2-row-status {{ $rowCls }}"@if ($statusBasis !== '') data-tippy-content="{{ $statusBasis }}"@endif><span class="sd"></span> {{ $rowLbl }}</span>
</div>
@endforeach
</div>
@if (count($rollup) > 0)
<details class="p2-brk">
<summary><i class="fa fa-chevron-right"></i> {{ __('stakeholder.lm.show_breakdown') }}</summary>
<div class="p2-brk-body">
@php
// Same contributor-count branch as the read:
// n = 1 → no bar, no percent, no "100%" tautology
// n ≥ 2 → name + count + share bar + share % + status
$contribCount = count($rollup);
@endphp
@foreach ($rollup as $prog)
@php
$showProjects = array_slice($prog['projects'], 0, 4);
$moreProj = max(0, count($prog['projects']) - 4);
$projCount = count($prog['projects']);
$nProjectsLbl = $projCount === 1
? __('stakeholder.lm.n_project_one')
: sprintf(__('stakeholder.lm.n_projects'), $projCount);
$pstatLbl = $prog['ragClass'] === 'risk'
? __('stakeholder.lm.status_at_risk')
: ($prog['ragClass'] === 'ok' ? __('stakeholder.lm.status_on_track') : __('stakeholder.lm.status_in_progress'));
@endphp
<div class="p2-brk-prog">
@if ($contribCount === 1)
<div class="p2-brk-progrow single">
<span class="pdot"></span>
<div>
<div class="pn">{{ $prog['name'] }}</div>
<div class="pmeta">{{ $nProjectsLbl }}</div>
</div>
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
</div>
@else
<div class="p2-brk-progrow">
<span class="pdot"></span>
<div>
<div class="pn">{{ $prog['name'] }}</div>
<div class="pmeta">{{ $nProjectsLbl }}</div>
</div>
<div class="pbar"><i style="width:{{ min(100, $prog['pct']) }}%;"></i></div>
<div class="pshare">{{ $prog['pct'] }}%</div>
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
</div>
@endif
@if (count($showProjects) > 0)
<div class="p2-brk-projs">
@foreach ($showProjects as $pj)
<div class="p2-brk-pj">
<span class="pjn">{{ $pj['name'] }}</span>
<span class="pjd {{ $pj['ragClass'] }}"></span>
</div>
@endforeach
@if ($moreProj > 0)
<div class="p2-brk-more">+ {{ $moreProj }} {{ __('stakeholder.lm.more_word') }}</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
</div>
@endforeach
{{-- Impact one aim, not a list --}}
@if (count($impactItems) > 0)
@php $primaryImpact = trim((string) (((array) $impactItems[0])['description'] ?? '')); @endphp
@if ($primaryImpact !== '')
<div class="p2-impact">
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.lm.for_what_label') }}</div>
<div class="goal">{{ $primaryImpact }}</div>
<div class="horizon">{{ __('stakeholder.lm.impact_horizon') }}</div>
</div>
@endif
@endif
{{-- Risk one weakest fragile link, single sentence, single period --}}
@if ($fragileLink !== null)
@php
$assumption = trim((string) ($fragileLink['assumption_text'] ?? ''));
$assumption = rtrim($assumption, '.!?');
$connector = trim((string) ($fragileLink['connector_label'] ?? ''));
@endphp
<div class="p2-risk">
<i class="fa fa-triangle-exclamation ri"></i>
<div class="rb">
<span class="rl">{{ __('stakeholder.lm.risk_label') }}</span>
@if ($assumption !== '')
{{ __('stakeholder.lm.risk_leap_intro') }} <b>{{ $assumption }}</b>.
@elseif ($connector !== '')
{{ __('stakeholder.lm.risk_generic_intro') }} <b>{{ $connector }}</b>.
@endif
@if (empty($fragileLink['has_data']))
<b>{{ __('stakeholder.lm.risk_no_evidence') }}</b>{{ __('stakeholder.lm.risk_keep_honest') }}
@endif
</div>
</div>
@endif
{{-- ── Also this period real completed work with NO LM link.
Item-grain drift (unalignedColumns names program-grain drift; this
is one level finer). Each row is a completion that touched real
numbers but maps to nothing in the model a link-me-to-an-outcome
invitation, not a scold. Silent when nothing is unlinked. --}}
@php
$completedThisPeriod = (array) ($report['milestones']['completed'] ?? []);
// Build the set of milestone IDs any LM item links to.
$linkedMilestoneIds = [];
foreach ($projectLinks as $itemLinks) {
foreach ($itemLinks as $link) {
if (($link['linked_entity_type'] ?? '') === 'milestone') {
$linkedMilestoneIds[(int) $link['linked_entity_id']] = true;
}
}
}
// "Something real to show" per the spec — a metric, a count, OR
// a completion. A completed milestone IS a completion, so a
// headline is sufficient; task stats are optional decoration.
// Filter out anything with no headline (a bare row is noise).
$alsoThisPeriod = [];
foreach ($completedThisPeriod as $ms) {
$mid = (int) ($ms->id ?? 0);
if ($mid === 0 || isset($linkedMilestoneIds[$mid])) continue;
$headline = trim((string) ($ms->headline ?? ''));
if ($headline === '') continue;
$taskStats = (array) ($ms->taskStats ?? []);
$doneCount = (int) ($taskStats['done'] ?? 0);
$totalCount = (int) ($taskStats['total'] ?? 0);
$metric = $totalCount > 0
? sprintf(__('stakeholder.lm.also_metric_tasks'), $doneCount, $totalCount)
: '';
$alsoThisPeriod[] = [
'id' => $mid,
'headline' => $headline,
'metric' => $metric,
'projectId' => (int) ($ms->projectId ?? 0),
'canvasId' => (int) ($logicModel['canvasId'] ?? 0),
];
}
$alsoCount = count($alsoThisPeriod);
$alsoShown = array_slice($alsoThisPeriod, 0, 3);
$alsoMore = max(0, $alsoCount - 3);
@endphp
@if ($alsoCount > 0)
<div class="p2-also">
<div class="lb"><i class="fa fa-code-branch"></i> {{ __('stakeholder.lm.also_label') }}</div>
@foreach ($alsoShown as $row)
{{-- Plain drift note: a milestone that closed this period but
doesn't map to a Logic Model outcome. No CTA — the app
doesn't currently have a one-click "link a milestone to
an outcome" flow, and a dead link on the honesty page is
worse than no link. When the flow lands, wire it here. --}}
<div class="row">
<b>{{ $row['headline'] }}</b>@if ($row['metric'] !== '') · <span class="metric">{{ $row['metric'] }}</span>@endif
</div>
@endforeach
@if ($alsoMore > 0)
<div class="more">{{ sprintf(__('stakeholder.lm.also_more'), $alsoMore) }}</div>
@endif
</div>
@endif
{{-- Off-strategy drift (strategy scope) --}}
@if (($scope ?? '') === 'strategy' && count($unaligned) > 0)
@php
$unalignedNames = array_map(
fn ($id) => $columns[$id]['name'] ?? ('#'.$id),
array_slice($unaligned, 0, 5)
);
$moreDrift = max(0, count($unaligned) - 5);
@endphp
<div class="p2-drift">
<i class="fa fa-diagram-project"></i>
<div>
<b>{{ __('stakeholder.lm.drift_label') }}:</b>
{{ sprintf(__('stakeholder.lm.drift_hint'), count($unaligned)) }}
<em>{{ implode(', ', $unalignedNames) }}@if ($moreDrift > 0) +{{ $moreDrift }} @endif</em>
</div>
</div>
@endif
</div>
@endif

View File

@@ -0,0 +1,626 @@
{{--
Stakeholder Report Page 1 (Overview)
§5 page 1: KPI band + peak-this-period hero + needs-attention +
Theory-of-Change narrative + theory-health strip.
Vars in (from deck):
$completedCount int
$completedDelta int (may be negative)
$goalsOnTrack int
$goalsTotal int
$overdueCount int
$hoursLogged float
$needsAttn array $report['needsAttention']
$logicModel null | {narrative, healthBadges, ...}
$hasLM bool
--}}
{{-- ── KPI band (value-first, delta inline, lowercase label) ──────── --}}
@php
// Drill-down source data. Show top 5 per cell, "+N more" tail if longer.
$completedItems = array_slice($report['milestones']['completed'] ?? [], 0, 5);
$completedMoreCount = max(0, count($report['milestones']['completed'] ?? []) - 5);
$overdueItems = array_slice($report['needsAttention']['overdueMilestones'] ?? [], 0, 5);
$overdueMoreCount = max(0, count($report['needsAttention']['overdueMilestones'] ?? []) - 5);
// Denominator counts for KPI context — a raw "3 overdue" doesn't say
// "out of how many". Total milestones = completed + inFlight + overdue +
// upcoming; open = the not-yet-done subset (inFlight + overdue + upcoming).
$inFlightCount = (int) ($stats['inFlight'] ?? 0);
$upcomingCount = (int) ($stats['upcoming'] ?? 0);
$openMsCount = $inFlightCount + $overdueCount + $upcomingCount;
$totalMsCount = $completedCount + $openMsCount;
// Drill on "Goals on track" lists the ON-TRACK goals (the number the cell
// represents). The count ($goalsOnTrack) comes from $stats, which the engine
// derives from the FULL report ($report['goals']) across the strategy AND its
// programs — so the drill list must read the same set, not $goalsGroup (which
// is scoped to the strategy's own goals only and is empty when goals live on
// programs, leaving a "9 on track" cell with an empty list). At-risk goals
// surface in the Needs Attention block.
$allGoalsForDrill = $report['goals']['goals'] ?? ($goalsGroup['goals'] ?? []);
$onTrackAll = array_filter($allGoalsForDrill, fn ($g) => ((array) $g)['status'] === 'status_ontrack' || (is_object($g) && ($g->status ?? '') === 'status_ontrack'));
$onTrackAll = array_values($onTrackAll);
$onTrackItems = array_slice($onTrackAll, 0, 5);
$onTrackMoreCount = max(0, count($onTrackAll) - 5);
// Hours drill = per-project effort breakdown, sorted desc. Project names
// come from $report['summaries'] (keyed by projectId with .name field).
$effortByProj = $report['effort']['byProject'] ?? [];
arsort($effortByProj);
$projNames = [];
foreach (($report['summaries'] ?? []) as $s) {
$s = (object) $s;
$projNames[(int) ($s->id ?? 0)] = (string) ($s->name ?? '');
}
$hoursItems = [];
foreach ($effortByProj as $pid => $h) {
if ($h <= 0) continue;
$hoursItems[] = ['name' => $projNames[(int) $pid] ?? ('#'.$pid), 'hours' => (float) $h];
}
$hoursMoreCount = max(0, count($hoursItems) - 5);
$hoursItems = array_slice($hoursItems, 0, 5);
$fmtDate = fn ($v) => is_object($v) ? $v->setToUserTimezone()->format('M j') : ($v ? date('M j', strtotime((string) $v)) : '');
@endphp
<div class="rd-kpi">
{{-- Completed --}}
<div class="rd-kcell @if ($completedCount > 0) has-detail @endif" tabindex="{{ $completedCount > 0 ? 0 : -1 }}">
@if ($completedCount > 0)
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
@endif
<div class="kv">
{{ $completedCount }}@if ($totalMsCount > 0)<small>/{{ $totalMsCount }}</small>@endif
@if ($completedDelta > 0)
<span class="up" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-up"></i> +{{ $completedDelta }}</span>
@elseif ($completedDelta < 0)
<span class="down" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-down"></i> {{ $completedDelta }}</span>
@endif
</div>
<div class="kl">{{ $totalMsCount > 0 ? __('stakeholder.kpi.milestones_completed') : __('stakeholder.kpi.completed_this_period') }}</div>
@if ($completedCount > 0)
<div class="kdrill">
<div class="kd-hd">{{ __('stakeholder.kpi.drill.completed') }}</div>
<ul>
@foreach ($completedItems as $m)
@php $m = (object) $m; @endphp
<li>
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
<span class="mt">{{ $fmtDate($m->completedOn ?? $m->modified ?? null) }}</span>
</li>
@endforeach
</ul>
@if ($completedMoreCount > 0)
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $completedMoreCount) }}</div>
@endif
</div>
@endif
</div>
{{-- Goals on track drill lists the ON-TRACK goals (what the count is) --}}
<div class="rd-kcell @if (count($onTrackItems) > 0) has-detail @endif" tabindex="{{ count($onTrackItems) > 0 ? 0 : -1 }}">
@if (count($onTrackItems) > 0)
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
@endif
<div class="kv">{{ $goalsOnTrack }}<small>/{{ $goalsTotal }}</small></div>
<div class="kl">{{ __('stakeholder.kpi.goals_on_track_lc') }}</div>
@if (count($onTrackItems) > 0)
<div class="kdrill">
<div class="kd-hd">{{ __('stakeholder.kpi.drill.on_track') }}</div>
<ul>
@foreach ($onTrackItems as $g)
@php $g = (object) $g; @endphp
<li>
<span class="nm" title="{{ $g->title ?? '' }}">{{ $g->title ?? $g->description ?? __('stakeholder.goals.untitled') }}</span>
<span class="mt">{{ round((float) ($g->goalProgress ?? 0)) }}%</span>
</li>
@endforeach
</ul>
@if ($onTrackMoreCount > 0)
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $onTrackMoreCount) }}</div>
@endif
</div>
@endif
</div>
{{-- Overdue milestones --}}
<div class="rd-kcell @if ($overdueCount > 0) risk @endif @if ($overdueCount > 0) has-detail @endif" tabindex="{{ $overdueCount > 0 ? 0 : -1 }}">
@if ($overdueCount > 0)
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
@endif
<div class="kv">{{ $overdueCount }}@if ($openMsCount > 0)<small>/{{ $openMsCount }}</small>@endif</div>
<div class="kl">{{ $openMsCount > 0 ? __('stakeholder.kpi.overdue_of_open') : __('stakeholder.kpi.milestones_overdue') }}</div>
@if ($overdueCount > 0)
<div class="kdrill">
<div class="kd-hd">{{ __('stakeholder.kpi.drill.overdue') }}</div>
<ul>
@foreach ($overdueItems as $m)
@php $m = (object) $m; @endphp
<li>
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
<span class="mt">{{ $m->projectName ?? '' }}</span>
</li>
@endforeach
</ul>
@if ($overdueMoreCount > 0)
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $overdueMoreCount) }}</div>
@endif
</div>
@endif
</div>
{{-- Hours logged drill lists per-project breakdown, largest first --}}
<div class="rd-kcell @if (count($hoursItems) > 0) has-detail @endif" tabindex="{{ count($hoursItems) > 0 ? 0 : -1 }}">
@if (count($hoursItems) > 0)
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
@endif
<div class="kv">{{ number_format($hoursLogged, $hoursLogged >= 100 ? 0 : 1) }}<small>h</small></div>
<div class="kl">{{ __('stakeholder.kpi.hours_this_period') }}</div>
@if (count($hoursItems) > 0)
<div class="kdrill">
<div class="kd-hd">{{ __('stakeholder.kpi.drill.hours') }}</div>
<ul>
@foreach ($hoursItems as $h)
<li>
<span class="nm" title="{{ $h['name'] }}">{{ $h['name'] }}</span>
<span class="mt">{{ number_format($h['hours'], $h['hours'] >= 100 ? 0 : 1) }}h</span>
</li>
@endforeach
</ul>
@if ($hoursMoreCount > 0)
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $hoursMoreCount) }}</div>
@endif
</div>
@endif
</div>
</div>
{{-- ── Hero (peak this period) + Needs attention ─────────────────── --}}
@php
// Needs-attention detail from ReportEngine::buildNeedsAttention. Key names
// verified against the service — the block reads items by NAME, not counts.
$overdueMilestones = $needsAttn['overdueMilestones'] ?? [];
$goalsAtRisk = $needsAttn['goalsAtRisk'] ?? [];
$staleProjects = $needsAttn['staleProjects'] ?? [];
$statusAlerts = $needsAttn['statusAlerts'] ?? [];
$needsCount = count($overdueMilestones) + count($goalsAtRisk) + count($staleProjects) + count($statusAlerts);
// Peak-this-period nomination (recommend-and-override, §5 page 1 hero).
// Rank candidates from completed-this-period milestones:
// 1. Has non-empty outcomeImpact (structural closure carrying its own board narrative)
// 2. Otherwise, most recent completion
// The top pick is the recommendation; the (future) owner override lives here.
$completed = $report['milestones']['completed'] ?? [];
$withImpact = [];
$withoutImpact = [];
foreach ($completed as $m) {
$m = (object) $m;
$impact = trim((string) ($m->outcomeImpact ?? ''));
if ($impact !== '') {
$withImpact[] = $m;
} else {
$withoutImpact[] = $m;
}
}
$sortByCompletion = fn ($a, $b) => strcmp((string) ($b->completedOn ?? $b->modified ?? ''), (string) ($a->completedOn ?? $a->modified ?? ''));
usort($withImpact, $sortByCompletion);
usort($withoutImpact, $sortByCompletion);
$peak = $withImpact[0] ?? $withoutImpact[0] ?? null;
$peakIsStrong = $peak !== null && trim((string) ($peak->outcomeImpact ?? '')) !== '';
if ($peak !== null) {
$peakDate = ! empty($peak->completedOn)
? (is_object($peak->completedOn) ? $peak->completedOn->setToUserTimezone()->format('M j') : date('M j', strtotime((string) $peak->completedOn)))
: (! empty($peak->modified) ? date('M j', strtotime((string) $peak->modified)) : '');
$peakBody = trim((string) ($peak->outcomeImpact ?? $peak->description ?? ''));
// Strip any HTML that survived from a rich-text editor.
$peakBody = trim(strip_tags($peakBody));
}
@endphp
<div class="p1-topband">
{{-- Peak this period recommend-and-override. Ranked from completed
milestones; owner override is a future write path. --}}
@if ($peak === null)
<div class="p1-hero empty">
<div class="eye"><span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span></div>
<div class="h">{{ __('stakeholder.overview.peak_none_title') }}</div>
<div>{{ __('stakeholder.overview.peak_none_hint') }}</div>
@if ($scope === 'strategy')
{{-- The Logic Model is strategy-scoped, so only offer the
"build it" CTA in a strategy report a program report
would link to the wrong (or no) canvas. --}}
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="p1-hero-cta">
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.overview.peak_none_cta') }}
</a>
@endif
</div>
@else
<div class="p1-hero">
<div class="eye">
<span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span>
<span class="rec"><i class="fa fa-wand-magic-sparkles"></i> {{ __('stakeholder.overview.peak_recommended') }}</span>
</div>
<h3>{{ $peak->headline ?? '' }}</h3>
@if ($peakBody !== '')
<p>{{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}</p>
@endif
<div class="hf">
@if (! empty($peak->projectName))
<span class="badge-goal"><i class="fa fa-diagram-project"></i> {{ $peak->projectName }}</span>
@endif
@if (! $peakIsStrong)
<span class="rec-note" title="{{ __('stakeholder.overview.peak_weak_tip') }}"><i class="fa fa-circle-info"></i> {{ __('stakeholder.overview.peak_weak_note') }}</span>
@endif
@if ($peakDate !== '')
<span class="hm">{{ $peakDate }}</span>
@endif
</div>
</div>
@endif
<div class="p1-needs @if ($needsCount === 0) calm @endif">
<div class="nt"><i class="fa @if ($needsCount === 0) fa-check-circle @else fa-triangle-exclamation @endif"></i>{{ __('stakeholder.overview.needs_label') }}</div>
<div class="nx">
@if ($needsCount === 0)
{{ __('stakeholder.overview.nothing_needs_attention') }}
@else
{{-- At-risk goals named. The block loses its meaning as a plain count;
a board wants to know WHICH goal is at risk to decide what to do. --}}
@if (count($goalsAtRisk) > 0)
<div class="na-grp">
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_goals_hd'), count($goalsAtRisk)) }}</div>
<ul class="na-items">
@foreach (array_slice($goalsAtRisk, 0, 3) as $goal)
@php
$goal = (object) $goal;
$isMiss = (string) $goal->status === 'status_miss';
$title = trim((string) ($goal->title ?? $goal->description ?? __('stakeholder.goals.untitled')));
$progress = round((float) ($goal->goalProgress ?? 0));
@endphp
<li>
<span class="na-name">{{ $title }}</span>
<span class="na-badge @if ($isMiss) miss @endif">{{ $isMiss ? __('stakeholder.goals.miss') : __('stakeholder.goals.atrisk') }}</span>
<span class="na-meta">· {{ $progress }}%</span>
</li>
@endforeach
@if (count($goalsAtRisk) > 3)
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($goalsAtRisk) - 3) }}</li>
@endif
</ul>
</div>
@endif
{{-- Overdue milestones named + project. --}}
@if (count($overdueMilestones) > 0)
<div class="na-grp">
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_milestones_hd'), count($overdueMilestones)) }}</div>
<ul class="na-items">
@foreach (array_slice($overdueMilestones, 0, 3) as $m)
@php
$m = (object) $m;
$mname = trim((string) ($m->headline ?? __('stakeholder.overview.na_untitled_milestone')));
$projName = trim((string) ($m->projectName ?? ''));
@endphp
<li>
<span class="na-name">{{ $mname }}</span>
@if ($projName !== '')
<span class="na-meta">· {{ $projName }}</span>
@endif
</li>
@endforeach
@if (count($overdueMilestones) > 3)
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($overdueMilestones) - 3) }}</li>
@endif
</ul>
</div>
@endif
{{-- Stale/silent projects quiet flag, one line per project. --}}
@if (count($staleProjects) > 0)
<div class="na-grp">
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_silent_hd'), count($staleProjects)) }}</div>
<ul class="na-items">
@foreach (array_slice($staleProjects, 0, 3) as $p)
@php $p = (object) $p; @endphp
<li>
<span class="na-name">{{ $p->name ?? '' }}</span>
<span class="na-meta">· {{ __('stakeholder.overview.na_no_update_30d') }}</span>
</li>
@endforeach
@if (count($staleProjects) > 3)
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($staleProjects) - 3) }}</li>
@endif
</ul>
</div>
@endif
@endif
</div>
</div>
</div>
{{-- ── Status narrative authored, verbatim, per-program ──────────
The one place on this page where a human says WHY and what they're
doing about it. Everything else is computed. Renders portfolio note
first (strategy scope) or the program's own note (program scope),
then per-program notes newest-first, cap 4 total. Silent when zero
notes exist never apologizes. --}}
@php
// Assemble a normalized note list with a "portfolio" flag so the
// portfolio note leads and per-program notes follow.
$narrativeNotes = [];
// strategyUpdates is keyed by projectId => array of updates (same shape as
// programUpdates below) — take the newest note of the report subject itself.
foreach (($strategyUpdates ?? []) as $notes) {
$notesArr = is_array($notes) ? $notes : [$notes];
if (empty($notesArr)) {
continue;
}
$newest = $notesArr[0]; // repo returns newest first
$narrativeNotes[] = [
'label' => __('stakeholder.overview.narrative_portfolio'),
'text' => trim(strip_tags((string) ($newest->text ?? ''))),
'date' => (string) ($newest->date ?? ''),
'portfolio' => true,
'sortKey' => (string) ($newest->date ?? ''),
];
}
// programUpdates is keyed by projectId => array of updates. Take the
// newest update per program (already newest-first from the repo).
$programNameById = [];
foreach (($programRows ?? []) as $pr) {
$pid = (int) (is_array($pr) ? ($pr['id'] ?? 0) : ($pr->id ?? 0));
$nm = (string) (is_array($pr) ? ($pr['name'] ?? '') : ($pr->name ?? ''));
if ($pid > 0) $programNameById[$pid] = $nm;
}
foreach (($programUpdates ?? []) as $projectId => $notes) {
$projectId = (int) $projectId;
$notesArr = is_array($notes) ? $notes : [];
if (empty($notesArr)) continue;
$newest = $notesArr[0]; // repo returns newest first
$narrativeNotes[] = [
'label' => $programNameById[$projectId] ?? __('stakeholder.overview.narrative_program_fallback'),
'text' => trim(strip_tags((string) ($newest->text ?? ''))),
'date' => (string) ($newest->date ?? ''),
'portfolio' => false,
'sortKey' => (string) ($newest->date ?? ''),
];
}
// Portfolio always first, then per-program by date desc, cap at 4.
usort($narrativeNotes, function ($a, $b) {
if ($a['portfolio'] !== $b['portfolio']) return $a['portfolio'] ? -1 : 1;
return strcmp($b['sortKey'], $a['sortKey']);
});
$narrativeNotes = array_values(array_filter($narrativeNotes, fn ($n) => $n['text'] !== ''));
$narrativeNotes = array_slice($narrativeNotes, 0, 4);
$fmtNoteDate = function (string $iso) {
if ($iso === '') return '';
try { return (new \DateTimeImmutable(substr($iso, 0, 19)))->format('M j'); }
catch (\Exception $e) { return ''; }
};
@endphp
<div class="p1-narrative">
<div class="lb"><i class="fa fa-message"></i> {{ __('stakeholder.overview.narrative_label') }}</div>
<div class="nn">
@forelse ($narrativeNotes as $n)
<div class="nr @if ($n['portfolio']) portfolio @endif">
<b>{{ $n['label'] }}</b> {{ $n['text'] }}
@if (($d = $fmtNoteDate($n['date'])) !== '')
<span class="dt">{{ $d }}</span>
@endif
</div>
@empty
<div class="nempty">{{ __('stakeholder.overview.narrative_empty') }}</div>
@endforelse
</div>
</div>
{{-- ── Theory of Change narrative (stage-colored) ────────────────── --}}
@if ($hasLM && ! empty($logicModel['narrative']['hasItems']))
@php $stageTexts = $logicModel['narrative']['stageTexts'] ?? []; @endphp
<div class="p1-toc collapsed" id="p1TocSection">
<div class="tl">
<span class="lbl-inner">
{{ __('stakeholder.overview.toc_label') }}
<span class="p1-info" tabindex="0">
<span class="ii" aria-hidden="true">i</span>
<span class="pop" role="tooltip">
<span class="h">{{ __('stakeholder.overview.color_legend') }}</span>
<span class="lg"><span class="sw" style="background:var(--rd-s1)"></span><span class="nm">{{ __('box.logicmodel.inputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.inputs') }}</span></span>
<span class="lg"><span class="sw" style="background:var(--rd-s2)"></span><span class="nm">{{ __('box.logicmodel.activities') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.activities') }}</span></span>
<span class="lg"><span class="sw" style="background:var(--rd-s3)"></span><span class="nm">{{ __('box.logicmodel.outputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outputs') }}</span></span>
<span class="lg"><span class="sw" style="background:var(--rd-s4)"></span><span class="nm">{{ __('box.logicmodel.outcomes') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outcomes') }}</span></span>
<span class="lg"><span class="sw" style="background:var(--rd-s5)"></span><span class="nm">{{ __('box.logicmodel.impact') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.impact') }}</span></span>
</span>
</span>
</span>
<button type="button" class="toc-toggle" onclick="p1TocToggle(this)" aria-label="{{ __('stakeholder.overview.toc_toggle') }}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="tx">
{{ __('stakeholder.overview.toc_by_investing') }}
<span class="n1">{{ $stageTexts['inputs'] ?? '['.__('box.logicmodel.inputs').']' }}</span>
{{ __('stakeholder.overview.toc_and_delivering') }}
<span class="n2">{{ $stageTexts['activities'] ?? '['.__('box.logicmodel.activities').']' }}</span>,
{{ __('stakeholder.overview.toc_we_produce') }}
<span class="n3">{{ $stageTexts['outputs'] ?? '['.__('box.logicmodel.outputs').']' }}</span>
{{ __('stakeholder.overview.toc_toward') }}
<span class="n4">{{ $stageTexts['outcomes'] ?? '['.__('box.logicmodel.outcomes').']' }}</span>,
{{ __('stakeholder.overview.toc_in_service_of') }}
<span class="n5">{{ $stageTexts['impact'] ?? '['.__('box.logicmodel.impact').']' }}</span>.
</div>
</div>
@else
<div class="p1-toc empty">{{ __('stakeholder.overview.toc_empty') }}</div>
@endif
{{-- ── Theory-health strip full state, not just warnings ──────── --}}
@if ($hasLM)
@php
$badges = $logicModel['healthBadges'] ?? [];
// Ensure we render all 4 connector slots even when a row is missing;
// the missing case reads as "no assessment yet" (grey), not "ok".
$bySlot = [];
for ($i = 1; $i <= 4; $i++) {
$b = $badges[$i] ?? null;
$status = $b && ! empty($b['has_data']) ? (string) ($b['health_status'] ?? '') : '';
$bySlot[$i] = [
'status' => $status !== '' ? $status : 'none',
'label' => $b['connector_label'] ?? '',
'assumption' => trim((string) ($b['assumption_text'] ?? '')),
'evidence' => trim((string) ($b['evidence_notes'] ?? '')),
];
}
$counts = ['ok' => 0, 'warning' => 0, 'risk' => 0, 'none' => 0];
$risky = [];
foreach ($bySlot as $slot) {
$counts[$slot['status']]++;
if (in_array($slot['status'], ['warning', 'risk'], true)) {
$risky[] = $slot;
}
}
// Sort by severity so a `risk` link leads the detail callout over any
// `warning` links — critical always takes priority in the board's read.
usort($risky, fn ($a, $b) => ($b['status'] === 'risk' ? 1 : 0) <=> ($a['status'] === 'risk' ? 1 : 0));
$solid = $counts['ok'];
$fragile = $counts['warning'] + $counts['risk'];
$unassessed = $counts['none'];
$tone = $fragile > 0 ? 'risk' : ($unassessed > 0 ? 'partial' : 'solid');
@endphp
<div class="p1-theory">
<div class="hd">
<span class="l">
{{ __('stakeholder.overview.theory_health_label') }}
<span class="p1-info" tabindex="0">
<span class="ii" aria-hidden="true">i</span>
<span class="pop" role="tooltip">
{{ __('stakeholder.overview.theory_health_explain') }}
</span>
</span>
</span>
<span class="rd @if ($fragile > 0) risk @endif">
@if ($fragile === 0 && $unassessed === 0)
<b>{{ sprintf(__('stakeholder.overview.theory_all_solid'), $solid) }}</b>
@elseif ($fragile === 0)
<b>{{ sprintf(__('stakeholder.overview.theory_solid_and_unassessed'), $solid, $unassessed) }}</b>
@else
<b>{{ sprintf(__('stakeholder.overview.theory_summary_mixed'), $solid, $fragile) }}</b>@if ($unassessed > 0) · {{ sprintf(__('stakeholder.overview.theory_plus_unassessed'), $unassessed) }}@endif
@endif
</span>
</div>
{{-- Chain: 5 stage chips + 4 colored connectors between. Each connector
is a line with the health icon as a badge; hover for detail. --}}
@php
$stageNames = [
1 => __('box.logicmodel.inputs'),
2 => __('box.logicmodel.activities'),
3 => __('box.logicmodel.outputs'),
4 => __('box.logicmodel.outcomes'),
5 => __('box.logicmodel.impact'),
];
@endphp
<div class="segs">
@for ($s = 1; $s <= 5; $s++)
<span class="stage s{{ $s }}">{{ $stageNames[$s] }}</span>
@if ($s < 5)
@php $slot = $bySlot[$s]; @endphp
<div class="conn {{ $slot['status'] }}" tabindex="0">
<div class="badge">
{{-- fa-circle-* family, matching the LM canvas's status pills
(Logicmodelcanvas::STATUS_LABELS uses the same set). --}}
<i class="fa
@if ($slot['status'] === 'ok') fa-circle-check
@elseif ($slot['status'] === 'warning') fa-circle-exclamation
@elseif ($slot['status'] === 'risk') fa-triangle-exclamation
@else fa-circle-question
@endif"></i>
</div>
@if ($slot['assumption'] !== '' || $slot['evidence'] !== '')
<div class="tip">
<b>{{ $slot['label'] !== '' ? $slot['label'] : 'Link '.$s }}</b>
@if ($slot['assumption'] !== '')
<em>{{ $slot['assumption'] }}</em>
@endif
@if ($slot['evidence'] !== '')
<div style="margin-top:6px;"><b>{{ __('stakeholder.overview.theory_evidence') }}</b>{{ $slot['evidence'] }}</div>
@elseif ($slot['status'] !== 'ok')
<div style="margin-top:6px;opacity:.85;">{{ __('stakeholder.overview.theory_no_evidence_short') }}</div>
@endif
</div>
@endif
</div>
@endif
@endfor
</div>
{{-- Fragile-link detail. Only if there's something to flag. --}}
@if ($fragile > 0)
@php $first = $risky[0]; @endphp
@php $others = array_slice($risky, 1); $otherCount = count($others); @endphp
<div class="detail @if ($first['status'] === 'risk') crit @endif">
<i class="fa @if ($first['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
<div>
<b>{{ $first['label'] }}</b> {{ $first['status'] === 'risk' ? __('stakeholder.overview.theory_is_critical') : __('stakeholder.overview.theory_needs_work') }}
@if ($first['assumption'] !== '')
{{ __('stakeholder.overview.theory_it_rests_on') }} <em>{{ $first['assumption'] }}</em>
@endif
@if ($first['evidence'] === '')
<span class="p1-info detail-info evidence-tag" tabindex="0">
<i class="fa fa-circle-exclamation"></i>
<span class="tag">{{ __('stakeholder.overview.theory_unproven') }}</span>
<span class="pop" role="tooltip">{{ __('stakeholder.overview.theory_unproven_explain') }}</span>
</span>
@endif
</div>
</div>
{{-- Secondary fragile links as compact "Also fragile" chip row
visible (not hidden in a tooltip) so a board sees the full
fragile set at once. --}}
@if ($otherCount > 0)
<div class="also-fragile">
<span class="lbl">{{ __('stakeholder.overview.theory_also_fragile') }}</span>
@foreach ($others as $o)
<span class="af-chip {{ $o['status'] }}" title="{{ $o['assumption'] }}">
<i class="fa @if ($o['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
<span class="nm">{{ $o['label'] }}</span>
</span>
@endforeach
</div>
@endif
@else
<div class="calm-line">
<i class="fa fa-check-circle"></i>
{{ __('stakeholder.overview.theory_all_ok') }}
</div>
@endif
</div>
@endif
<script>
/* Theory of Change collapse — click chevron to toggle the narrative. Persists
the state in localStorage so it survives page reloads. */
(function () {
if (window.__p1TocInit) return;
window.__p1TocInit = true;
// Default state is COLLAPSED (rendered server-side with .collapsed). Only
// remove it if the user has previously explicitly expanded it.
var KEY = 'rd.p1.toc.collapsed';
var section = document.getElementById('p1TocSection');
if (section && localStorage.getItem(KEY) === '0') {
section.classList.remove('collapsed');
}
window.p1TocToggle = function (btn) {
var s = btn.closest('.p1-toc');
if (!s) return;
var isNowCollapsed = s.classList.toggle('collapsed');
try { localStorage.setItem(KEY, isNowCollapsed ? '1' : '0'); } catch (e) {}
};
})();
</script>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
<?php
namespace Leantime\Domain\Reports;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Events\EventDispatcher;
EventDispatcher::add_event_listener('leantime.core.console.consolekernel.schedule.cron', function ($params) {
if (get_class($scheduler = $params['schedule']) !== Schedule::class) {
return;
}
/** @var Services\Reports $reportService */
$reportService = app()->make(Services\Reports::class);
$scheduler->call(function () use ($reportService) {
$telemetry = $reportService->sendAnonymousTelemetry();
if ($telemetry === false) {
return;
}
try {
$response = $telemetry->wait();
} catch (\Throwable $e) {
Log::error($e);
}
})->name('reports:telemetry')->daily();
$scheduler->call(function () use ($reportService) {
$reportService->cronDailyIngestion();
})->name('reports:dailyIngestion')->daily();
// Safety net behind the goal-repository write hooks: records value changes that bypassed
// the repository so KPI trend history stays complete.
$scheduler->call(function () {
try {
app()->make(\Leantime\Domain\Goalcanvas\Repositories\Goalcanvas::class)->snapshotGoalValues();
} catch (\Throwable $e) {
Log::error($e);
}
})->name('reports:goalValueSnapshot')->daily();
});