OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
69
app/Domain/Reports/Controllers/Project.php
Normal file
69
app/Domain/Reports/Controllers/Project.php
Normal 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');
|
||||
}
|
||||
}
|
||||
97
app/Domain/Reports/Controllers/Show.php
Normal file
97
app/Domain/Reports/Controllers/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
42
app/Domain/Reports/Hxcontrollers/Outcome.php
Normal file
42
app/Domain/Reports/Hxcontrollers/Outcome.php
Normal 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');
|
||||
}
|
||||
}
|
||||
39
app/Domain/Reports/Hxcontrollers/ProjectReport.php
Normal file
39
app/Domain/Reports/Hxcontrollers/ProjectReport.php
Normal 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));
|
||||
}
|
||||
}
|
||||
204
app/Domain/Reports/Models/ReportPeriod.php
Normal file
204
app/Domain/Reports/Models/ReportPeriod.php
Normal 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');
|
||||
}
|
||||
}
|
||||
66
app/Domain/Reports/Models/Reports.php
Normal file
66
app/Domain/Reports/Models/Reports.php
Normal 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() {}
|
||||
}
|
||||
38
app/Domain/Reports/Permissions/ReportsPermissions.php
Normal file
38
app/Domain/Reports/Permissions/ReportsPermissions.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
361
app/Domain/Reports/Repositories/ReportEngine.php
Normal file
361
app/Domain/Reports/Repositories/ReportEngine.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
301
app/Domain/Reports/Repositories/Reports.php
Normal file
301
app/Domain/Reports/Repositories/Reports.php
Normal 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();
|
||||
}
|
||||
}
|
||||
483
app/Domain/Reports/Services/CapacityAnalyzer.php
Normal file
483
app/Domain/Reports/Services/CapacityAnalyzer.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
737
app/Domain/Reports/Services/ReportEngine.php
Normal file
737
app/Domain/Reports/Services/ReportEngine.php
Normal 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).'…';
|
||||
}
|
||||
}
|
||||
594
app/Domain/Reports/Services/Reports.php
Normal file
594
app/Domain/Reports/Services/Reports.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{--
|
||||
Commitment integrity strip: milestones whose due date was pushed out of the period, and
|
||||
milestones added mid-period. Kept compact — it's an honesty note, not a section.
|
||||
|
||||
Expects:
|
||||
$slippage: array{pushedOut: object[], addedMidPeriod: object[]}
|
||||
$showProjects: bool
|
||||
--}}
|
||||
@php
|
||||
$showProjects = $showProjects ?? false;
|
||||
@endphp
|
||||
|
||||
@if (!empty($slippage['pushedOut']) || !empty($slippage['addedMidPeriod']))
|
||||
<div class="reportSlippage">
|
||||
<strong class="tw-block tw-mb-1"><i class="fa fa-fw fa-arrows-left-right tw-opacity-60"></i> {{ __('subtitles.changed_this_period') }}</strong>
|
||||
|
||||
@foreach ($slippage['pushedOut'] as $milestone)
|
||||
<div>
|
||||
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
|
||||
@if ($showProjects)<span class="tw-opacity-70">({{ $tpl->escape($milestone->projectName) }})</span>@endif
|
||||
{{ sprintf(__('text.slippage_moved_out'), $milestone->dueDateMoves, $milestone->dueDate?->formatDateForUser() ?? '—') }}
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@foreach ($slippage['addedMidPeriod'] as $milestone)
|
||||
<div>
|
||||
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
|
||||
@if ($showProjects)<span class="tw-opacity-70">({{ $tpl->escape($milestone->projectName) }})</span>@endif
|
||||
{{ __('text.slippage_added_mid_period') }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
80
app/Domain/Reports/Templates/partials/goalTable.blade.php
Normal file
80
app/Domain/Reports/Templates/partials/goalTable.blade.php
Normal file
@@ -0,0 +1,80 @@
|
||||
{{--
|
||||
Goals & KPIs table: status dot, metric ("18 of 40 graduates"), progress bar.
|
||||
The linked-milestone column only renders when at least one goal links a milestone.
|
||||
|
||||
Expects:
|
||||
$goals: object[] - engine-enriched goal rows (goalProgress resolved incl. roll-ups)
|
||||
$showProjects: bool
|
||||
$emptyText: string
|
||||
--}}
|
||||
@php
|
||||
$showProjects = $showProjects ?? false;
|
||||
$goalStatusColors = [
|
||||
'status_ontrack' => 'var(--green)',
|
||||
'status_atrisk' => 'var(--yellow)',
|
||||
'status_miss' => 'var(--red)',
|
||||
];
|
||||
$hasMilestoneLinks = false;
|
||||
foreach ($goals as $goalRow) {
|
||||
if (!empty($goalRow->milestoneHeadline)) {
|
||||
$hasMilestoneLinks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$fmt = fn ($n) => \Illuminate\Support\Number::format((float) $n, maxPrecision: 1);
|
||||
@endphp
|
||||
|
||||
@if (count($goals) === 0)
|
||||
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
|
||||
@else
|
||||
<table class="reportTable reportGoalTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('label.goal') }}</th>
|
||||
@if ($showProjects)<th>{{ __('label.project') }}</th>@endif
|
||||
<th class="numCol">{{ __('label.metric') }}</th>
|
||||
<th style="width: 28%;">{{ __('label.progress') }}</th>
|
||||
@if ($hasMilestoneLinks)<th>{{ __('label.linked_milestone') }}</th>@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($goals as $goal)
|
||||
<tr>
|
||||
<td>
|
||||
<span class="statusDot" style="background:{{ $goalStatusColors[$goal->status] ?? 'var(--grey)' }};"></span>
|
||||
<strong>{{ $tpl->escape($goal->title) }}</strong>
|
||||
@if ($goal->setting === 'linkAndReport')
|
||||
<span class="cellNote"><i class="fa fa-sitemap tw-opacity-60"></i>
|
||||
@if (!empty($goal->childGoalCount))
|
||||
{{ sprintf(__('text.fed_by_n_goals'), $goal->childGoalCount) }}
|
||||
@else
|
||||
{{ __('text.goal_rollup_tooltip') }}
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
@if ($showProjects)
|
||||
<td class="tw-opacity-70">{{ $tpl->escape($goal->boardProjectName ?? $goal->projectName ?? '') }}</td>
|
||||
@endif
|
||||
<td class="numCol">
|
||||
<strong>{{ $fmt($goal->currentValue) }}</strong> <span class="tw-opacity-60">of {{ $fmt($goal->endValue) }} {{ $tpl->escape($goal->metricType ?? '') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="progress tw-flex-1 tw-m-0" style="height: 6px;">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar"
|
||||
aria-valuenow="{{ round($goal->goalProgress) }}" aria-valuemin="0" aria-valuemax="100"
|
||||
style="width: {{ round($goal->goalProgress) }}%">
|
||||
</div>
|
||||
</div>
|
||||
<span class="tw-text-sm tw-opacity-70" style="font-variant-numeric: tabular-nums;">{{ round($goal->goalProgress) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
@if ($hasMilestoneLinks)
|
||||
<td class="tw-opacity-70">{{ $tpl->escape($goal->milestoneHeadline ?? '') }}</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
101
app/Domain/Reports/Templates/partials/milestoneList.blade.php
Normal file
101
app/Domain/Reports/Templates/partials/milestoneList.blade.php
Normal file
@@ -0,0 +1,101 @@
|
||||
{{--
|
||||
Milestone list used by all report screens in three modes:
|
||||
- completed: completion date + outcome narrative (inline-capturable) + key-task drill-down
|
||||
- inflight: progress bar + due date (overdue rows passed in first by the engine)
|
||||
- upcoming: schedule only
|
||||
|
||||
Expects:
|
||||
$milestones: object[] - engine-enriched milestone rows
|
||||
$mode: string - completed|inflight|upcoming
|
||||
$showProjects: bool - show project names next to milestones (rollup screens)
|
||||
$showTasks: bool - show the key-task drill-down on completed milestones (default true)
|
||||
$allowOutcomeEdit: bool - enable inline outcome capture (project-level screen)
|
||||
$effortByMilestone: array<int, float> - hours logged per milestone in the period
|
||||
$period: \Leantime\Domain\Reports\Models\ReportPeriod
|
||||
$emptyText: string - shown when the list is empty
|
||||
--}}
|
||||
@php
|
||||
$showProjects = $showProjects ?? false;
|
||||
$showTasks = $showTasks ?? true;
|
||||
$allowOutcomeEdit = $allowOutcomeEdit ?? false;
|
||||
$effortByMilestone = $effortByMilestone ?? [];
|
||||
@endphp
|
||||
|
||||
@if (count($milestones) === 0)
|
||||
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
|
||||
@else
|
||||
<ul class="reportMilestoneList">
|
||||
@foreach ($milestones as $milestone)
|
||||
<li class="reportMilestone" style="border-left: 3px solid {{ $milestone->tags }};">
|
||||
|
||||
<div class="milestoneTitleRow">
|
||||
@if ($mode === 'completed')
|
||||
<i class="fa fa-check-circle" style="color: var(--green);"></i>
|
||||
@endif
|
||||
<strong>
|
||||
<a href="{{ BASE_URL }}/tickets/editMilestone/{{ $milestone->id }}" class="milestoneModal hideLinkOnPrint">{{ $tpl->escape($milestone->headline) }}</a>
|
||||
</strong>
|
||||
@if ($showProjects)
|
||||
<span class="milestoneProject">{{ $tpl->escape($milestone->projectName) }}</span>
|
||||
@endif
|
||||
|
||||
<span class="milestoneMeta">
|
||||
@if ($mode === 'completed')
|
||||
{{ __('label.completed_on') }} {{ $milestone->completedOn?->formatDateForUser() ?? '—' }}
|
||||
@elseif ($mode === 'upcoming')
|
||||
{{ $milestone->startDate?->formatDateForUser() }} – {{ $milestone->dueDate?->formatDateForUser() ?? '—' }}
|
||||
@else
|
||||
@php $isOverdue = $milestone->dueDate !== null && $milestone->dueDate->isPast(); @endphp
|
||||
<span @if ($isOverdue) style="color: var(--red); font-weight: 600;" @endif>
|
||||
{{ __('label.due') }} {{ $milestone->dueDate?->formatDateForUser() ?? __('text.no_date_defined') }}
|
||||
</span>
|
||||
@endif
|
||||
@if (!empty($effortByMilestone[$milestone->id]))
|
||||
· {{ \Illuminate\Support\Number::format($effortByMilestone[$milestone->id], maxPrecision: 1) }} {{ __('label.hours_short') }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($mode === 'completed')
|
||||
@include('reports::partials.outcome', ['milestone' => $milestone, 'canEdit' => $allowOutcomeEdit])
|
||||
|
||||
@if ($showTasks && !empty($milestone->keyTasks))
|
||||
<details class="reportKeyTasks">
|
||||
<summary>
|
||||
{{ sprintf(__('text.tasks_done_of_total'), $milestone->taskStats['done'], $milestone->taskStats['total']) }}
|
||||
</summary>
|
||||
<ul class="tw-list-none tw-pl-5 tw-pt-1 tw-m-0 tw-text-sm tw-opacity-80">
|
||||
@foreach ($milestone->keyTasks as $task)
|
||||
<li>
|
||||
<i class="fa fa-fw {{ $task->isDone ? 'fa-check tw-opacity-60' : 'fa-circle-o' }}"></i>
|
||||
{{ $tpl->escape($task->headline) }}
|
||||
</li>
|
||||
@endforeach
|
||||
@if ($milestone->taskStats['total'] > count($milestone->keyTasks))
|
||||
<li class="tw-opacity-60">{{ sprintf(__('text.and_n_more'), $milestone->taskStats['total'] - count($milestone->keyTasks)) }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($mode === 'inflight')
|
||||
<div class="tw-flex tw-items-center tw-gap-3 tw-mt-2">
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar"
|
||||
aria-valuenow="{{ round($milestone->percentDone) }}" aria-valuemin="0" aria-valuemax="100"
|
||||
style="width: {{ round($milestone->percentDone) }}%">
|
||||
</div>
|
||||
</div>
|
||||
<span class="tw-text-sm tw-opacity-70 tw-whitespace-nowrap" style="font-variant-numeric: tabular-nums;">{{ round($milestone->percentDone) }}%
|
||||
@if ($milestone->taskStats['total'] > 0)
|
||||
· {{ sprintf(__('text.tasks_done_of_total'), $milestone->taskStats['done'], $milestone->taskStats['total']) }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@@ -0,0 +1,64 @@
|
||||
{{--
|
||||
"Needs attention" block: red/yellow projects, silent projects, overdue milestones and
|
||||
at-risk goals. Rendered first on every report screen; hidden entirely when all is well.
|
||||
|
||||
Expects:
|
||||
$needsAttention: array{statusAlerts: object[], staleProjects: object[], overdueMilestones: object[], goalsAtRisk: object[]}
|
||||
$showProjects: bool - prefix items with their project name (rollup screens)
|
||||
--}}
|
||||
@php
|
||||
$hasAttentionItems = !empty($needsAttention['statusAlerts'])
|
||||
|| !empty($needsAttention['staleProjects'])
|
||||
|| !empty($needsAttention['overdueMilestones'])
|
||||
|| !empty($needsAttention['goalsAtRisk']);
|
||||
$showProjects = $showProjects ?? false;
|
||||
@endphp
|
||||
|
||||
@if ($hasAttentionItems)
|
||||
<div class="reportNeedsAttention">
|
||||
<h5 class="subtitle"><i class="fa fa-triangle-exclamation" style="color: var(--red);"></i> {{ __('subtitles.needs_attention') }}</h5>
|
||||
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@foreach ($needsAttention['statusAlerts'] as $project)
|
||||
<li>
|
||||
<span class="statusDot" style="background:var(--{{ $project->latestStatus === 'red' ? 'red' : 'yellow' }});"></span>
|
||||
<strong>{{ $tpl->escape($project->name) }}</strong>
|
||||
{{ __('text.attention_reported_status') }}
|
||||
@if (!empty($project->latestStatusText))
|
||||
— <span class="tw-opacity-80">{{ $tpl->escape($project->latestStatusText) }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@foreach ($needsAttention['overdueMilestones'] as $milestone)
|
||||
<li>
|
||||
<i class="fa fa-fw fa-clock" style="color: var(--red);"></i>
|
||||
<strong>{{ $tpl->escape($milestone->headline) }}</strong>
|
||||
@if ($showProjects)<span class="tw-opacity-60">({{ $tpl->escape($milestone->projectName) }})</span>@endif
|
||||
{{ __('text.attention_overdue_since') }} {{ $milestone->dueDate?->formatDateForUser() }}
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@foreach ($needsAttention['goalsAtRisk'] as $goal)
|
||||
<li>
|
||||
<i class="fa fa-fw fa-bullseye" style="color: var(--yellow);"></i>
|
||||
<strong>{{ $tpl->escape($goal->title) }}</strong>
|
||||
{{ $goal->status === 'status_miss' ? __('text.attention_goal_missed') : __('text.attention_goal_at_risk') }}
|
||||
<span class="tw-opacity-60">({{ \Illuminate\Support\Number::format((float) $goal->currentValue, maxPrecision: 1) }} of {{ \Illuminate\Support\Number::format((float) $goal->endValue, maxPrecision: 1) }} {{ $tpl->escape($goal->metricType ?? '') }})</span>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@foreach ($needsAttention['staleProjects'] as $project)
|
||||
<li>
|
||||
<i class="fa fa-fw fa-comment-slash tw-opacity-60"></i>
|
||||
<strong>{{ $tpl->escape($project->name) }}</strong>
|
||||
@if (!empty($project->latestStatusDate))
|
||||
{{ sprintf(__('text.attention_no_update_since'), $project->latestStatusDate->formatDateForUser()) }}
|
||||
@else
|
||||
{{ __('text.attention_never_updated') }}
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
46
app/Domain/Reports/Templates/partials/outcome.blade.php
Normal file
46
app/Domain/Reports/Templates/partials/outcome.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
{{--
|
||||
Outcome & impact block of a completed milestone: shows the narrative when present, offers
|
||||
inline capture right on the report when missing. The save posts via HTMX and this partial
|
||||
re-renders in place.
|
||||
|
||||
Expects:
|
||||
$milestone: object - id, outcomeImpact
|
||||
$canEdit: bool - show the inline add/edit affordance
|
||||
--}}
|
||||
<div class="milestoneOutcome" id="milestoneOutcome-{{ $milestone->id }}">
|
||||
|
||||
@if (!empty($milestone->outcomeImpact))
|
||||
<div class="tw-text-sm outcomeText">
|
||||
{{ $milestone->outcomeImpact }}
|
||||
@if ($canEdit)
|
||||
<a href="javascript:void(0)" class="tw-opacity-50 hideOnPrint"
|
||||
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').show();">
|
||||
<i class="fa fa-pencil"></i>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($canEdit)
|
||||
<div class="outcomeText hideOnPrint">
|
||||
<a href="javascript:void(0)" class="tw-text-sm tw-opacity-60"
|
||||
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').show();">
|
||||
<i class="fa fa-plus-circle"></i> {{ __('links.add_outcome_impact') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($canEdit)
|
||||
<form class="outcomeForm tw-mt-1 hideOnPrint" style="display:none;"
|
||||
hx-post="{{ BASE_URL }}/hx/reports/outcome/save"
|
||||
hx-target="#milestoneOutcome-{{ $milestone->id }}"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="milestoneId" value="{{ $milestone->id }}" />
|
||||
<textarea name="outcomeImpact" rows="2" class="tw-w-full tw-text-sm"
|
||||
placeholder="{{ __('input.placeholders.outcome_impact') }}">{{ $milestone->outcomeImpact }}</textarea>
|
||||
<button type="submit" class="btn btn-primary btn-xs">{{ __('buttons.save') }}</button>
|
||||
<a href="javascript:void(0)" class="btn btn-xs"
|
||||
onclick="jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeForm').hide(); jQuery('#milestoneOutcome-{{ $milestone->id }} .outcomeText').show();">
|
||||
{{ __('buttons.cancel') }}
|
||||
</a>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
{{--
|
||||
Project status report body — swapped by the period picker via HTMX.
|
||||
|
||||
Expects:
|
||||
$report: array - ReportEngine::buildReport() output for [$projectId]
|
||||
$period: \Leantime\Domain\Reports\Models\ReportPeriod
|
||||
$projectId: int
|
||||
--}}
|
||||
@php
|
||||
$summary = $report['summaries'][$projectId] ?? null;
|
||||
$stats = $report['stats'];
|
||||
$deltas = $report['deltas'];
|
||||
|
||||
$inFlightMilestones = array_merge($report['milestones']['overdue'], $report['milestones']['inProgress']);
|
||||
$fmt = fn ($n) => \Illuminate\Support\Number::format((float) $n, maxPrecision: 1);
|
||||
@endphp
|
||||
|
||||
<div id="reportBody">
|
||||
|
||||
{{-- Header band: status, progress, timeline --}}
|
||||
@if ($summary !== null)
|
||||
<div class="reportHeaderBand">
|
||||
@include('reports::partials.statusPill', ['status' => $summary->latestStatus, 'date' => $summary->latestStatusDate])
|
||||
<span>
|
||||
<strong>{{ round($summary->progress['percent'] ?? 0) }}%</strong> {{ __('label.report_complete') }}
|
||||
@php $completionState = $summary->progress['estimatedCompletionState'] ?? 'ready'; @endphp
|
||||
@if ($completionState === 'needs_more_data')
|
||||
· <a href="{{ BASE_URL }}/tickets/showAll" class="btn btn-primary"><i class="fa fa-thumb-tack"></i> {{ __('label.complete_more_todos') }}</a>
|
||||
@elseif ($completionState === 'complete')
|
||||
· <a href="{{ BASE_URL }}/projects/showAll" class="btn btn-primary"><i class="fa fa-suitcase"></i> {{ __('label.project_complete_onto_next') }}</a>
|
||||
@elseif (!empty($summary->progress['estimatedCompletionDate']) && $summary->progress['estimatedCompletionDate'] !== false)
|
||||
· {{ __('label.estimated_completion') }} {{ $summary->progress['estimatedCompletionDate'] }}
|
||||
@endif
|
||||
</span>
|
||||
<span class="tw-opacity-70">{{ $period->label() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('reports::partials.statTiles', ['tiles' => [
|
||||
['label' => __('label.milestones_completed'), 'value' => $stats['completed'], 'delta' => ['value' => $deltas['completedDelta'], 'goodWhenUp' => true, 'vs' => __('label.vs_prior_period_short')]],
|
||||
['label' => __('label.milestones_in_flight'), 'value' => $stats['inFlight']],
|
||||
['label' => __('label.milestones_overdue'), 'value' => $stats['overdue'], 'tone' => 'danger'],
|
||||
['label' => __('label.hours_logged'), 'value' => $fmt($stats['hoursLogged']), 'delta' => ['value' => $deltas['hoursDelta'], 'goodWhenUp' => null, 'vs' => __('label.vs_prior_period_short')]],
|
||||
]])
|
||||
|
||||
@include('reports::partials.needsAttention', ['needsAttention' => $report['needsAttention'], 'showProjects' => false])
|
||||
|
||||
<div class="reportSection">
|
||||
<h5 class="subtitle">{{ __('subtitles.accomplished_this_period') }} <span class="sectionCount">{{ count($report['milestones']['completed']) }}</span></h5>
|
||||
@include('reports::partials.milestoneList', [
|
||||
'milestones' => $report['milestones']['completed'],
|
||||
'mode' => 'completed',
|
||||
'allowOutcomeEdit' => true,
|
||||
'effortByMilestone' => $report['effort']['byMilestone'],
|
||||
'period' => $period,
|
||||
'emptyText' => __('text.report_no_completed_milestones'),
|
||||
])
|
||||
|
||||
@include('reports::partials.changedThisPeriod', ['slippage' => $report['milestones']['slippage'], 'showProjects' => false])
|
||||
</div>
|
||||
|
||||
<div class="reportSection">
|
||||
<h5 class="subtitle">{{ __('subtitles.in_flight') }} <span class="sectionCount">{{ count($inFlightMilestones) }}</span></h5>
|
||||
@include('reports::partials.milestoneList', [
|
||||
'milestones' => $inFlightMilestones,
|
||||
'mode' => 'inflight',
|
||||
'effortByMilestone' => $report['effort']['byMilestone'],
|
||||
'period' => $period,
|
||||
'emptyText' => __('text.report_no_inflight_milestones'),
|
||||
])
|
||||
</div>
|
||||
|
||||
<div class="reportSection">
|
||||
<h5 class="subtitle">{{ __('subtitles.coming_up') }}</h5>
|
||||
@if (count($report['milestones']['upcomingByQuarter']) === 0)
|
||||
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ __('text.report_no_upcoming_milestones') }}</div>
|
||||
@else
|
||||
@foreach ($report['milestones']['upcomingByQuarter'] as $quarterLabel => $quarterMilestones)
|
||||
<h6 class="tw-font-bold tw-opacity-70 tw-mt-3 tw-mb-1">{{ $quarterLabel }}</h6>
|
||||
@include('reports::partials.milestoneList', [
|
||||
'milestones' => $quarterMilestones,
|
||||
'mode' => 'upcoming',
|
||||
'period' => $period,
|
||||
'emptyText' => '',
|
||||
])
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="reportSection">
|
||||
<h5 class="subtitle">{{ __('subtitles.goals_kpis') }}</h5>
|
||||
@include('reports::partials.goalTable', [
|
||||
'goals' => $report['goals']['goals'],
|
||||
'emptyText' => __('text.report_no_goals'),
|
||||
])
|
||||
</div>
|
||||
|
||||
<div class="reportSection">
|
||||
<h5 class="subtitle">{{ __('subtitles.status_narrative') }}</h5>
|
||||
@include('reports::partials.statusNarrative', [
|
||||
'updatesByProject' => $report['statusUpdates'],
|
||||
'summaries' => $report['summaries'],
|
||||
'showProjects' => false,
|
||||
'emptyText' => __('text.report_no_status_updates'),
|
||||
])
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
{{--
|
||||
Stakeholder Report — page-header three-dot menu.
|
||||
|
||||
Verdict override (green / yellow / red / revert) + Print. Called from both
|
||||
the strategy and program report templates; POST target changes based on
|
||||
$scope.
|
||||
|
||||
Vars in:
|
||||
$scope 'strategy' | 'program'
|
||||
$projectId int — strategy or program id
|
||||
$verdictOverride null | 'green' | 'yellow' | 'red' (drives Revert visibility)
|
||||
--}}
|
||||
@php
|
||||
$hxBase = BASE_URL.'/hx/'.($scope === 'strategy' ? 'strategyPro' : 'pgmPro').'/report/setVerdict';
|
||||
@endphp
|
||||
|
||||
|
||||
<span class="dropdown dropdownWrapper headerEditDropdown hideOnPrint">
|
||||
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown" aria-label="{{ __('stakeholder.header.actions') }}"><i class="fa-solid fa-ellipsis-v"></i></a>
|
||||
<ul class="dropdown-menu editCanvasDropdown rd-actions-menu">
|
||||
<li class="dropdown-header">{{ __('stakeholder.verdict.set_label') }}</li>
|
||||
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"green","projectId":{{ (int) $projectId }}}' hx-swap="none">
|
||||
<i class="fa fa-circle-check" style="color:#3E937A;"></i> {{ __('stakeholder.verdict.ontrack') }}
|
||||
</a></li>
|
||||
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"yellow","projectId":{{ (int) $projectId }}}' hx-swap="none">
|
||||
<i class="fa fa-circle-exclamation" style="color:#C09035;"></i> {{ __('stakeholder.verdict.atrisk') }}
|
||||
</a></li>
|
||||
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"red","projectId":{{ (int) $projectId }}}' hx-swap="none">
|
||||
<i class="fa fa-triangle-exclamation" style="color:#C2295B;"></i> {{ __('stakeholder.verdict.off') }}
|
||||
</a></li>
|
||||
@if (($verdictOverride ?? null) !== null)
|
||||
<li><a href="javascript:void(0)" hx-post="{{ $hxBase }}" hx-vals='{"verdict":"revert","projectId":{{ (int) $projectId }}}' hx-swap="none">
|
||||
<i class="fa fa-arrow-rotate-left"></i> {{ __('stakeholder.verdict.revert') }}
|
||||
</a></li>
|
||||
@endif
|
||||
<li class="border"></li>
|
||||
<li><a href="javascript:window.print();"><i class="fa fa-print"></i> {{ __('label.print_report') }}</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
@@ -0,0 +1,192 @@
|
||||
{{--
|
||||
Renders a single capacity vs. demand card. Used at two levels:
|
||||
- top-level: one card per program (strategy scope) or per project (program scope)
|
||||
- nested: inside an expanded program card, one compact card per child project
|
||||
|
||||
Vars in:
|
||||
$c The capacity analysis row (project or program-rolled).
|
||||
$verdictLabels Map of verdict key → translated label.
|
||||
$compactOnly When true, always render the one-liner regardless of verdict.
|
||||
--}}
|
||||
|
||||
@php
|
||||
$compactOnly = $compactOnly ?? false;
|
||||
$vLabel = $verdictLabels[$c['verdict']] ?? $c['verdict'];
|
||||
$showFull = ! $compactOnly && in_array($c['verdict'], ['critical', 'tight', 'no_capacity'], true);
|
||||
@endphp
|
||||
|
||||
@if ($showFull)
|
||||
@php
|
||||
// Balance bar geometry — carry the same reading the text states.
|
||||
// Fill 0 → available in the "supply" (green) segment.
|
||||
// Then a distinct "deficit" segment from available → needed, in
|
||||
// the verdict color. Marker sits AT the available position so a
|
||||
// reader instantly sees "we have this much, we need this much".
|
||||
$barMax = max($c['availableHours'], $c['referenceDemand'], 1);
|
||||
$availableMark = min(100, ($c['availableHours'] / $barMax) * 100);
|
||||
$demandWidth = min(100, ($c['referenceDemand'] / $barMax) * 100);
|
||||
$deficitWidth = max(0, $demandWidth - $availableMark);
|
||||
$gapHrs = abs($c['gap']);
|
||||
$gapPct = $c['availableHours'] > 0 ? abs($c['gap'] / $c['availableHours']) * 100 : 0;
|
||||
$isShort = $c['gap'] > 0;
|
||||
|
||||
// Run-rate framing. Supply is a current weekly rate; projecting it over
|
||||
// a (past) period to a total is the fiction we avoid. Demand is a real
|
||||
// total of estimated work — expressed here as the weekly rate needed to
|
||||
// clear it within the period. The gap % (verdict) is unchanged: rate
|
||||
// ratios equal total ratios, so the bar geometry above still holds.
|
||||
$weeks = max(1, (int) $c['weeksInWindow']);
|
||||
$supplyPerWk = $c['weeklyHoursToProject'];
|
||||
$demandPerWk = $c['referenceDemand'] / $weeks;
|
||||
$gapPerWk = $gapHrs / $weeks;
|
||||
@endphp
|
||||
<div class="p3-cap {{ $c['verdict'] }}">
|
||||
<div class="p3-cap-hd">
|
||||
<span class="verdict {{ $c['verdict'] }}">
|
||||
<i class="fa fa-{{ $c['verdict'] === 'critical' ? 'triangle-exclamation' : ($c['verdict'] === 'no_capacity' ? 'ban' : 'circle-exclamation') }}"></i>
|
||||
{{ $vLabel }}
|
||||
</span>
|
||||
<div class="name">{{ $c['name'] }}</div>
|
||||
|
||||
@if ($c['trustSignal'] === 'budgeted' && $c['effortHours'] > 0)
|
||||
<span class="trust good" data-tippy-content="{{ __('stakeholder.rc.cap.trust_budgeted') }}">
|
||||
<i class="fa fa-check"></i>{{ __('stakeholder.rc.cap.trust_high') }}
|
||||
</span>
|
||||
@elseif ($c['trustSignal'] === 'effort')
|
||||
<span class="trust warn" data-tippy-content="{{ __('stakeholder.rc.cap.trust_effort') }}">
|
||||
<i class="fa fa-triangle-exclamation"></i>{{ __('stakeholder.rc.cap.trust_effort_short') }}
|
||||
</span>
|
||||
@elseif ($c['trustSignal'] === 'mixed')
|
||||
<span class="trust warn" data-tippy-content="{{ sprintf(__('stakeholder.rc.cap.trust_mixed'), (int) ($c['divergence'] * 100)) }}">
|
||||
<i class="fa fa-triangle-exclamation"></i>{{ __('stakeholder.rc.cap.trust_mixed_short') }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($c['referenceDemand'] > 0 && $c['availableHours'] > 0)
|
||||
<div class="headline-num {{ $c['verdict'] }}">
|
||||
@if ($isShort)
|
||||
<span class="unit-h" data-hours="{{ round($gapPerWk) }}">{{ round($gapPerWk) }}h</span>/wk {{ sprintf(__('stakeholder.rc.cap.short_suffix'), (int) $gapPct) }}
|
||||
@else
|
||||
<span class="unit-h" data-hours="{{ round($gapPerWk) }}">{{ round($gapPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.buffer_suffix') }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="p3-cap-body">
|
||||
<div class="p3-cap-row">
|
||||
<div class="lbl">{{ __('stakeholder.rc.cap.scope') }}</div>
|
||||
<div class="val">
|
||||
@if ($c['openTicketCount'] === 0)
|
||||
<span class="muted">{{ __('stakeholder.rc.cap.no_tickets') }}</span>
|
||||
@else
|
||||
@if ($c['budgetedHours'] > 0)
|
||||
<span class="primary"><span class="unit-h" data-hours="{{ round($c['budgetedHours']) }}">{{ round($c['budgetedHours']) }}h</span> {{ __('stakeholder.rc.cap.budgeted') }}</span>
|
||||
<span class="muted">
|
||||
({{ $c['ticketsWithBudget'] }}/{{ $c['openTicketCount'] }}
|
||||
{{ __('stakeholder.rc.cap.tickets_with_hours') }} — {{ (int) ($c['coverage'] * 100) }}% {{ __('stakeholder.rc.cap.coverage') }})
|
||||
</span>
|
||||
@else
|
||||
<span class="muted">{{ __('stakeholder.rc.cap.no_budgeted') }} ({{ $c['openTicketCount'] }} {{ __('stakeholder.rc.cap.open_tickets') }})</span>
|
||||
@endif
|
||||
<br>
|
||||
@if ($c['effortPoints'] > 0)
|
||||
<span class="primary"><span class="unit-h" data-hours="{{ round($c['effortHours']) }}">{{ round($c['effortHours']) }}h</span> {{ __('stakeholder.rc.cap.effort') }}</span>
|
||||
<span class="muted">({{ $c['effortPoints'] }}
|
||||
<span class="pts-info" data-tippy-content="{{ __('stakeholder.rc.cap.points_help') }}">pts <i class="fa fa-circle-info"></i></span>
|
||||
× {{ $c['hoursPerPoint'] }}h/pt)</span>
|
||||
@else
|
||||
<span class="muted">{{ __('stakeholder.rc.cap.no_effort') }}</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p3-cap-row">
|
||||
<div class="lbl">{{ __('stakeholder.rc.cap.capacity') }}</div>
|
||||
<div class="val">
|
||||
@if ($c['peopleCount'] === 0)
|
||||
<span class="muted">{{ __('stakeholder.rc.cap.no_people') }}</span>
|
||||
@else
|
||||
<span class="primary"><span class="unit-h" data-hours="{{ round($supplyPerWk) }}">{{ round($supplyPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.supply') }}</span>
|
||||
<span class="muted">
|
||||
({{ $c['peopleCount'] }} {{ __($c['peopleCount'] === 1 ? 'stakeholder.rc.cap.person' : 'stakeholder.rc.cap.people') }} × <span class="unit-h" data-hours="{{ round($supplyPerWk / max(1, $c['peopleCount']), 1) }}">{{ round($supplyPerWk / max(1, $c['peopleCount']), 1) }}h</span>/wk)
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($c['referenceDemand'] > 0 && $c['availableHours'] > 0)
|
||||
<div class="p3-cap-row">
|
||||
<div class="lbl">{{ __('stakeholder.rc.cap.balance') }}</div>
|
||||
<div class="val">
|
||||
<span class="unit-h" data-hours="{{ round($demandPerWk) }}">{{ round($demandPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.demand') }}
|
||||
<span class="divider">·</span>
|
||||
<span class="unit-h" data-hours="{{ round($supplyPerWk) }}">{{ round($supplyPerWk) }}h</span>/wk {{ __('stakeholder.rc.cap.supply') }}
|
||||
<div class="p3-cap-bar">
|
||||
<div class="track {{ $c['verdict'] }}">
|
||||
{{-- Supply segment: green fill 0 → available. --}}
|
||||
<div class="supply" style="width:{{ $availableMark }}%;"></div>
|
||||
{{-- Deficit segment: verdict-color band from
|
||||
available → needed, showing the shortfall. --}}
|
||||
@if ($isShort && $deficitWidth > 0)
|
||||
<div class="deficit" style="left:{{ $availableMark }}%;width:{{ $deficitWidth }}%;"
|
||||
data-tippy-content="{{ sprintf(__("stakeholder.rc.cap.deficit_tooltip"), round($gapPerWk)) }}"></div>
|
||||
@endif
|
||||
<div class="marker" style="left:{{ $availableMark }}%;">
|
||||
<span class="marker-label">{{ __('stakeholder.rc.cap.marker_label') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><span class="unit-h" data-hours="0">0h</span></span>
|
||||
<span><span class="unit-h" data-hours="{{ round($barMax) }}">{{ round($barMax) }}h</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($isShort && $c['recommendations']['extendWeeks'] > 0)
|
||||
@php $r = $c['recommendations']; @endphp
|
||||
<div class="p3-cap-rebalance">
|
||||
<div class="hd">{{ __('stakeholder.rc.cap.rebalance_hd') }}</div>
|
||||
<div class="opts">
|
||||
<div class="opt">
|
||||
<div class="icn"><i class="fa fa-calendar-plus"></i></div>
|
||||
<div class="lever">{{ __('stakeholder.rc.cap.lever_extend_pre') }} <b>{{ $r['extendWeeks'] }}</b> {{ __('stakeholder.rc.cap.lever_extend_post') }}</div>
|
||||
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_extend_detail'), round($c['weeklyHoursToProject'])) }}</div>
|
||||
</div>
|
||||
<div class="opt">
|
||||
<div class="icn"><i class="fa fa-user-plus"></i></div>
|
||||
<div class="lever">{{ __('stakeholder.rc.cap.lever_add_pre') }} <b>{{ $r['addPeople'] }}</b> {{ $r['addPeople'] === 1 ? __('stakeholder.rc.cap.lever_add_post_one') : __('stakeholder.rc.cap.lever_add_post_many') }}</div>
|
||||
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_add_detail'), round($c['weeklyHoursToProject'] / max(1, $c['peopleCount']), 1), $c['weeksInWindow']) }}</div>
|
||||
</div>
|
||||
<div class="opt">
|
||||
<div class="icn"><i class="fa fa-scissors"></i></div>
|
||||
<div class="lever">{{ __('stakeholder.rc.cap.lever_cut_pre') }} <b>{{ round($r['cutPoints']) }}</b> {{ __('stakeholder.rc.cap.lever_cut_post') }}</div>
|
||||
<div class="detail">{{ sprintf(__('stakeholder.rc.cap.lever_cut_detail'), round($r['cutHours']), $c['hoursPerPoint']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Compact one-liner --}}
|
||||
<div class="p3-cap-compact">
|
||||
<span class="verdict {{ $c['verdict'] }}">
|
||||
@if ($c['verdict'] === 'buffer')<i class="fa fa-check"></i>@else<i class="fa fa-minus"></i>@endif
|
||||
{{ $vLabel }}
|
||||
</span>
|
||||
<div class="name">{{ $c['name'] }}</div>
|
||||
<div class="summary">
|
||||
@if ($c['verdict'] === 'no_work')
|
||||
{{ __('stakeholder.rc.cap.summary_no_work') }}
|
||||
@else
|
||||
<span class="unit-h" data-hours="{{ round($c['referenceDemand']) }}">{{ round($c['referenceDemand']) }}h</span> {{ __('stakeholder.rc.cap.summary_needed') }}
|
||||
·
|
||||
<span class="unit-h" data-hours="{{ round($c['availableHours']) }}">{{ round($c['availableHours']) }}h</span> {{ __('stakeholder.rc.cap.summary_available') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
343
app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php
Normal file
343
app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php
Normal file
@@ -0,0 +1,343 @@
|
||||
{{--
|
||||
Stakeholder Report — 4-page deck shell.
|
||||
|
||||
Reused by both StrategyPro (strategy scope) and PgmPro (program scope).
|
||||
Data passed in via @include vars; this partial owns:
|
||||
- persistent header (subject, period, updated, status verdict)
|
||||
- global controls (period picker, print)
|
||||
- deck navigation (4 tabs + swipe + arrow keys + arrow buttons)
|
||||
- the 4 page containers (Overview / Logic Model / Resources & Coverage / Programs)
|
||||
- scoped CSS with `minmax(0,1fr)` discipline (§2 layout constraint)
|
||||
- print stylesheet expanding the deck (§7)
|
||||
|
||||
Vars in:
|
||||
$scope 'strategy' | 'program'
|
||||
$subject string — displayed in the header
|
||||
$period ReportPeriod
|
||||
$updatedAt string
|
||||
$verdict 'ontrack' | 'atrisk' | 'off' | 'unknown'
|
||||
$verdictLabel string — the visible verdict
|
||||
$verdictSource string — provenance line (never hidden, per §3)
|
||||
$report ReportEngine::buildReport() output
|
||||
$stats $report['stats']
|
||||
$deltas $report['deltas']
|
||||
$needsAttn $report['needsAttention']
|
||||
$logicModel null | {canvasId, narrative, stageProgress, healthBadges, coverageMatrix}
|
||||
$goalsGroup {goals, byProject, counts} — strategy: strategyGoals; program: programGoals
|
||||
$programRows array — strategy only, empty at program scope
|
||||
$programUpdates array — strategy only, empty at program scope
|
||||
--}}
|
||||
|
||||
@php
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
|
||||
$verdictDotColor = match ($verdict) {
|
||||
'ontrack' => '#3E937A',
|
||||
'inprogress' => '#3F72B0',
|
||||
'atrisk' => '#C09035',
|
||||
'off' => '#C2295B',
|
||||
default => '#9CA3AF',
|
||||
};
|
||||
$completedCount = (int) ($stats['completed'] ?? 0);
|
||||
$overdueCount = (int) ($stats['overdue'] ?? 0);
|
||||
$goalsOnTrack = (int) ($stats['goalsOnTrack'] ?? 0);
|
||||
$goalsTotal = (int) ($stats['goalsTotal'] ?? 0);
|
||||
$hoursLogged = (float) ($stats['hoursLogged'] ?? 0);
|
||||
$completedDelta = (int) ($deltas['completedDelta'] ?? 0);
|
||||
$hasLM = $logicModel !== null;
|
||||
|
||||
// Semantic period label — the "why this period" chip in the header sub-line.
|
||||
// Board audiences care WHY the report is showing this range (because it's
|
||||
// last closed) more than the raw dates, which appear separately in the picker.
|
||||
$periodMeaning = match ($period->preset) {
|
||||
ReportPeriod::PRESET_LAST_QUARTER => __('stakeholder.period.last_closed'),
|
||||
ReportPeriod::PRESET_THIS_QUARTER => __('stakeholder.period.in_progress'),
|
||||
ReportPeriod::PRESET_NEXT_QUARTER => __('stakeholder.period.upcoming'),
|
||||
ReportPeriod::PRESET_CUSTOM => __('stakeholder.period.custom'),
|
||||
default => '',
|
||||
};
|
||||
|
||||
// Preset name for the picker button — matches what the user selects in the
|
||||
// dropdown ("Last quarter" / "This quarter" / "Next quarter"). Deliberately
|
||||
// NOT "Q2 2026" — Leantime doesn't let companies define fiscal quarters, so
|
||||
// a calendar Q# label would be a lie for anyone whose fiscal year isn't
|
||||
// calendar-aligned. The literal date range is shown next to it.
|
||||
$presetName = match ($period->preset) {
|
||||
ReportPeriod::PRESET_LAST_QUARTER => __('label.period_last_quarter'),
|
||||
ReportPeriod::PRESET_THIS_QUARTER => __('label.period_this_quarter'),
|
||||
ReportPeriod::PRESET_NEXT_QUARTER => __('label.period_next_quarter'),
|
||||
ReportPeriod::PRESET_CUSTOM => __('label.period_custom'),
|
||||
default => __('label.period_this_quarter'),
|
||||
};
|
||||
|
||||
// Reload URL bases for the period picker preset links.
|
||||
$reportUrl = BASE_URL.'/'.($scope === 'strategy' ? 'strategyPro' : 'pgmPro').'/report';
|
||||
@endphp
|
||||
|
||||
|
||||
@php
|
||||
// Prefer a $rdDark boolean passed by the caller/composer; fall back to the
|
||||
// Theme service only when it isn't supplied, so the view isn't required to
|
||||
// do a container lookup.
|
||||
$rdDark = $rdDark ?? (app()->make(\Leantime\Core\UI\Theme::class)->getColorMode() === 'dark');
|
||||
@endphp
|
||||
<div class="rd-scope @if ($rdDark) rd-dark @endif">
|
||||
|
||||
{{-- ── Persistent document header (doc-shell) ───────────────────────
|
||||
breadcrumb + subject switcher + status verdict + actions live in the
|
||||
card; the plugin collapses the shared teal pageheader (body.report-doc)
|
||||
so the report reads as one document. Switcher + actions degrade safely
|
||||
when the caller doesn't pass switchableSubjects / projectId. --}}
|
||||
<div class="rd-hdr">
|
||||
<div class="st">
|
||||
{{-- One report = ONE subject; Task-view breadcrumb flow
|
||||
("Report // {subject}", like "To-Dos // All To-Dos"). The
|
||||
scope label and period meaning are redundant here — the
|
||||
breadcrumb says Report, the period picker below owns the
|
||||
period context — so the under-text keeps only freshness. --}}
|
||||
<h1 class="h"><span class="crumb-type">{{ __('stakeholder.header.crumb_report') }}</span> <span class="crumb-sep" aria-hidden="true">/</span> {{ $subject }}</h1>
|
||||
</div>
|
||||
{{-- RIGHT = what about it: verdict with freshness stacked under it,
|
||||
centered as one group against the title row, then ⋮. --}}
|
||||
<div class="verdict">
|
||||
{{-- Provenance ("set 1 month ago · overrides metrics") is secondary:
|
||||
it lives in the tooltip so the right side stays one balanced,
|
||||
vertically-centered row with the actions menu. --}}
|
||||
<div class="v" data-tippy-content="{{ $verdictSource }}"><span class="dot" style="background:{{ $verdictDotColor }}"></span>{{ $verdictLabel }}</div>
|
||||
<div class="prov">{{ __('stakeholder.header.updated') }} {{ $updatedAt }}</div>
|
||||
</div>
|
||||
@if (! empty($projectId ?? null))
|
||||
<div class="rd-actions">
|
||||
@include('reports::partials.stakeholder.actionsMenu', [
|
||||
'scope' => $scope,
|
||||
'projectId' => $projectId,
|
||||
'verdictOverride' => $verdictOverride ?? null,
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ── Tab bar + period picker on ONE row (saves a full row of vertical
|
||||
space; picker sits with the view-mode controls it belongs with) ── --}}
|
||||
<div class="lt-tabs lt-tabs--floating hideOnPrint">
|
||||
{{-- Framed segmented tab group (mirrors the global .tabs nav): the tabs
|
||||
sit in one outlined container so they read as a connected control,
|
||||
the active one a white segment inside it. --}}
|
||||
{{-- <nav> + aria-current, not the ARIA tabs pattern: the deck pages
|
||||
aren't role=tabpanel targets, so tablist semantics would mislead
|
||||
assistive tech (Copilot review). --}}
|
||||
<nav class="lt-tabs-group" id="rdTabs" aria-label="{{ __('stakeholder.tabs.label') }}">
|
||||
<button type="button" class="lt-tab on" data-page="0" onclick="rdGo(0)" aria-current="true"><i class="fa fa-gauge-simple-high"></i> {{ __('stakeholder.tab.overview') }}</button>
|
||||
<button type="button" class="lt-tab" data-page="1" onclick="rdGo(1)"><i class="fa fa-diagram-project"></i> {{ __('stakeholder.tab.logic_model') }}</button>
|
||||
<button type="button" class="lt-tab" data-page="2" onclick="rdGo(2)"><i class="fa fa-people-arrows"></i> {{ __('stakeholder.tab.resources_coverage') }}</button>
|
||||
<button type="button" class="lt-tab" data-page="3" onclick="rdGo(3)"><i class="fa fa-compass"></i> {{ __('stakeholder.tab.impact_journey') }}</button>
|
||||
</nav>
|
||||
|
||||
<div class="lt-tabs-actions">
|
||||
<div class="rd-picker" id="rdPicker">
|
||||
<button type="button" class="rd-picker-btn" onclick="rdTogglePicker(event)">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<span class="rd-picker-q">{{ $presetName }}</span>
|
||||
<span class="rd-picker-range">· {{ $period->from->setToUserTimezone()->format('M j') }} – {{ $period->to->setToUserTimezone()->format('M j, Y') }}</span>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="rd-picker-menu" id="rdPickerMenu" hidden>
|
||||
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_LAST_QUARTER }}"
|
||||
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_LAST_QUARTER) on @endif">
|
||||
<span class="l">{{ __('label.period_last_quarter') }}</span>
|
||||
<span class="d">{{ __('stakeholder.period.default_hint') }}</span>
|
||||
</a>
|
||||
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_THIS_QUARTER }}"
|
||||
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_THIS_QUARTER) on @endif">
|
||||
<span class="l">{{ __('label.period_this_quarter') }}</span>
|
||||
<span class="d">{{ __('stakeholder.period.in_progress_hint') }}</span>
|
||||
</a>
|
||||
<a href="{{ $reportUrl }}?preset={{ ReportPeriod::PRESET_NEXT_QUARTER }}"
|
||||
class="rd-picker-opt @if ($period->preset === ReportPeriod::PRESET_NEXT_QUARTER) on @endif">
|
||||
<span class="l">{{ __('label.period_next_quarter') }}</span>
|
||||
<span class="d">{{ __('stakeholder.period.upcoming_hint') }}</span>
|
||||
</a>
|
||||
<div class="rd-picker-sep"></div>
|
||||
<form method="GET" action="{{ $reportUrl }}" class="rd-picker-custom">
|
||||
<input type="hidden" name="preset" value="{{ ReportPeriod::PRESET_CUSTOM }}">
|
||||
<label class="rd-picker-cl">{{ __('label.period_custom') }}</label>
|
||||
<div class="rd-picker-crow">
|
||||
<input type="text" name="from" class="rd-picker-cinput periodPickerDate"
|
||||
placeholder="{{ __('label.period_from') }}"
|
||||
value="{{ $period->preset === ReportPeriod::PRESET_CUSTOM ? $period->from->setToUserTimezone()->formatDateForUser() : '' }}">
|
||||
<span class="rd-picker-cdash">–</span>
|
||||
<input type="text" name="to" class="rd-picker-cinput periodPickerDate"
|
||||
placeholder="{{ __('label.period_to') }}"
|
||||
value="{{ $period->preset === ReportPeriod::PRESET_CUSTOM ? $period->to->setToUserTimezone()->formatDateForUser() : '' }}">
|
||||
<button type="submit" class="rd-picker-capply">{{ __('label.period_apply') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rd-arrows">
|
||||
<button type="button" class="rd-arrow" id="rdPrev" onclick="rdGo(rdActive - 1)" aria-label="{{ __('stakeholder.nav.prev') }}"><i class="fa fa-chevron-left"></i></button>
|
||||
<button type="button" class="rd-arrow" id="rdNext" onclick="rdGo(rdActive + 1)" aria-label="{{ __('stakeholder.nav.next') }}"><i class="fa fa-chevron-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Deck ─────────────────────────────────────────────────────── --}}
|
||||
<div class="rd-deck">
|
||||
<div class="rd-deck-viewport">
|
||||
<div class="rd-deck-track" id="rdTrack">
|
||||
|
||||
{{-- ═══ Page 1 — Overview ═════════════════════════════ --}}
|
||||
<div class="rd-page on">
|
||||
@include('reports::partials.stakeholder.page-overview', compact(
|
||||
'completedCount', 'completedDelta', 'goalsOnTrack', 'goalsTotal',
|
||||
'overdueCount', 'hoursLogged', 'needsAttn', 'logicModel', 'hasLM',
|
||||
'goalsGroup', 'report', 'strategyUpdates', 'programUpdates',
|
||||
'programRows'
|
||||
))
|
||||
</div>
|
||||
|
||||
{{-- ═══ Page 2 — Logic Model read-out ═════════════════ --}}
|
||||
<div class="rd-page">
|
||||
@include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report'))
|
||||
</div>
|
||||
|
||||
{{-- ═══ Page 3 — Resources & Coverage ═════════════════ --}}
|
||||
<div class="rd-page">
|
||||
@include('reports::partials.stakeholder.page-resources', compact('logicModel', 'hasLM', 'resourceSummary', 'report', 'scope', 'capacityAnalysis', 'programMeta', 'programChildMap', 'capacityByProgram') + ['projectId' => $projectId ?? null])
|
||||
</div>
|
||||
|
||||
{{-- ═══ Page 4 — Impact Journey ═══════════════════════ --}}
|
||||
<div class="rd-page">
|
||||
@include('reports::partials.stakeholder.page-impact-journey', compact('scope', 'logicModel', 'hasLM'))
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/*
|
||||
* Report deck navigation. Vanilla JS — no Alpine, no jQuery dependency for the
|
||||
* core interaction. Supports: tab click, prev/next buttons, arrow keys,
|
||||
* horizontal swipe.
|
||||
*/
|
||||
(function () {
|
||||
if (window.__rdDeckInit) return;
|
||||
window.__rdDeckInit = true;
|
||||
|
||||
window.rdActive = 0;
|
||||
window.rdCount = 4;
|
||||
|
||||
// Per-user last-viewed page persists in localStorage so a refresh (and
|
||||
// returning to the report) lands you back where you were, not on the
|
||||
// Overview every time.
|
||||
var LS_PAGE = 'lt.stakeholderReport.activePage';
|
||||
|
||||
window.rdGo = function (idx, opts) {
|
||||
if (idx < 0 || idx >= window.rdCount) return;
|
||||
window.rdActive = idx;
|
||||
|
||||
var track = document.getElementById('rdTrack');
|
||||
if (!track) return;
|
||||
track.style.transform = 'translateX(' + (-100 * idx) + '%)';
|
||||
|
||||
// Only the active page contributes to height (no dead space on short pages).
|
||||
var pages = track.querySelectorAll('.rd-page');
|
||||
pages.forEach(function (p, i) { p.classList.toggle('on', i === idx); });
|
||||
|
||||
// Tab state — scoped to this deck's nav; .lt-tab is a shared global
|
||||
// class, so a bare selector could toggle unrelated tab groups.
|
||||
document.querySelectorAll('#rdTabs .lt-tab').forEach(function (btn) {
|
||||
var on = parseInt(btn.dataset.page, 10) === idx;
|
||||
btn.classList.toggle('on', on);
|
||||
if (on) { btn.setAttribute('aria-current', 'true'); } else { btn.removeAttribute('aria-current'); }
|
||||
});
|
||||
|
||||
// Arrow enable state.
|
||||
var prev = document.getElementById('rdPrev');
|
||||
var next = document.getElementById('rdNext');
|
||||
if (prev) prev.toggleAttribute('disabled', idx === 0);
|
||||
if (next) next.toggleAttribute('disabled', idx === window.rdCount - 1);
|
||||
|
||||
// Persist unless the caller says otherwise (used on initial restore
|
||||
// so we don't rewrite the value with the very value we just read).
|
||||
if (!opts || opts.persist !== false) {
|
||||
try { localStorage.setItem(LS_PAGE, String(idx)); } catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
// Arrow keys — only when focus isn't in a text input.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (e.key === 'ArrowLeft') window.rdGo(window.rdActive - 1);
|
||||
if (e.key === 'ArrowRight') window.rdGo(window.rdActive + 1);
|
||||
});
|
||||
|
||||
// Swipe (touch). Threshold 60px so accidental drags don't switch pages.
|
||||
var deck = document.querySelector('.rd-deck-viewport');
|
||||
if (deck) {
|
||||
var startX = 0, startY = 0, tracking = false;
|
||||
deck.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1) return;
|
||||
startX = e.touches[0].clientX; startY = e.touches[0].clientY; tracking = true;
|
||||
}, { passive: true });
|
||||
deck.addEventListener('touchend', function (e) {
|
||||
if (!tracking) return; tracking = false;
|
||||
var dx = e.changedTouches[0].clientX - startX;
|
||||
var dy = e.changedTouches[0].clientY - startY;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx)) return;
|
||||
window.rdGo(window.rdActive + (dx < 0 ? 1 : -1));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Initial state — restore the last-viewed page if persisted, else Overview.
|
||||
var initialPage = 0;
|
||||
try {
|
||||
var saved = parseInt(localStorage.getItem(LS_PAGE) || '', 10);
|
||||
if (!isNaN(saved) && saved >= 0 && saved < window.rdCount) initialPage = saved;
|
||||
} catch (e) {}
|
||||
window.rdGo(initialPage, { persist: false });
|
||||
|
||||
// Compact period-picker dropdown: toggle open, dismiss on outside click.
|
||||
window.rdTogglePicker = function (e) {
|
||||
if (e) e.stopPropagation();
|
||||
var menu = document.getElementById('rdPickerMenu');
|
||||
if (!menu) return;
|
||||
menu.toggleAttribute('hidden');
|
||||
};
|
||||
document.addEventListener('click', function (e) {
|
||||
var picker = document.getElementById('rdPicker');
|
||||
if (!picker || picker.contains(e.target)) return;
|
||||
var menu = document.getElementById('rdPickerMenu');
|
||||
if (menu && !menu.hasAttribute('hidden')) menu.setAttribute('hidden', '');
|
||||
});
|
||||
|
||||
// Wire the datepicker to the two custom-range inputs (same helper Marcel's
|
||||
// periodpicker uses). Only if jQuery + the helper are present.
|
||||
if (typeof jQuery !== 'undefined' && jQuery.fn.datepicker && window.leantime?.dateHelper) {
|
||||
jQuery('.rd-picker-cinput').datepicker({
|
||||
dateFormat: window.leantime.dateHelper.getFormatFromSettings('dateformat', 'jquery')
|
||||
});
|
||||
}
|
||||
|
||||
// KPI drill toggle — click a cell with .has-detail to open its drill list.
|
||||
// Click elsewhere closes it. Only one open at a time.
|
||||
document.addEventListener('click', function (e) {
|
||||
var cell = e.target.closest('.rd-kcell.has-detail');
|
||||
// Clicked inside the open drill? Let the click through (don't close).
|
||||
if (e.target.closest('.rd-kcell.has-detail .kdrill')) return;
|
||||
|
||||
// Close every other open drill first (single-open behavior).
|
||||
document.querySelectorAll('.rd-kcell.has-detail.open').forEach(function (c) {
|
||||
if (c !== cell) c.classList.remove('open');
|
||||
});
|
||||
|
||||
// Toggle the clicked cell (if any).
|
||||
if (cell) cell.classList.toggle('open');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,537 @@
|
||||
{{--
|
||||
Stakeholder Report — Page 4 (Impact Journey)
|
||||
|
||||
"Not a tracker — a vision that becomes proof."
|
||||
|
||||
One artifact, three lens views, tense-driven. Same bars, targets, and
|
||||
people stay on screen; the framing and tense change around them.
|
||||
|
||||
Vision (Day 1, always available) — targets only, "will"
|
||||
Progress (unlocks with any snapshot) — captured + remaining, "is becoming"
|
||||
Impact (unlocks when targets met) — achieved, "did"
|
||||
|
||||
Bookends both authored, both present Day 1:
|
||||
STARTED (the world / the problem) → the journey → DIFFERENT (the impact)
|
||||
|
||||
Human meaning is AUTHORED on the canvas (item.description / item.assumptions
|
||||
/ item.conclusion), never generated — §7 rule 12. A concatenated narrative
|
||||
is a fabrication when this ends up in a funder's hands.
|
||||
|
||||
Per-lens content is rendered server-side and CSS-toggled by the parent
|
||||
wrapper's data-active-lens attribute — so the "same component, words &
|
||||
fill change" reads as one journey maturing, not hard cuts.
|
||||
|
||||
Vars in:
|
||||
$logicModel null | {narrative, coverageMatrix, projectLinks, linkedGoals, ...}
|
||||
$hasLM bool
|
||||
$scope 'strategy' | 'program'
|
||||
--}}
|
||||
|
||||
|
||||
@if (! $hasLM)
|
||||
<div class="p4-wrap">
|
||||
<div class="p4-empty">
|
||||
<div class="lb">{{ __('stakeholder.ij.no_lm_title') }}</div>
|
||||
<div class="s">{{ __('stakeholder.ij.no_lm_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
|
||||
$projectLinks = $logicModel['projectLinks'] ?? [];
|
||||
$linkedGoals = $logicModel['linkedGoals'] ?? [];
|
||||
|
||||
$outputItems = $stages['outputs']['items'] ?? [];
|
||||
$outcomeItems = $stages['outcomes']['items'] ?? [];
|
||||
$impactItems = $stages['impact']['items'] ?? [];
|
||||
|
||||
// Metric aggregator (unchanged from before; comment retained for context).
|
||||
$metricFor = function ($item) use ($projectLinks, $linkedGoals) {
|
||||
$itemId = (int) (((array) $item)['id'] ?? 0);
|
||||
$links = $projectLinks[$itemId] ?? [];
|
||||
$goals = [];
|
||||
foreach ($links as $link) {
|
||||
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
|
||||
$gid = (int) ($link['linked_entity_id'] ?? 0);
|
||||
if (isset($linkedGoals[$gid])) $goals[] = $linkedGoals[$gid];
|
||||
}
|
||||
if (count($goals) === 0) return null;
|
||||
|
||||
$current = array_sum(array_column($goals, 'currentValue'));
|
||||
$target = array_sum(array_column($goals, 'endValue'));
|
||||
$unit = $goals[0]['metricType'] ?? 'number';
|
||||
|
||||
$byDate = [];
|
||||
foreach ($goals as $g) {
|
||||
foreach (($g['snapshots'] ?? []) as $s) {
|
||||
$day = substr((string) $s['date'], 0, 10);
|
||||
if (! isset($byDate[$day])) $byDate[$day] = 0.0;
|
||||
$byDate[$day] += (float) $s['value'];
|
||||
}
|
||||
}
|
||||
ksort($byDate);
|
||||
$snapshots = [];
|
||||
foreach ($byDate as $day => $value) $snapshots[] = ['date' => $day, 'value' => $value];
|
||||
|
||||
$arr = (array) $item;
|
||||
// Meaning source per §1 of the authored-meaning spec:
|
||||
// why_this_matters — the primary source (authored, nullable)
|
||||
// No fallback to conclusion — conclusion narrows to "as measured
|
||||
// by" methodology only, going forward. Mining it for meaning is
|
||||
// exactly the double-duty that broke Page 2 earlier.
|
||||
return [
|
||||
'id' => $itemId,
|
||||
'label' => trim((string) ($arr['description'] ?? '')),
|
||||
'meaning' => trim((string) ($arr['why_this_matters'] ?? '')),
|
||||
'measuredBy' => trim((string) ($arr['conclusion'] ?? '')),
|
||||
'current' => $current,
|
||||
'target' => $target,
|
||||
'unit' => $unit,
|
||||
'snapshots' => $snapshots,
|
||||
];
|
||||
};
|
||||
|
||||
$anySnapshots = false;
|
||||
$collectMetrics = function (array $items) use ($metricFor, &$anySnapshots) {
|
||||
$metrics = [];
|
||||
foreach ($items as $it) {
|
||||
$m = $metricFor($it);
|
||||
if ($m === null) continue;
|
||||
if (count($m['snapshots']) > 0) $anySnapshots = true;
|
||||
$metrics[] = $m;
|
||||
}
|
||||
return $metrics;
|
||||
};
|
||||
$outMetrics = $collectMetrics($outputItems);
|
||||
$ocMetrics = $collectMetrics($outcomeItems);
|
||||
|
||||
$anyHit = false;
|
||||
foreach (array_merge($outMetrics, $ocMetrics) as $m) {
|
||||
if ($m['target'] > 0 && $m['current'] >= $m['target']) { $anyHit = true; break; }
|
||||
}
|
||||
$progressUnlocked = $anySnapshots;
|
||||
$impactUnlocked = $anyHit;
|
||||
|
||||
// Default lens = the most advanced state the data supports. A page
|
||||
// with snapshots defaults to Progress (not Vision) so a returning
|
||||
// reader lands on the current reality — Vision is reachable via
|
||||
// the toggle for the "share the promise" flow.
|
||||
$defaultLens = $impactUnlocked ? 'impact' : ($progressUnlocked ? 'progress' : 'vision');
|
||||
|
||||
$fmt = function ($value, string $unit) {
|
||||
$value = (float) $value;
|
||||
if ($unit === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
|
||||
if ($value == floor($value)) return number_format($value, 0, '.', ',');
|
||||
return number_format($value, 1, '.', ',');
|
||||
};
|
||||
|
||||
// Detects whether the authored label starts with the target number
|
||||
// (e.g. "1,200 screenings completed" for target 1,200). Used to
|
||||
// suppress the redundant "target N" subtitle on Vision — the label
|
||||
// is already the promise.
|
||||
$labelContainsTarget = function (string $label, float $target, string $unit) use ($fmt): bool {
|
||||
if ($target <= 0) return false;
|
||||
$formatted = $fmt($target, $unit);
|
||||
// strip commas for a looser match too
|
||||
$stripped = str_replace(',', '', $formatted);
|
||||
$labelStripped = str_replace(',', '', $label);
|
||||
return stripos($labelStripped, $stripped) !== false;
|
||||
};
|
||||
|
||||
// ── STARTED bookend text.
|
||||
// Authored ONLY. Sourced from the Impact item's `starting_picture`
|
||||
// (a dedicated field for the world today, before this work). No
|
||||
// synthesis, no fallback to narrative concatenation — an empty state
|
||||
// is more honest than a fabrication, and this artifact ends up in
|
||||
// funders' hands.
|
||||
$startedText = '';
|
||||
if (count($impactItems) > 0) {
|
||||
$startedText = trim((string) (((array) $impactItems[0])['starting_picture'] ?? ''));
|
||||
}
|
||||
|
||||
// ── DIFFERENT bookend: authored impact title + authored meaning.
|
||||
// Meaning source: why_this_matters ONLY. No fallback to conclusion
|
||||
// (which is now "as measured by" methodology). No fallback to
|
||||
// assumptions (that's for the theory-of-change assertion, a
|
||||
// different concept from the funder-facing meaning).
|
||||
$differentTitle = '';
|
||||
$differentMeaning = '';
|
||||
if (count($impactItems) > 0) {
|
||||
$impArr = (array) $impactItems[0];
|
||||
$differentTitle = trim((string) ($impArr['description'] ?? ''));
|
||||
$differentMeaning = trim((string) ($impArr['why_this_matters'] ?? ''));
|
||||
}
|
||||
|
||||
// ── Arc statement (Vision beat 2). The ONE place a light
|
||||
// concatenation is correct: assembled from authored labels, capped
|
||||
// at 3 producing + 2 achieving, two sentences maximum. Never the
|
||||
// §8 rule-1 canvas dump. Nothing generated — every word is a label
|
||||
// a human wrote on the canvas.
|
||||
$capProducing = array_slice($outMetrics, 0, 3);
|
||||
$capAchieving = array_slice($ocMetrics, 0, 2);
|
||||
$producingLabels = array_values(array_filter(array_map(
|
||||
static fn ($m) => trim((string) $m['label']),
|
||||
$capProducing
|
||||
)));
|
||||
$achievingLabels = array_values(array_filter(array_map(
|
||||
static fn ($m) => trim((string) $m['label']),
|
||||
$capAchieving
|
||||
)));
|
||||
|
||||
$producingSentence = $producingLabels === []
|
||||
? ''
|
||||
: implode('. ', $producingLabels).'.';
|
||||
|
||||
$achievingSentence = '';
|
||||
if (count($achievingLabels) === 1) {
|
||||
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $achievingLabels[0]).'.';
|
||||
} elseif (count($achievingLabels) >= 2) {
|
||||
// Natural join: "X and Y" for two, "X, Y, and Z" for three (only
|
||||
// if the cap is ever raised).
|
||||
if (count($achievingLabels) === 2) {
|
||||
$joined = $achievingLabels[0].' '.__('stakeholder.ij.beat_arc_and').' '.$achievingLabels[1];
|
||||
} else {
|
||||
$joined = implode(', ', array_slice($achievingLabels, 0, -1))
|
||||
.', '.__('stakeholder.ij.beat_arc_and').' '.end($achievingLabels);
|
||||
}
|
||||
$achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $joined).'.';
|
||||
}
|
||||
|
||||
$arcStatement = trim($producingSentence.' '.$achievingSentence);
|
||||
|
||||
// Only the "Logic Model canvas" phrase links out — the surrounding nudge
|
||||
// sentence stays plain text. Built once, injected via sprintf %s below.
|
||||
$lmCanvasLink = '<a href="'.BASE_URL.'/logicmodelcanvas/showCanvas">'.e(__('stakeholder.ij.nudge_link')).'</a>';
|
||||
@endphp
|
||||
|
||||
<div class="p4-wrap" data-active-lens="{{ $defaultLens }}" data-p4-lens-wrap>
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="p4-hd">
|
||||
<div>
|
||||
<div class="t">{{ __('stakeholder.ij.header_title') }}</div>
|
||||
<div class="s">{{ __('stakeholder.ij.header_sub') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Lens toggle — shows only when >=2 lenses exist for the reader --}}
|
||||
@if ($progressUnlocked || $impactUnlocked)
|
||||
<div class="p4-lens" data-p4-lens>
|
||||
<button type="button" class="lopt @if ($defaultLens === 'vision') is-active @endif" data-lens-target="vision">{{ __('stakeholder.ij.lens_vision') }}</button>
|
||||
<button type="button" class="lopt @if ($defaultLens === 'progress') is-active @endif @if (! $progressUnlocked) locked @endif" data-lens-target="progress" @if (! $progressUnlocked) disabled @endif>
|
||||
@if (! $progressUnlocked)<i class="fa fa-lock"></i>@endif
|
||||
{{ __('stakeholder.ij.lens_progress') }}
|
||||
</button>
|
||||
<button type="button" class="lopt @if ($defaultLens === 'impact') is-active @endif @if (! $impactUnlocked) locked @endif" data-lens-target="impact" @if (! $impactUnlocked) disabled @endif>
|
||||
@if (! $impactUnlocked)<i class="fa fa-lock"></i>@endif
|
||||
{{ __('stakeholder.ij.lens_impact') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── VISION lens: three beats. Complete on day one, by design.
|
||||
Beat 1 (world today) and beat 3 (world delivered) carry the
|
||||
same authored text as the STARTED/DIFFERENT bookends below
|
||||
— the block-level lens toggle means only one shows at a
|
||||
time, so there is no duplication for the reader. --}}
|
||||
<div class="p4-vision" data-lens-block="vision">
|
||||
<div class="beat today">
|
||||
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.beat_today_lb') }}</div>
|
||||
@if ($startedText !== '')
|
||||
<div class="txt">{{ $startedText }}</div>
|
||||
@else
|
||||
<div class="empty">
|
||||
{{ __('stakeholder.ij.bookend_started_empty') }}
|
||||
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="beat arc">
|
||||
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.beat_arc_lb') }}</div>
|
||||
@if ($arcStatement !== '')
|
||||
<div class="statement">{{ $arcStatement }}</div>
|
||||
@else
|
||||
<div class="empty">
|
||||
{{ __('stakeholder.ij.beat_arc_empty') }}
|
||||
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.beat_arc_empty_nudge')), $lmCanvasLink) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="beat delivered">
|
||||
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.beat_delivered_lb') }}</div>
|
||||
@if ($differentTitle !== '')
|
||||
<div class="txt">{{ $differentTitle }}</div>
|
||||
@if ($differentMeaning !== '')
|
||||
<div class="meaning">{{ $differentMeaning }}</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="empty">
|
||||
{{ __('stakeholder.ij.bookend_different_empty') }}
|
||||
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── PROGRESS + IMPACT: the same bookends flanking the arc with
|
||||
live tracks. Hidden entirely on the Vision lens by the
|
||||
block-level toggle above. --}}
|
||||
<div data-lens-block="tracks">
|
||||
|
||||
{{-- STARTED bookend — authored problem text or honest empty state.
|
||||
NO narrative dump. NO generated summary. --}}
|
||||
<div class="p4-bookend started">
|
||||
<div class="lb"><i class="fa fa-flag"></i> {{ __('stakeholder.ij.bookend_started') }}</div>
|
||||
@if ($startedText !== '')
|
||||
<div class="meaning" style="margin-top:0;">{{ $startedText }}</div>
|
||||
@else
|
||||
<div class="empty">
|
||||
{{ __('stakeholder.ij.bookend_started_empty') }}
|
||||
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- The Arc --}}
|
||||
@if (count($outMetrics) > 0 || count($ocMetrics) > 0)
|
||||
<div class="p4-arc">
|
||||
<div class="lb"><i class="fa fa-arrow-trend-up"></i> {{ __('stakeholder.ij.arc_label') }}</div>
|
||||
|
||||
@foreach ([
|
||||
['key'=>'outputs', 'items'=>$outMetrics, 'title'=>__('stakeholder.ij.arc_producing'), 'icon'=>'fa-boxes-stacked'],
|
||||
['key'=>'outcomes', 'items'=>$ocMetrics, 'title'=>__('stakeholder.ij.arc_achieving'), 'icon'=>'fa-chart-line'],
|
||||
] as $group)
|
||||
@if (count($group['items']) === 0) @continue @endif
|
||||
<div class="p4-mgroup {{ $group['key'] }}">
|
||||
<div class="gh"><i class="fa {{ $group['icon'] }}"></i> {{ $group['title'] }}</div>
|
||||
|
||||
@foreach ($group['items'] as $m)
|
||||
@php
|
||||
$n = count($m['snapshots']);
|
||||
$growthState = $n <= 1 ? 'baseline' : ($n === 2 ? 'before_after' : 'full_arc');
|
||||
$hitTarget = $m['target'] > 0 && $m['current'] >= $m['target'];
|
||||
$peak = max($m['target'], $m['current'], 1);
|
||||
foreach ($m['snapshots'] as $s) $peak = max($peak, (float) $s['value']);
|
||||
$barH = fn ($v) => max(4, min(50, (int) round(($v / $peak) * 50)));
|
||||
$labelHasTarget = $labelContainsTarget($m['label'], $m['target'], $m['unit']);
|
||||
@endphp
|
||||
<div class="p4-metric">
|
||||
<div class="mn">
|
||||
@if ($m['meaning'] !== '')
|
||||
{{-- Meaning leads. The metric is evidence.
|
||||
"as measured by {label}" reads as the receipt
|
||||
underneath — same rule as Page 2's verdict + read
|
||||
pattern, one level down. --}}
|
||||
{{ $m['meaning'] }}
|
||||
<span class="meaning">
|
||||
{{ __('stakeholder.ij.as_measured_by') }} {{ $m['label'] }}
|
||||
@if ($m['measuredBy'] !== '')
|
||||
— {{ $m['measuredBy'] }}
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
{{-- No authored meaning yet: today's behavior — the
|
||||
authored label leads, optional methodology below.
|
||||
No regression. --}}
|
||||
{{ $m['label'] }}
|
||||
@if (! $labelHasTarget && $m['target'] > 0)
|
||||
<span class="tgt">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
|
||||
@endif
|
||||
@if ($m['measuredBy'] !== '')
|
||||
<span class="meaning">{{ __('stakeholder.ij.as_measured_by') }} {{ $m['measuredBy'] }}</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ONE persistent bar per metric. Track = the target
|
||||
(always visible, same shape all lenses). Fill = current
|
||||
progress, animates on lens change via CSS transition.
|
||||
Vision → fill 0%. Progress → fill current/target. Impact
|
||||
→ fill 100%. The morph IS the story. --}}
|
||||
@php
|
||||
// Cap the visual fill % — over-target still reads as full,
|
||||
// "we did this" carries the over-delivery in the value label.
|
||||
$progressPct = $m['target'] > 0
|
||||
? min(100, ($m['current'] / $m['target']) * 100)
|
||||
: 0;
|
||||
// Fill values per lens — the JS uses these dataset attrs
|
||||
// to swap width + label text on lens change.
|
||||
$visionFill = 0;
|
||||
$progressFill = $progressPct;
|
||||
$impactFill = 100;
|
||||
$visionLabel = __('stakeholder.ij.fill_lbl_vision');
|
||||
$progressLabel = sprintf(__('stakeholder.ij.fill_lbl_progress'), $fmt($m['current'], $m['unit']));
|
||||
$impactLabel = sprintf(__('stakeholder.ij.fill_lbl_impact'), $fmt(max($m['current'], $m['target']), $m['unit']));
|
||||
@endphp
|
||||
<div class="p4-arc-viz"
|
||||
data-p4-bar
|
||||
data-vision-fill="{{ $visionFill }}"
|
||||
data-progress-fill="{{ $progressFill }}"
|
||||
data-impact-fill="{{ $impactFill }}"
|
||||
data-vision-lbl="{{ $visionLabel }}"
|
||||
data-progress-lbl="{{ $progressLabel }}"
|
||||
data-impact-lbl="{{ $impactLabel }}">
|
||||
<div class="p4-scale">
|
||||
<span class="p4-fill-lbl">{{
|
||||
$defaultLens === 'vision' ? $visionLabel
|
||||
: ($defaultLens === 'impact' ? $impactLabel : $progressLabel)
|
||||
}}</span>
|
||||
<span class="p4-target-lbl">{{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}</span>
|
||||
</div>
|
||||
<div class="p4-track">
|
||||
<div class="p4-fill" style="width:{{
|
||||
$defaultLens === 'vision' ? $visionFill
|
||||
: ($defaultLens === 'impact' ? $impactFill : $progressFill)
|
||||
}}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── VERDICTS — tense-aware, encouragement via structure ── --}}
|
||||
{{-- Vision: future tense. --}}
|
||||
<span class="p4-verdict willbe" data-lens="vision">
|
||||
<span class="sd"></span> {{ __('stakeholder.ij.v_will_be_measured') }}
|
||||
</span>
|
||||
{{-- Progress: present-becoming tense. One framing for both
|
||||
units — "N% of the way" reads truthfully for counts and
|
||||
percents alike, and stays consistent down the column
|
||||
regardless of metric type. --}}
|
||||
{{-- One progress color for every in-progress row: the text is
|
||||
uniformly "N% of the way", and the percentage itself conveys
|
||||
how far along. A silent amber↔blue flip at a hidden 75%
|
||||
threshold made identically-worded rows look different for no
|
||||
visible reason, so the dot stays consistent down the column. --}}
|
||||
<span class="p4-verdict @if ($hitTarget) hit @elseif ($n === 0) captured @else trending @endif" data-lens="progress">
|
||||
<span class="sd"></span>
|
||||
@if ($n === 0)
|
||||
{{ __('stakeholder.ij.v_no_snapshots_yet') }}
|
||||
@elseif ($hitTarget)
|
||||
{{ __('stakeholder.ij.v_hit_target_progress') }}
|
||||
@elseif ($m['target'] > 0)
|
||||
{{ sprintf(__('stakeholder.ij.v_pct_of_way'), (int) round(($m['current'] / $m['target']) * 100)) }}
|
||||
@else
|
||||
{{ __('stakeholder.ij.v_trending') }}
|
||||
@endif
|
||||
</span>
|
||||
{{-- Impact: past tense. Only truthful for hit-target rows. --}}
|
||||
<span class="p4-verdict @if ($hitTarget) hit @else captured @endif" data-lens="impact">
|
||||
<span class="sd"></span>
|
||||
@if ($hitTarget)
|
||||
{{ __('stakeholder.ij.v_we_did_this') }}
|
||||
@else
|
||||
{{ __('stakeholder.ij.v_not_yet_impact') }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Structural encouragement — only when the claim can be
|
||||
reconstructed from what's on screen (§7 rule 8 / v6 Fix 2).
|
||||
Progress-lens "one more completes the arc" needed a period
|
||||
cadence the page doesn't render yet; dropped until we do. --}}
|
||||
@if (! $progressUnlocked)
|
||||
<div class="p4-chip" data-lens="vision"><i class="fa fa-circle-info"></i> {{ __('stakeholder.ij.chip_first_period') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="p4-empty">
|
||||
<div class="lb">{{ __('stakeholder.ij.no_metrics_title') }}</div>
|
||||
<div class="s">{{ __('stakeholder.ij.no_metrics_hint') }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- DIFFERENT bookend — authored impact + supporting meaning.
|
||||
Meaning is the emotional payload: WHY this matters to the
|
||||
people it affects. Never generated. --}}
|
||||
<div class="p4-bookend different">
|
||||
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.ij.bookend_different') }}</div>
|
||||
@if ($differentTitle !== '')
|
||||
<div class="txt">{{ $differentTitle }}</div>
|
||||
@if ($differentMeaning !== '')
|
||||
<div class="meaning">{{ $differentMeaning }}</div>
|
||||
@endif
|
||||
{{-- Tense hints — one per lens, framing shifts with capture state. --}}
|
||||
<span class="tense-hint" data-lens="vision">{{ __('stakeholder.ij.different_tense_vision') }}</span>
|
||||
<span class="tense-hint" data-lens="progress">{{ __('stakeholder.ij.different_tense_progress') }}</span>
|
||||
<span class="tense-hint" data-lens="impact">{{ __('stakeholder.ij.different_tense_impact') }}</span>
|
||||
@else
|
||||
<div class="empty">
|
||||
{{ __('stakeholder.ij.bookend_different_empty') }}
|
||||
<span class="nudge">{!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>{{-- /[data-lens-block=tracks] --}}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var wrap = document.querySelector('[data-p4-lens-wrap]');
|
||||
var toggle = document.querySelector('[data-p4-lens]');
|
||||
if (! wrap || ! toggle) return;
|
||||
|
||||
var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Morph a single bar to the target lens: swap fill width + label text.
|
||||
// Width transition is CSS-driven; label crossfade is a quick opacity dip.
|
||||
function morphBar (viz, lens) {
|
||||
var fillEl = viz.querySelector('.p4-fill');
|
||||
var lblEl = viz.querySelector('.p4-fill-lbl');
|
||||
if (! fillEl || ! lblEl) return;
|
||||
var pct = viz.getAttribute('data-' + lens + '-fill');
|
||||
var lbl = viz.getAttribute('data-' + lens + '-lbl');
|
||||
if (pct !== null) fillEl.style.width = pct + '%';
|
||||
if (lbl !== null) {
|
||||
if (reducedMotion) {
|
||||
lblEl.textContent = lbl;
|
||||
} else {
|
||||
lblEl.style.opacity = '0';
|
||||
setTimeout(function () { lblEl.textContent = lbl; lblEl.style.opacity = '1'; }, 140);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function switchLens (lens) {
|
||||
var wasVision = wrap.getAttribute('data-active-lens') === 'vision';
|
||||
wrap.setAttribute('data-active-lens', lens);
|
||||
|
||||
// Arrival — when leaving Vision for Progress/Impact, the tracks
|
||||
// don't just appear (that would be a jump); they stagger in.
|
||||
// Rows start hidden (.arriving), then .arrived is added on a
|
||||
// stagger so the CSS transition plays. Reduced-motion → instant.
|
||||
if (wasVision && lens !== 'vision' && ! reducedMotion) {
|
||||
var rows = wrap.querySelectorAll('[data-lens-block="tracks"] .p4-metric');
|
||||
rows.forEach(function (row) { row.classList.remove('arrived'); row.classList.add('arriving'); });
|
||||
rows.forEach(function (row, i) {
|
||||
setTimeout(function () {
|
||||
row.classList.remove('arriving');
|
||||
row.classList.add('arrived');
|
||||
}, i * 80);
|
||||
});
|
||||
}
|
||||
|
||||
var bars = wrap.querySelectorAll('[data-p4-bar]');
|
||||
bars.forEach(function (viz, i) {
|
||||
// Stagger by 80ms so the morph reads as a sequence, not a jump.
|
||||
var delay = reducedMotion ? 0 : (i * 80);
|
||||
if (delay === 0) morphBar(viz, lens);
|
||||
else setTimeout(function () { morphBar(viz, lens); }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.lopt');
|
||||
if (! btn || btn.classList.contains('locked') || btn.hasAttribute('disabled')) return;
|
||||
var target = btn.getAttribute('data-lens-target');
|
||||
if (! target) return;
|
||||
toggle.querySelectorAll('.lopt').forEach(function (b) { b.classList.toggle('is-active', b === btn); });
|
||||
switchLens(target);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
@@ -0,0 +1,832 @@
|
||||
{{--
|
||||
Stakeholder Report — Page 2 (Logic Model read-out)
|
||||
|
||||
Fully rewritten against the punch-list. Behavior contracts:
|
||||
|
||||
1. Goal-as-truth. If an item has any linked goal (projectLinks entry with
|
||||
linked_entity_type='goal'), the goal record supplies BOTH current and
|
||||
target values. The item description is display label only — never
|
||||
parsed for a number in that case. Items with no goal link show no
|
||||
denominator and no percent.
|
||||
2. Guard: if aggregate target <= 0 or current/target > 5, drop the ratio
|
||||
(impossible math never reaches a funder) and Log it.
|
||||
3. Templated read: max 2 lines per stage, one going-well + one exception,
|
||||
materiality-weighted at 20%. Vocabulary is fixed — no freeform prose.
|
||||
4. Belief line is ONE sentence, ≤220 chars, built from first 3 activities
|
||||
+ first impact.
|
||||
5. Risk box: one weakest health badge, single sentence, single period.
|
||||
6. Layout: max-width 900px, min-width:0 on every grid child, no
|
||||
horizontal blowout at 1280px.
|
||||
7. Status color map is fixed — at-risk is white bg + inset ring, never cream.
|
||||
|
||||
Vars in:
|
||||
$logicModel null | {canvasId, narrative, stageProgress, healthBadges,
|
||||
coverageMatrix, projectLinks, linkedGoals, projectMeta}
|
||||
$hasLM bool
|
||||
$scope 'strategy' | 'program'
|
||||
--}}
|
||||
|
||||
|
||||
@php
|
||||
// Empty-state trigger is keyed on ITEM COUNT, not just board existence.
|
||||
// A blank Logic Model board (0 items) makes $hasLM true but leaves the
|
||||
// read-out hollow; the reverse flow itself creates a board on start, so
|
||||
// gating only on board-existence would strand users in an empty skeleton.
|
||||
// Treat "no board" and "board with no items" identically — both land on
|
||||
// the populate-from-your-work empty-state below.
|
||||
$lmStages = $hasLM ? ($logicModel['coverageMatrix']['stages'] ?? []) : [];
|
||||
$lmHasContent = false;
|
||||
foreach ($lmStages as $lmStage) {
|
||||
if (count($lmStage['items'] ?? []) > 0) {
|
||||
$lmHasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Existing linked work in this strategy, for the empty-state's "we found
|
||||
// your work" line. programRows carry type + projectCount; program rows
|
||||
// count as programs, and projectCount sums to total leaf projects (direct
|
||||
// + under programs). Empty at program scope, so the line self-hides there.
|
||||
$lmProgramCount = 0;
|
||||
$lmProjectCount = 0;
|
||||
foreach ($programRows ?? [] as $lmRow) {
|
||||
// programRows carries stdClass rows (object[], like page-programs), so
|
||||
// cast before array access to avoid "Cannot use object as array".
|
||||
$lmRow = (array) $lmRow;
|
||||
if (($lmRow['type'] ?? '') === 'program') {
|
||||
$lmProgramCount++;
|
||||
}
|
||||
$lmProjectCount += (int) ($lmRow['projectCount'] ?? 0);
|
||||
}
|
||||
// Bold the counts so the totals read as totals. Every piece is server-side
|
||||
// (ints + translated nouns, both e()-escaped), so the {!! !!} render below
|
||||
// carries no user input.
|
||||
$lmFoundParts = [];
|
||||
if ($lmProgramCount > 0) {
|
||||
$lmFoundParts[] = '<b>'.$lmProgramCount.'</b> '.e(__($lmProgramCount === 1 ? 'stakeholder.lm.found_program' : 'stakeholder.lm.found_programs'));
|
||||
}
|
||||
if ($lmProjectCount > 0) {
|
||||
$lmFoundParts[] = '<b>'.$lmProjectCount.'</b> '.e(__($lmProjectCount === 1 ? 'stakeholder.lm.found_project' : 'stakeholder.lm.found_projects'));
|
||||
}
|
||||
$lmFoundStr = implode('<span class="sep">·</span>', $lmFoundParts);
|
||||
@endphp
|
||||
|
||||
@if (! $lmHasContent)
|
||||
<div class="p2-wrap">
|
||||
<div class="p2-lm-emptyzone">
|
||||
<div class="p2-lm-empty">
|
||||
<span class="ic"><i class="fa fa-diagram-project" aria-hidden="true"></i></span>
|
||||
<h2 class="t">{{ __('stakeholder.lm.empty_title') }}</h2>
|
||||
<div class="b">{{ __('stakeholder.lm.empty_body') }}</div>
|
||||
@if (($scope ?? '') === 'strategy')
|
||||
@if ($lmFoundStr !== '')
|
||||
<div class="found">
|
||||
<i class="fa fa-circle-check" aria-hidden="true"></i>
|
||||
<span>{!! sprintf(e(__('stakeholder.lm.empty_found')), $lmFoundStr) !!}</span>
|
||||
</div>
|
||||
@endif
|
||||
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="cta">
|
||||
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.lm.empty_cta') }}
|
||||
</a>
|
||||
<div class="hint">{{ __('stakeholder.lm.empty_hint') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$stages = $logicModel['coverageMatrix']['stages'] ?? [];
|
||||
$columns = $logicModel['coverageMatrix']['columns'] ?? [];
|
||||
$unaligned = $logicModel['coverageMatrix']['unalignedColumns'] ?? [];
|
||||
$healthBadges = $logicModel['healthBadges'] ?? [];
|
||||
$projectLinks = $logicModel['projectLinks'] ?? [];
|
||||
$linkedGoals = $logicModel['linkedGoals'] ?? [];
|
||||
$projectMeta = $logicModel['projectMeta'] ?? [];
|
||||
|
||||
$activityItems = $stages['activities']['items'] ?? [];
|
||||
$outputItems = $stages['outputs']['items'] ?? [];
|
||||
$outcomeItems = $stages['outcomes']['items'] ?? [];
|
||||
$impactItems = $stages['impact']['items'] ?? [];
|
||||
|
||||
$isEvaluated = fn ($item) => isset($projectLinks[(int) (((array) $item)['id'] ?? 0)])
|
||||
&& count($projectLinks[(int) (((array) $item)['id'] ?? 0)]) > 0;
|
||||
|
||||
// Belief line — templated, ≤220 chars.
|
||||
$activityDescs = array_slice(
|
||||
array_values(array_filter(array_map(fn ($it) => trim((string) (((array) $it)['description'] ?? '')), $activityItems))),
|
||||
0, 3
|
||||
);
|
||||
$activitiesSummary = '';
|
||||
if (count($activityDescs) > 0) {
|
||||
$lowered = array_map(fn ($s) => (mb_strtolower(mb_substr($s, 0, 1)) . mb_substr($s, 1)), $activityDescs);
|
||||
$activitiesSummary = count($lowered) === 1
|
||||
? $lowered[0]
|
||||
: (count($lowered) === 2
|
||||
? $lowered[0] . ' and ' . $lowered[1]
|
||||
: $lowered[0] . ', ' . $lowered[1] . ', and ' . $lowered[2]);
|
||||
}
|
||||
$impactSummary = '';
|
||||
if (count($impactItems) > 0) {
|
||||
$impactSummary = trim((string) (((array) $impactItems[0])['description'] ?? ''));
|
||||
// lowercase first char for grammar
|
||||
if ($impactSummary !== '') $impactSummary = mb_strtolower(mb_substr($impactSummary, 0, 1)) . mb_substr($impactSummary, 1);
|
||||
}
|
||||
$beliefLine = '';
|
||||
if ($activitiesSummary !== '' && $impactSummary !== '') {
|
||||
$beliefLine = sprintf(__('stakeholder.lm.belief_full'), $activitiesSummary, $impactSummary);
|
||||
} elseif ($activitiesSummary !== '') {
|
||||
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
|
||||
}
|
||||
if (mb_strlen($beliefLine) > 220 && $activitiesSummary !== '') {
|
||||
$beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary);
|
||||
}
|
||||
|
||||
// Infer the item's expected metric unit ONCE from its authored label.
|
||||
// Cached in $itemExpectedUnit so render never regexes. `%` → percent,
|
||||
// `$` → currency, anything else → number. This is the label's contract;
|
||||
// goals whose metricType disagrees are dropped from the item's rollup
|
||||
// (rather than silently pretending unrelated units are the same thing).
|
||||
$itemExpectedUnit = [];
|
||||
$inferItemUnit = function (string $desc): string {
|
||||
$d = trim($desc);
|
||||
if ($d === '') return 'number';
|
||||
if (str_contains($d, '%')) return 'percent';
|
||||
if (str_contains($d, '$')) return 'currency';
|
||||
return 'number';
|
||||
};
|
||||
foreach (array_merge($outputItems, $outcomeItems, $activityItems, $impactItems) as $it) {
|
||||
$iid = (int) (((array) $it)['id'] ?? 0);
|
||||
if ($iid > 0) $itemExpectedUnit[$iid] = $inferItemUnit((string) (((array) $it)['description'] ?? ''));
|
||||
}
|
||||
|
||||
// Goal-based aggregate for an item. Only goals whose unit matches the
|
||||
// item's expected unit contribute — mismatched goals are logged and
|
||||
// dropped (safety over silent lying). Returns null when no matching
|
||||
// goals exist, or the ratio is impossible.
|
||||
$itemGoalAggregate = function ($item) use ($projectLinks, $linkedGoals, $itemExpectedUnit) {
|
||||
$itemId = (int) (((array) $item)['id'] ?? 0);
|
||||
$expectedUnit = $itemExpectedUnit[$itemId] ?? 'number';
|
||||
$links = $projectLinks[$itemId] ?? [];
|
||||
$goals = [];
|
||||
$mismatched = 0;
|
||||
foreach ($links as $link) {
|
||||
if (($link['linked_entity_type'] ?? '') !== 'goal') continue;
|
||||
$gid = (int) ($link['linked_entity_id'] ?? 0);
|
||||
if (! isset($linkedGoals[$gid])) continue;
|
||||
$g = $linkedGoals[$gid];
|
||||
$gUnit = ($g['metricType'] ?? 'number') === 'percent' ? 'percent'
|
||||
: (($g['metricType'] ?? 'number') === 'currency' ? 'currency' : 'number');
|
||||
if ($gUnit !== $expectedUnit) {
|
||||
$mismatched++;
|
||||
\Illuminate\Support\Facades\Log::info('page2: unit mismatch on link', [
|
||||
'itemId' => $itemId, 'expected' => $expectedUnit,
|
||||
'goalId' => $gid, 'goalUnit' => $gUnit,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$goals[] = $g;
|
||||
}
|
||||
if (count($goals) === 0) return null;
|
||||
|
||||
$current = array_sum(array_column($goals, 'currentValue'));
|
||||
$end = array_sum(array_column($goals, 'endValue'));
|
||||
$hasTarget = $end > 0;
|
||||
$ratio = $hasTarget ? ($current / $end) : null;
|
||||
if ($ratio !== null && $ratio > 5) {
|
||||
\Illuminate\Support\Facades\Log::warning('page2: implausible ratio', ['itemId' => $itemId, 'current' => $current, 'target' => $end]);
|
||||
$hasTarget = false;
|
||||
}
|
||||
|
||||
$anyRisk = false; $allOnTrack = true;
|
||||
foreach ($goals as $g) {
|
||||
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') $anyRisk = true;
|
||||
if ($g['status'] !== 'status_ontrack') $allOnTrack = false;
|
||||
}
|
||||
return [
|
||||
'goals' => $goals,
|
||||
'current' => $current,
|
||||
'end' => $end,
|
||||
'hasTarget' => $hasTarget,
|
||||
'pct' => $hasTarget ? min(100, (int) round($ratio * 100)) : 0,
|
||||
'metricType' => $expectedUnit,
|
||||
'ragClass' => $anyRisk ? 'risk' : ($allOnTrack ? 'ok' : 'wip'),
|
||||
'mismatched' => $mismatched,
|
||||
];
|
||||
};
|
||||
|
||||
// Format a metric respecting its type (percent vs number).
|
||||
$fmtMetric = function ($value, string $metricType) {
|
||||
$value = (float) $value;
|
||||
if ($metricType === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%';
|
||||
if ($value == floor($value)) return number_format($value, 0, '.', ',');
|
||||
return number_format($value, 1, '.', ',');
|
||||
};
|
||||
|
||||
// Strip leading numeric token from label when it's redundant with a goal
|
||||
// aggregate. "1,200 screenings completed" → "screenings completed".
|
||||
$stripLeadingNumber = function (string $desc): string {
|
||||
if (preg_match('/^[\d][\d,\.]*(?:%|\+|)?\s+(.+)$/u', trim($desc), $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
return trim($desc);
|
||||
};
|
||||
|
||||
// Stage-level aggregate — total contributions across items in the stage.
|
||||
$stageAgg = function (array $items) use ($isEvaluated, $itemGoalAggregate) {
|
||||
$total = 0.0; $end = 0.0; $anyRisk = false; $rolled = [];
|
||||
foreach ($items as $item) {
|
||||
if (! $isEvaluated($item)) continue;
|
||||
$a = $itemGoalAggregate($item);
|
||||
if ($a === null) continue;
|
||||
$itemId = (int) (((array) $item)['id'] ?? 0);
|
||||
$rolled[$itemId] = $a;
|
||||
$total += $a['current'];
|
||||
$end += $a['end'];
|
||||
if ($a['ragClass'] === 'risk') $anyRisk = true;
|
||||
}
|
||||
$pct = $end > 0 ? min(100, (int) round(($total / $end) * 100)) : 0;
|
||||
return ['rolled' => $rolled, 'pct' => $pct, 'anyRisk' => $anyRisk, 'total' => $total, 'end' => $end];
|
||||
};
|
||||
|
||||
// Per-program contribution across a stage — for Show-the-breakdown and
|
||||
// the templated read. Returns {contributors, unresolvedShare}.
|
||||
//
|
||||
// A goal is UNRESOLVED when its owning project is missing, has no
|
||||
// parent program, or is itself the strategy — those are never
|
||||
// contributors (a strategy is not its own program; a null bucket is
|
||||
// not a colleague). Unresolved value is tracked separately so the
|
||||
// page can render an honest data-quality note without personifying
|
||||
// the null bucket as a named entity.
|
||||
$stageProgramRollup = function (array $items) use ($isEvaluated, $itemGoalAggregate, $projectMeta) {
|
||||
$perProgram = [];
|
||||
$unresolvedCurrent = 0.0;
|
||||
$unresolvedEnd = 0.0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! $isEvaluated($item)) continue;
|
||||
$a = $itemGoalAggregate($item);
|
||||
if ($a === null) continue;
|
||||
|
||||
$itemId = (int) (((array) $item)['id'] ?? 0);
|
||||
$itemDesc = trim((string) (((array) $item)['description'] ?? ''));
|
||||
|
||||
foreach ($a['goals'] as $g) {
|
||||
$pid = $g['projectId'];
|
||||
$progId = $g['programId'];
|
||||
// Unresolved: no project, no program, or the "project" is
|
||||
// actually a strategy row (which happens when a goal lives
|
||||
// on the strategy's own canvas).
|
||||
$isUnresolved = ($pid === null)
|
||||
|| ($progId === null)
|
||||
|| (($g['projectType'] ?? '') === 'strategy');
|
||||
if ($isUnresolved) {
|
||||
$unresolvedCurrent += $g['currentValue'];
|
||||
$unresolvedEnd += $g['endValue'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $progId;
|
||||
if (! isset($perProgram[$key])) {
|
||||
$perProgram[$key] = [
|
||||
'id' => $progId,
|
||||
'name' => $projectMeta[$pid]['programName'] ?? ('#'.$progId),
|
||||
'current' => 0.0,
|
||||
'end' => 0.0,
|
||||
'anyRisk' => false,
|
||||
'allOnTrack' => true,
|
||||
'projects' => [],
|
||||
'riskItems' => [], // {id, description} for citation in exception line
|
||||
];
|
||||
}
|
||||
$perProgram[$key]['current'] += $g['currentValue'];
|
||||
$perProgram[$key]['end'] += $g['endValue'];
|
||||
if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') {
|
||||
$perProgram[$key]['anyRisk'] = true;
|
||||
// Track the ITEM this contributor caused risk on — the read
|
||||
// uses it to make exception lines cite what's off.
|
||||
$alreadyTracked = false;
|
||||
foreach ($perProgram[$key]['riskItems'] as $ri) {
|
||||
if ($ri['id'] === $itemId) { $alreadyTracked = true; break; }
|
||||
}
|
||||
if (! $alreadyTracked) {
|
||||
$perProgram[$key]['riskItems'][] = ['id' => $itemId, 'description' => $itemDesc];
|
||||
}
|
||||
}
|
||||
if ($g['status'] !== 'status_ontrack') $perProgram[$key]['allOnTrack'] = false;
|
||||
|
||||
// Project row aggregation inside program. Skip strategy-typed
|
||||
// projects (defense-in-depth: already filtered above).
|
||||
if (($g['projectType'] ?? '') === 'strategy') continue;
|
||||
|
||||
$found = false;
|
||||
foreach ($perProgram[$key]['projects'] as &$pj) {
|
||||
if ($pj['id'] === $pid) {
|
||||
$pj['current'] += $g['currentValue'];
|
||||
$pj['end'] += $g['endValue'];
|
||||
if ($g['status'] === 'status_atrisk') $pj['ragClass'] = 'risk';
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($pj);
|
||||
if (! $found) {
|
||||
$perProgram[$key]['projects'][] = [
|
||||
'id' => $pid,
|
||||
'name' => $projectMeta[$pid]['name'] ?? ('#'.$pid),
|
||||
'current' => $g['currentValue'],
|
||||
'end' => $g['endValue'],
|
||||
'ragClass' => $g['status'] === 'status_atrisk' ? 'risk' : ($g['status'] === 'status_ontrack' ? 'ok' : 'wip'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renormalize shares over RESOLVED contributors only. Unresolved
|
||||
// value doesn't dilute the lead's share — it's tracked separately
|
||||
// as a data-quality signal.
|
||||
$resolvedTotal = array_sum(array_column($perProgram, 'current'));
|
||||
foreach ($perProgram as &$prog) {
|
||||
$prog['pct'] = $resolvedTotal > 0 ? (int) round(($prog['current'] / $resolvedTotal) * 100) : 0;
|
||||
$prog['ragClass'] = $prog['anyRisk'] ? 'risk' : ($prog['allOnTrack'] ? 'ok' : 'wip');
|
||||
$prog['statusWord'] = $prog['anyRisk'] ? __('stakeholder.lm.status_word_behind') : ($prog['allOnTrack'] ? __('stakeholder.lm.status_word_ontrack') : __('stakeholder.lm.status_word_ramping'));
|
||||
usort($prog['projects'], fn ($a, $b) => $b['current'] <=> $a['current']);
|
||||
}
|
||||
unset($prog);
|
||||
uasort($perProgram, fn ($a, $b) => $b['pct'] <=> $a['pct']);
|
||||
|
||||
$grandTotal = $resolvedTotal + $unresolvedCurrent;
|
||||
$unresolvedShare = $grandTotal > 0 ? (int) round(($unresolvedCurrent / $grandTotal) * 100) : 0;
|
||||
|
||||
return [
|
||||
'contributors' => array_values($perProgram),
|
||||
'unresolvedShare' => $unresolvedShare,
|
||||
'hasUnresolved' => $unresolvedCurrent > 0 || $unresolvedEnd > 0,
|
||||
];
|
||||
};
|
||||
|
||||
// Templated read — max 2 lines. Vocabulary is fixed per punch-list §2.
|
||||
// BRANCHES ON CONTRIBUTOR COUNT:
|
||||
// 1 → single-contributor sentence (scoped to the stage — "on outputs
|
||||
// here", not bare "on this" — so cross-card contradictions read
|
||||
// as legitimate scope differences)
|
||||
// 2 → lead is carrying + second adds the rest
|
||||
// 3+ → lead + weighted exception, materiality threshold 20%
|
||||
// Exception lines CITE THE ITEM the contributor is off on — the row
|
||||
// status and the read reconcile via the item name.
|
||||
$renderRead = function (array $programs, string $scopeLabel) {
|
||||
$n = count($programs);
|
||||
if ($n === 0) return [];
|
||||
|
||||
$statusLead = fn ($p) => match ($p['ragClass']) {
|
||||
'ok' => '<span class="g">' . e($p['statusWord']) . '</span>',
|
||||
'risk' => '<span class="r">' . e($p['statusWord']) . '</span>',
|
||||
default => '<span class="w">' . e($p['statusWord']) . '</span>',
|
||||
};
|
||||
|
||||
// ── Single-contributor branch: no shares, no driver language.
|
||||
// Scoped ("outputs here" / "outcomes here") so a program appearing
|
||||
// in both cards with different statuses reads as legitimate scope,
|
||||
// not contradiction.
|
||||
if ($n === 1) {
|
||||
$only = $programs[0];
|
||||
$type = $only['ragClass'] === 'risk' ? 'risk' : ($only['ragClass'] === 'ok' ? 'good' : 'watch');
|
||||
return [[
|
||||
'type' => $type,
|
||||
'html' => sprintf(
|
||||
__('stakeholder.lm.read_only_program_scoped'),
|
||||
e($only['name']),
|
||||
e($scopeLabel),
|
||||
$statusLead($only)
|
||||
),
|
||||
]];
|
||||
}
|
||||
|
||||
$lead = $programs[0];
|
||||
$second = $programs[1] ?? null;
|
||||
$lines = [];
|
||||
|
||||
// Helper: name the specific item this contributor is off on.
|
||||
// Cites the first risk item; if none, empty string skips the
|
||||
// "on X" clause.
|
||||
$riskItemName = function (array $prog): string {
|
||||
$items = $prog['riskItems'] ?? [];
|
||||
return count($items) > 0 ? (string) ($items[0]['description'] ?? '') : '';
|
||||
};
|
||||
|
||||
$exceptionLine = function (array $prog, bool $critical) use ($statusLead, $riskItemName) {
|
||||
$item = $riskItemName($prog);
|
||||
$onClause = $item !== '' ? sprintf(__('stakeholder.lm.read_on_item_clause'), e($item)) : '';
|
||||
$tmpl = $critical ? 'stakeholder.lm.read_critical_cited' : 'stakeholder.lm.read_watch_cited';
|
||||
// Order: NAME is STATUS_WORD on ITEM — but at X% ...
|
||||
// statusLead comes BEFORE onClause; onClause carries its own
|
||||
// leading space so tokens don't run together.
|
||||
return [
|
||||
'type' => $critical ? 'risk' : 'watch',
|
||||
'html' => '<span class="lead-label">' . e($critical ? __('stakeholder.lm.critical_label') : __('stakeholder.lm.watch_label')) . ':</span> '
|
||||
. sprintf(
|
||||
__($tmpl),
|
||||
e($prog['name']),
|
||||
$statusLead($prog),
|
||||
$onClause,
|
||||
$prog['pct']
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
// ── Two-contributor branch: lead + second.
|
||||
// If second is the exception, DROP the "adds the rest" clause —
|
||||
// otherwise it names the second twice (benign in line 1, behind
|
||||
// in line 2). Never two lines about the same entity.
|
||||
if ($n === 2) {
|
||||
$secondIsException = $second['ragClass'] !== 'ok';
|
||||
$goingWellHtml = sprintf(
|
||||
__('stakeholder.lm.read_going_well'),
|
||||
e($lead['name']),
|
||||
$lead['pct'],
|
||||
$statusLead($lead)
|
||||
);
|
||||
if (! $secondIsException) {
|
||||
$goingWellHtml .= sprintf(__('stakeholder.lm.read_second_clause'), e($second['name']));
|
||||
}
|
||||
$lines[] = ['type' => 'good', 'html' => $goingWellHtml];
|
||||
if ($secondIsException) {
|
||||
$lines[] = $exceptionLine($second, $second['pct'] >= 20);
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
|
||||
// ── 3+ contributor branch: lead + weighted exception.
|
||||
$exception = null;
|
||||
foreach ($programs as $p) {
|
||||
if ($p['ragClass'] !== 'ok') { $exception = $p; break; }
|
||||
}
|
||||
|
||||
$leadIsException = $exception !== null && $exception['id'] === $lead['id'];
|
||||
if (! $leadIsException) {
|
||||
$lines[] = [
|
||||
'type' => 'good',
|
||||
'html' => sprintf(
|
||||
__('stakeholder.lm.read_going_well'),
|
||||
e($lead['name']),
|
||||
$lead['pct'],
|
||||
$statusLead($lead)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($exception !== null) {
|
||||
$lines[] = $exceptionLine($exception, $exception['pct'] >= 20 || $leadIsException);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
};
|
||||
|
||||
// Weakest fragile link for the Risk block — one, not four.
|
||||
$fragileLink = null;
|
||||
$riskPref = ['risk' => 0, 'warning' => 1];
|
||||
foreach ($healthBadges as $badge) {
|
||||
$s = $badge['health_status'] ?? '';
|
||||
if (! isset($riskPref[$s])) continue;
|
||||
if ($fragileLink === null || $riskPref[$s] < $riskPref[$fragileLink['health_status']]) {
|
||||
$fragileLink = $badge;
|
||||
continue;
|
||||
}
|
||||
// Tie-break on risk_level (higher = worse).
|
||||
if ($s === $fragileLink['health_status'] && ((int) ($badge['risk_level'] ?? 0)) > ((int) ($fragileLink['risk_level'] ?? 0))) {
|
||||
$fragileLink = $badge;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="p2-wrap">
|
||||
|
||||
<div class="p2-subhead">{{ __('stakeholder.lm.page_subhead') }}</div>
|
||||
|
||||
{{-- Belief — one templated sentence --}}
|
||||
<div class="p2-believe">
|
||||
<span class="lb">{{ __('stakeholder.lm.believe_label') }}</span>
|
||||
@if ($beliefLine !== '')
|
||||
{{ $beliefLine }}
|
||||
@else
|
||||
{{ __('stakeholder.lm.belief_empty') }}
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stage card renderer — one call for Outputs, one for Outcomes. --}}
|
||||
@foreach ([
|
||||
['key' => 'outputs', 'items' => $outputItems, 'frame' => __('stakeholder.lm.frame_producing_outputs'), 'icon' => 'fa-boxes-stacked', 'cls' => 'outputs'],
|
||||
['key' => 'outcomes', 'items' => $outcomeItems, 'frame' => __('stakeholder.lm.frame_achieving_outcomes'), 'icon' => 'fa-chart-line', 'cls' => 'outcomes'],
|
||||
] as $stage)
|
||||
@php
|
||||
$stageItems = array_values(array_filter($stage['items'], $isEvaluated));
|
||||
if (count($stageItems) === 0) continue;
|
||||
|
||||
$agg = $stageAgg($stage['items']);
|
||||
$rollupResult = $stageProgramRollup($stage['items']);
|
||||
$rollup = $rollupResult['contributors'];
|
||||
$scopeLabel = $stage['key'] === 'outputs' ? __('stakeholder.lm.scope_outputs') : __('stakeholder.lm.scope_outcomes');
|
||||
$readLns = $renderRead($rollup, $scopeLabel);
|
||||
|
||||
// Badge derives from the READ's outcome, not from item-level rollup.
|
||||
// This makes the badge the read's headline — a reader can't catch
|
||||
// the card contradicting itself (v4 Fix 4).
|
||||
// No resolved contributors → "no evidence yet"
|
||||
// Any Critical line → "At risk"
|
||||
// Any Watch line only → "In progress"
|
||||
// All going-well → "On track"
|
||||
$readTypes = array_column($readLns, 'type');
|
||||
if (count($rollup) === 0) {
|
||||
$badgeCls = 'pending';
|
||||
$badgeLbl = __('stakeholder.lm.no_evidence_yet');
|
||||
} elseif (in_array('risk', $readTypes, true)) {
|
||||
$badgeCls = 'risk';
|
||||
$badgeLbl = __('stakeholder.lm.verdict_at_risk');
|
||||
} elseif (in_array('watch', $readTypes, true)) {
|
||||
$badgeCls = 'wip';
|
||||
$badgeLbl = __('stakeholder.lm.verdict_in_progress');
|
||||
} else {
|
||||
$badgeCls = 'ok';
|
||||
$badgeLbl = __('stakeholder.lm.verdict_on_track');
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="p2-stage {{ $stage['cls'] }}">
|
||||
<div class="stage-hd">
|
||||
<div class="frame"><i class="fa {{ $stage['icon'] }}"></i> {{ $stage['frame'] }}</div>
|
||||
<span class="p2-vbadge {{ $badgeCls }}"><span class="sd"></span> {{ $badgeLbl }}</span>
|
||||
</div>
|
||||
|
||||
@if (count($readLns) > 0)
|
||||
<div class="p2-read">
|
||||
@foreach ($readLns as $ln)
|
||||
<div class="p2-readline {{ $ln['type'] }}">
|
||||
<span class="dot"></span>
|
||||
<div>{!! $ln['html'] !!}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($rollupResult['hasUnresolved'])
|
||||
{{-- No resolved contributors, only unresolved: don't render a
|
||||
read; render the not-linked note alone. --}}
|
||||
<div class="p2-unresolved">{{ __('stakeholder.lm.not_linked_note') }}</div>
|
||||
@endif
|
||||
|
||||
@if (count($readLns) > 0 && $rollupResult['unresolvedShare'] >= 10)
|
||||
{{-- Data-quality note, muted, non-prose. Not a bullet, not in
|
||||
the read's voice. --}}
|
||||
<div class="p2-unresolved">{{ sprintf(__('stakeholder.lm.unresolved_note'), $rollupResult['unresolvedShare']) }}</div>
|
||||
@endif
|
||||
|
||||
<div class="p2-rows">
|
||||
@foreach ($stageItems as $item)
|
||||
@php
|
||||
$itemId = (int) (((array) $item)['id'] ?? 0);
|
||||
$arr = (array) $item;
|
||||
// Label is ALWAYS the authored description, verbatim
|
||||
// — never prefixed, never regex-parsed for a number.
|
||||
$label = trim((string) ($arr['description'] ?? ''));
|
||||
// Aggregate carries only unit-matched goals (see
|
||||
// itemGoalAggregate). When present, we can safely
|
||||
// render the current value as its own element beside
|
||||
// the label — no splice.
|
||||
$a = $agg['rolled'][$itemId] ?? null;
|
||||
$currentDisplay = '';
|
||||
if ($a !== null) {
|
||||
// Row status = goal RAG (same as the contributor
|
||||
// rollup that feeds the badge). Not a pct
|
||||
// threshold — a row and its card must agree.
|
||||
if ($a['ragClass'] === 'risk') {
|
||||
$rowCls = 'risk'; $rowLbl = __('stakeholder.lm.status_at_risk');
|
||||
} elseif ($a['ragClass'] === 'ok') {
|
||||
$rowCls = 'ok'; $rowLbl = __('stakeholder.lm.status_on_track');
|
||||
} else {
|
||||
$rowCls = 'wip'; $rowLbl = __('stakeholder.lm.status_in_progress');
|
||||
}
|
||||
$currentDisplay = sprintf(__('stakeholder.lm.so_far'), $fmtMetric($a['current'], $a['metricType']));
|
||||
} else {
|
||||
$rowCls = 'pending';
|
||||
$rowLbl = __('stakeholder.lm.status_pending');
|
||||
}
|
||||
@endphp
|
||||
@php
|
||||
// Provenance for the row status — surfaced as a
|
||||
// tooltip on hover so the reader can trace it back to
|
||||
// a person and date without instructional prose on
|
||||
// the page. Format: "Set by {Author} · {Date}". Falls
|
||||
// back to a generic note when author/date is missing.
|
||||
$statusBasis = '';
|
||||
if ($a !== null && count($a['goals']) > 0) {
|
||||
$sourceGoal = null;
|
||||
foreach ($a['goals'] as $ag) {
|
||||
if ($sourceGoal === null || (string) ($ag['modified'] ?? '') > (string) ($sourceGoal['modified'] ?? '')) {
|
||||
$sourceGoal = $ag;
|
||||
}
|
||||
}
|
||||
$author = trim((string) ($sourceGoal['authorName'] ?? ''));
|
||||
$dateStr = '';
|
||||
$mod = (string) ($sourceGoal['modified'] ?? '');
|
||||
if ($mod !== '') {
|
||||
try { $dateStr = (new \DateTimeImmutable($mod))->format('M j'); } catch (\Exception $e) {}
|
||||
}
|
||||
if ($author !== '' && $dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who_when'), $author, $dateStr);
|
||||
elseif ($author !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who'), $author);
|
||||
elseif ($dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_when'), $dateStr);
|
||||
}
|
||||
@endphp
|
||||
<div class="p2-row">
|
||||
<div class="p2-row-title">
|
||||
{{ $label }}
|
||||
@if ($currentDisplay !== '')
|
||||
<span class="p2-row-value">{{ $currentDisplay }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="p2-row-status {{ $rowCls }}"@if ($statusBasis !== '') data-tippy-content="{{ $statusBasis }}"@endif><span class="sd"></span> {{ $rowLbl }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (count($rollup) > 0)
|
||||
<details class="p2-brk">
|
||||
<summary><i class="fa fa-chevron-right"></i> {{ __('stakeholder.lm.show_breakdown') }}</summary>
|
||||
<div class="p2-brk-body">
|
||||
@php
|
||||
// Same contributor-count branch as the read:
|
||||
// n = 1 → no bar, no percent, no "100%" tautology
|
||||
// n ≥ 2 → name + count + share bar + share % + status
|
||||
$contribCount = count($rollup);
|
||||
@endphp
|
||||
@foreach ($rollup as $prog)
|
||||
@php
|
||||
$showProjects = array_slice($prog['projects'], 0, 4);
|
||||
$moreProj = max(0, count($prog['projects']) - 4);
|
||||
$projCount = count($prog['projects']);
|
||||
$nProjectsLbl = $projCount === 1
|
||||
? __('stakeholder.lm.n_project_one')
|
||||
: sprintf(__('stakeholder.lm.n_projects'), $projCount);
|
||||
$pstatLbl = $prog['ragClass'] === 'risk'
|
||||
? __('stakeholder.lm.status_at_risk')
|
||||
: ($prog['ragClass'] === 'ok' ? __('stakeholder.lm.status_on_track') : __('stakeholder.lm.status_in_progress'));
|
||||
@endphp
|
||||
<div class="p2-brk-prog">
|
||||
@if ($contribCount === 1)
|
||||
<div class="p2-brk-progrow single">
|
||||
<span class="pdot"></span>
|
||||
<div>
|
||||
<div class="pn">{{ $prog['name'] }}</div>
|
||||
<div class="pmeta">{{ $nProjectsLbl }}</div>
|
||||
</div>
|
||||
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="p2-brk-progrow">
|
||||
<span class="pdot"></span>
|
||||
<div>
|
||||
<div class="pn">{{ $prog['name'] }}</div>
|
||||
<div class="pmeta">{{ $nProjectsLbl }}</div>
|
||||
</div>
|
||||
<div class="pbar"><i style="width:{{ min(100, $prog['pct']) }}%;"></i></div>
|
||||
<div class="pshare">{{ $prog['pct'] }}%</div>
|
||||
<span class="pstat {{ $prog['ragClass'] }}"><span class="sd"></span> {{ $pstatLbl }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (count($showProjects) > 0)
|
||||
<div class="p2-brk-projs">
|
||||
@foreach ($showProjects as $pj)
|
||||
<div class="p2-brk-pj">
|
||||
<span class="pjn">{{ $pj['name'] }}</span>
|
||||
<span class="pjd {{ $pj['ragClass'] }}"></span>
|
||||
</div>
|
||||
@endforeach
|
||||
@if ($moreProj > 0)
|
||||
<div class="p2-brk-more">+ {{ $moreProj }} {{ __('stakeholder.lm.more_word') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Impact — one aim, not a list --}}
|
||||
@if (count($impactItems) > 0)
|
||||
@php $primaryImpact = trim((string) (((array) $impactItems[0])['description'] ?? '')); @endphp
|
||||
@if ($primaryImpact !== '')
|
||||
<div class="p2-impact">
|
||||
<div class="lb"><i class="fa fa-bullseye"></i> {{ __('stakeholder.lm.for_what_label') }}</div>
|
||||
<div class="goal">{{ $primaryImpact }}</div>
|
||||
<div class="horizon">{{ __('stakeholder.lm.impact_horizon') }}</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Risk — one weakest fragile link, single sentence, single period --}}
|
||||
@if ($fragileLink !== null)
|
||||
@php
|
||||
$assumption = trim((string) ($fragileLink['assumption_text'] ?? ''));
|
||||
$assumption = rtrim($assumption, '.!?');
|
||||
$connector = trim((string) ($fragileLink['connector_label'] ?? ''));
|
||||
@endphp
|
||||
<div class="p2-risk">
|
||||
<i class="fa fa-triangle-exclamation ri"></i>
|
||||
<div class="rb">
|
||||
<span class="rl">{{ __('stakeholder.lm.risk_label') }}</span>
|
||||
@if ($assumption !== '')
|
||||
{{ __('stakeholder.lm.risk_leap_intro') }} <b>{{ $assumption }}</b>.
|
||||
@elseif ($connector !== '')
|
||||
{{ __('stakeholder.lm.risk_generic_intro') }} <b>{{ $connector }}</b>.
|
||||
@endif
|
||||
@if (empty($fragileLink['has_data']))
|
||||
<b>{{ __('stakeholder.lm.risk_no_evidence') }}</b>{{ __('stakeholder.lm.risk_keep_honest') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Also this period — real completed work with NO LM link.
|
||||
Item-grain drift (unalignedColumns names program-grain drift; this
|
||||
is one level finer). Each row is a completion that touched real
|
||||
numbers but maps to nothing in the model — a link-me-to-an-outcome
|
||||
invitation, not a scold. Silent when nothing is unlinked. --}}
|
||||
@php
|
||||
$completedThisPeriod = (array) ($report['milestones']['completed'] ?? []);
|
||||
// Build the set of milestone IDs any LM item links to.
|
||||
$linkedMilestoneIds = [];
|
||||
foreach ($projectLinks as $itemLinks) {
|
||||
foreach ($itemLinks as $link) {
|
||||
if (($link['linked_entity_type'] ?? '') === 'milestone') {
|
||||
$linkedMilestoneIds[(int) $link['linked_entity_id']] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// "Something real to show" per the spec — a metric, a count, OR
|
||||
// a completion. A completed milestone IS a completion, so a
|
||||
// headline is sufficient; task stats are optional decoration.
|
||||
// Filter out anything with no headline (a bare row is noise).
|
||||
$alsoThisPeriod = [];
|
||||
foreach ($completedThisPeriod as $ms) {
|
||||
$mid = (int) ($ms->id ?? 0);
|
||||
if ($mid === 0 || isset($linkedMilestoneIds[$mid])) continue;
|
||||
$headline = trim((string) ($ms->headline ?? ''));
|
||||
if ($headline === '') continue;
|
||||
$taskStats = (array) ($ms->taskStats ?? []);
|
||||
$doneCount = (int) ($taskStats['done'] ?? 0);
|
||||
$totalCount = (int) ($taskStats['total'] ?? 0);
|
||||
$metric = $totalCount > 0
|
||||
? sprintf(__('stakeholder.lm.also_metric_tasks'), $doneCount, $totalCount)
|
||||
: '';
|
||||
$alsoThisPeriod[] = [
|
||||
'id' => $mid,
|
||||
'headline' => $headline,
|
||||
'metric' => $metric,
|
||||
'projectId' => (int) ($ms->projectId ?? 0),
|
||||
'canvasId' => (int) ($logicModel['canvasId'] ?? 0),
|
||||
];
|
||||
}
|
||||
$alsoCount = count($alsoThisPeriod);
|
||||
$alsoShown = array_slice($alsoThisPeriod, 0, 3);
|
||||
$alsoMore = max(0, $alsoCount - 3);
|
||||
@endphp
|
||||
|
||||
@if ($alsoCount > 0)
|
||||
<div class="p2-also">
|
||||
<div class="lb"><i class="fa fa-code-branch"></i> {{ __('stakeholder.lm.also_label') }}</div>
|
||||
@foreach ($alsoShown as $row)
|
||||
{{-- Plain drift note: a milestone that closed this period but
|
||||
doesn't map to a Logic Model outcome. No CTA — the app
|
||||
doesn't currently have a one-click "link a milestone to
|
||||
an outcome" flow, and a dead link on the honesty page is
|
||||
worse than no link. When the flow lands, wire it here. --}}
|
||||
<div class="row">
|
||||
<b>{{ $row['headline'] }}</b>@if ($row['metric'] !== '') · <span class="metric">{{ $row['metric'] }}</span>@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@if ($alsoMore > 0)
|
||||
<div class="more">{{ sprintf(__('stakeholder.lm.also_more'), $alsoMore) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Off-strategy drift (strategy scope) --}}
|
||||
@if (($scope ?? '') === 'strategy' && count($unaligned) > 0)
|
||||
@php
|
||||
$unalignedNames = array_map(
|
||||
fn ($id) => $columns[$id]['name'] ?? ('#'.$id),
|
||||
array_slice($unaligned, 0, 5)
|
||||
);
|
||||
$moreDrift = max(0, count($unaligned) - 5);
|
||||
@endphp
|
||||
<div class="p2-drift">
|
||||
<i class="fa fa-diagram-project"></i>
|
||||
<div>
|
||||
<b>{{ __('stakeholder.lm.drift_label') }}:</b>
|
||||
{{ sprintf(__('stakeholder.lm.drift_hint'), count($unaligned)) }}
|
||||
<em>{{ implode(', ', $unalignedNames) }}@if ($moreDrift > 0) +{{ $moreDrift }} @endif</em>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,626 @@
|
||||
{{--
|
||||
Stakeholder Report — Page 1 (Overview)
|
||||
|
||||
§5 page 1: KPI band + peak-this-period hero + needs-attention +
|
||||
Theory-of-Change narrative + theory-health strip.
|
||||
|
||||
Vars in (from deck):
|
||||
$completedCount int
|
||||
$completedDelta int (may be negative)
|
||||
$goalsOnTrack int
|
||||
$goalsTotal int
|
||||
$overdueCount int
|
||||
$hoursLogged float
|
||||
$needsAttn array — $report['needsAttention']
|
||||
$logicModel null | {narrative, healthBadges, ...}
|
||||
$hasLM bool
|
||||
--}}
|
||||
|
||||
|
||||
{{-- ── KPI band (value-first, delta inline, lowercase label) ──────── --}}
|
||||
@php
|
||||
// Drill-down source data. Show top 5 per cell, "+N more" tail if longer.
|
||||
$completedItems = array_slice($report['milestones']['completed'] ?? [], 0, 5);
|
||||
$completedMoreCount = max(0, count($report['milestones']['completed'] ?? []) - 5);
|
||||
$overdueItems = array_slice($report['needsAttention']['overdueMilestones'] ?? [], 0, 5);
|
||||
$overdueMoreCount = max(0, count($report['needsAttention']['overdueMilestones'] ?? []) - 5);
|
||||
|
||||
// Denominator counts for KPI context — a raw "3 overdue" doesn't say
|
||||
// "out of how many". Total milestones = completed + inFlight + overdue +
|
||||
// upcoming; open = the not-yet-done subset (inFlight + overdue + upcoming).
|
||||
$inFlightCount = (int) ($stats['inFlight'] ?? 0);
|
||||
$upcomingCount = (int) ($stats['upcoming'] ?? 0);
|
||||
$openMsCount = $inFlightCount + $overdueCount + $upcomingCount;
|
||||
$totalMsCount = $completedCount + $openMsCount;
|
||||
// Drill on "Goals on track" lists the ON-TRACK goals (the number the cell
|
||||
// represents). The count ($goalsOnTrack) comes from $stats, which the engine
|
||||
// derives from the FULL report ($report['goals']) across the strategy AND its
|
||||
// programs — so the drill list must read the same set, not $goalsGroup (which
|
||||
// is scoped to the strategy's own goals only and is empty when goals live on
|
||||
// programs, leaving a "9 on track" cell with an empty list). At-risk goals
|
||||
// surface in the Needs Attention block.
|
||||
$allGoalsForDrill = $report['goals']['goals'] ?? ($goalsGroup['goals'] ?? []);
|
||||
$onTrackAll = array_filter($allGoalsForDrill, fn ($g) => ((array) $g)['status'] === 'status_ontrack' || (is_object($g) && ($g->status ?? '') === 'status_ontrack'));
|
||||
$onTrackAll = array_values($onTrackAll);
|
||||
$onTrackItems = array_slice($onTrackAll, 0, 5);
|
||||
$onTrackMoreCount = max(0, count($onTrackAll) - 5);
|
||||
|
||||
// Hours drill = per-project effort breakdown, sorted desc. Project names
|
||||
// come from $report['summaries'] (keyed by projectId with .name field).
|
||||
$effortByProj = $report['effort']['byProject'] ?? [];
|
||||
arsort($effortByProj);
|
||||
$projNames = [];
|
||||
foreach (($report['summaries'] ?? []) as $s) {
|
||||
$s = (object) $s;
|
||||
$projNames[(int) ($s->id ?? 0)] = (string) ($s->name ?? '');
|
||||
}
|
||||
$hoursItems = [];
|
||||
foreach ($effortByProj as $pid => $h) {
|
||||
if ($h <= 0) continue;
|
||||
$hoursItems[] = ['name' => $projNames[(int) $pid] ?? ('#'.$pid), 'hours' => (float) $h];
|
||||
}
|
||||
$hoursMoreCount = max(0, count($hoursItems) - 5);
|
||||
$hoursItems = array_slice($hoursItems, 0, 5);
|
||||
$fmtDate = fn ($v) => is_object($v) ? $v->setToUserTimezone()->format('M j') : ($v ? date('M j', strtotime((string) $v)) : '');
|
||||
@endphp
|
||||
<div class="rd-kpi">
|
||||
{{-- Completed --}}
|
||||
<div class="rd-kcell @if ($completedCount > 0) has-detail @endif" tabindex="{{ $completedCount > 0 ? 0 : -1 }}">
|
||||
@if ($completedCount > 0)
|
||||
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
||||
@endif
|
||||
<div class="kv">
|
||||
{{ $completedCount }}@if ($totalMsCount > 0)<small>/{{ $totalMsCount }}</small>@endif
|
||||
@if ($completedDelta > 0)
|
||||
<span class="up" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-up"></i> +{{ $completedDelta }}</span>
|
||||
@elseif ($completedDelta < 0)
|
||||
<span class="down" title="{{ sprintf(__('stakeholder.kpi.delta_vs_prior'), $completedDelta) }}"><i class="fa fa-arrow-down"></i> {{ $completedDelta }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="kl">{{ $totalMsCount > 0 ? __('stakeholder.kpi.milestones_completed') : __('stakeholder.kpi.completed_this_period') }}</div>
|
||||
@if ($completedCount > 0)
|
||||
<div class="kdrill">
|
||||
<div class="kd-hd">{{ __('stakeholder.kpi.drill.completed') }}</div>
|
||||
<ul>
|
||||
@foreach ($completedItems as $m)
|
||||
@php $m = (object) $m; @endphp
|
||||
<li>
|
||||
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
|
||||
<span class="mt">{{ $fmtDate($m->completedOn ?? $m->modified ?? null) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@if ($completedMoreCount > 0)
|
||||
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $completedMoreCount) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Goals on track — drill lists the ON-TRACK goals (what the count is) --}}
|
||||
<div class="rd-kcell @if (count($onTrackItems) > 0) has-detail @endif" tabindex="{{ count($onTrackItems) > 0 ? 0 : -1 }}">
|
||||
@if (count($onTrackItems) > 0)
|
||||
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
||||
@endif
|
||||
<div class="kv">{{ $goalsOnTrack }}<small>/{{ $goalsTotal }}</small></div>
|
||||
<div class="kl">{{ __('stakeholder.kpi.goals_on_track_lc') }}</div>
|
||||
@if (count($onTrackItems) > 0)
|
||||
<div class="kdrill">
|
||||
<div class="kd-hd">{{ __('stakeholder.kpi.drill.on_track') }}</div>
|
||||
<ul>
|
||||
@foreach ($onTrackItems as $g)
|
||||
@php $g = (object) $g; @endphp
|
||||
<li>
|
||||
<span class="nm" title="{{ $g->title ?? '' }}">{{ $g->title ?? $g->description ?? __('stakeholder.goals.untitled') }}</span>
|
||||
<span class="mt">{{ round((float) ($g->goalProgress ?? 0)) }}%</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@if ($onTrackMoreCount > 0)
|
||||
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $onTrackMoreCount) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Overdue milestones --}}
|
||||
<div class="rd-kcell @if ($overdueCount > 0) risk @endif @if ($overdueCount > 0) has-detail @endif" tabindex="{{ $overdueCount > 0 ? 0 : -1 }}">
|
||||
@if ($overdueCount > 0)
|
||||
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
||||
@endif
|
||||
<div class="kv">{{ $overdueCount }}@if ($openMsCount > 0)<small>/{{ $openMsCount }}</small>@endif</div>
|
||||
<div class="kl">{{ $openMsCount > 0 ? __('stakeholder.kpi.overdue_of_open') : __('stakeholder.kpi.milestones_overdue') }}</div>
|
||||
@if ($overdueCount > 0)
|
||||
<div class="kdrill">
|
||||
<div class="kd-hd">{{ __('stakeholder.kpi.drill.overdue') }}</div>
|
||||
<ul>
|
||||
@foreach ($overdueItems as $m)
|
||||
@php $m = (object) $m; @endphp
|
||||
<li>
|
||||
<span class="nm" title="{{ $m->headline ?? '' }}">{{ $m->headline ?? __('stakeholder.overview.na_untitled_milestone') }}</span>
|
||||
<span class="mt">{{ $m->projectName ?? '' }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@if ($overdueMoreCount > 0)
|
||||
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $overdueMoreCount) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Hours logged — drill lists per-project breakdown, largest first --}}
|
||||
<div class="rd-kcell @if (count($hoursItems) > 0) has-detail @endif" tabindex="{{ count($hoursItems) > 0 ? 0 : -1 }}">
|
||||
@if (count($hoursItems) > 0)
|
||||
<span class="see-list">{{ __('stakeholder.kpi.see_list') }} <i class="fa fa-chevron-down"></i></span>
|
||||
@endif
|
||||
<div class="kv">{{ number_format($hoursLogged, $hoursLogged >= 100 ? 0 : 1) }}<small>h</small></div>
|
||||
<div class="kl">{{ __('stakeholder.kpi.hours_this_period') }}</div>
|
||||
@if (count($hoursItems) > 0)
|
||||
<div class="kdrill">
|
||||
<div class="kd-hd">{{ __('stakeholder.kpi.drill.hours') }}</div>
|
||||
<ul>
|
||||
@foreach ($hoursItems as $h)
|
||||
<li>
|
||||
<span class="nm" title="{{ $h['name'] }}">{{ $h['name'] }}</span>
|
||||
<span class="mt">{{ number_format($h['hours'], $h['hours'] >= 100 ? 0 : 1) }}h</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@if ($hoursMoreCount > 0)
|
||||
<div class="kd-more">{{ sprintf(__('stakeholder.kpi.drill.more'), $hoursMoreCount) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Hero (peak this period) + Needs attention ─────────────────── --}}
|
||||
@php
|
||||
// Needs-attention detail from ReportEngine::buildNeedsAttention. Key names
|
||||
// verified against the service — the block reads items by NAME, not counts.
|
||||
$overdueMilestones = $needsAttn['overdueMilestones'] ?? [];
|
||||
$goalsAtRisk = $needsAttn['goalsAtRisk'] ?? [];
|
||||
$staleProjects = $needsAttn['staleProjects'] ?? [];
|
||||
$statusAlerts = $needsAttn['statusAlerts'] ?? [];
|
||||
$needsCount = count($overdueMilestones) + count($goalsAtRisk) + count($staleProjects) + count($statusAlerts);
|
||||
|
||||
// Peak-this-period nomination (recommend-and-override, §5 page 1 hero).
|
||||
// Rank candidates from completed-this-period milestones:
|
||||
// 1. Has non-empty outcomeImpact (structural closure carrying its own board narrative)
|
||||
// 2. Otherwise, most recent completion
|
||||
// The top pick is the recommendation; the (future) owner override lives here.
|
||||
$completed = $report['milestones']['completed'] ?? [];
|
||||
$withImpact = [];
|
||||
$withoutImpact = [];
|
||||
foreach ($completed as $m) {
|
||||
$m = (object) $m;
|
||||
$impact = trim((string) ($m->outcomeImpact ?? ''));
|
||||
if ($impact !== '') {
|
||||
$withImpact[] = $m;
|
||||
} else {
|
||||
$withoutImpact[] = $m;
|
||||
}
|
||||
}
|
||||
$sortByCompletion = fn ($a, $b) => strcmp((string) ($b->completedOn ?? $b->modified ?? ''), (string) ($a->completedOn ?? $a->modified ?? ''));
|
||||
usort($withImpact, $sortByCompletion);
|
||||
usort($withoutImpact, $sortByCompletion);
|
||||
$peak = $withImpact[0] ?? $withoutImpact[0] ?? null;
|
||||
$peakIsStrong = $peak !== null && trim((string) ($peak->outcomeImpact ?? '')) !== '';
|
||||
|
||||
if ($peak !== null) {
|
||||
$peakDate = ! empty($peak->completedOn)
|
||||
? (is_object($peak->completedOn) ? $peak->completedOn->setToUserTimezone()->format('M j') : date('M j', strtotime((string) $peak->completedOn)))
|
||||
: (! empty($peak->modified) ? date('M j', strtotime((string) $peak->modified)) : '');
|
||||
$peakBody = trim((string) ($peak->outcomeImpact ?? $peak->description ?? ''));
|
||||
// Strip any HTML that survived from a rich-text editor.
|
||||
$peakBody = trim(strip_tags($peakBody));
|
||||
}
|
||||
@endphp
|
||||
<div class="p1-topband">
|
||||
{{-- Peak this period — recommend-and-override. Ranked from completed
|
||||
milestones; owner override is a future write path. --}}
|
||||
@if ($peak === null)
|
||||
<div class="p1-hero empty">
|
||||
<div class="eye"><span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span></div>
|
||||
<div class="h">{{ __('stakeholder.overview.peak_none_title') }}</div>
|
||||
<div>{{ __('stakeholder.overview.peak_none_hint') }}</div>
|
||||
@if ($scope === 'strategy')
|
||||
{{-- The Logic Model is strategy-scoped, so only offer the
|
||||
"build it" CTA in a strategy report — a program report
|
||||
would link to the wrong (or no) canvas. --}}
|
||||
<a href="{{ BASE_URL }}/logicmodelcanvas/showCanvas" class="p1-hero-cta">
|
||||
<i class="fa fa-wand-magic-sparkles" aria-hidden="true"></i> {{ __('stakeholder.overview.peak_none_cta') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="p1-hero">
|
||||
<div class="eye">
|
||||
<span class="slabel">{{ __('stakeholder.overview.peak_label') }}</span>
|
||||
<span class="rec"><i class="fa fa-wand-magic-sparkles"></i> {{ __('stakeholder.overview.peak_recommended') }}</span>
|
||||
</div>
|
||||
<h3>{{ $peak->headline ?? '' }}</h3>
|
||||
@if ($peakBody !== '')
|
||||
<p>{{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}</p>
|
||||
@endif
|
||||
<div class="hf">
|
||||
@if (! empty($peak->projectName))
|
||||
<span class="badge-goal"><i class="fa fa-diagram-project"></i> {{ $peak->projectName }}</span>
|
||||
@endif
|
||||
@if (! $peakIsStrong)
|
||||
<span class="rec-note" title="{{ __('stakeholder.overview.peak_weak_tip') }}"><i class="fa fa-circle-info"></i> {{ __('stakeholder.overview.peak_weak_note') }}</span>
|
||||
@endif
|
||||
@if ($peakDate !== '')
|
||||
<span class="hm">{{ $peakDate }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="p1-needs @if ($needsCount === 0) calm @endif">
|
||||
<div class="nt"><i class="fa @if ($needsCount === 0) fa-check-circle @else fa-triangle-exclamation @endif"></i>{{ __('stakeholder.overview.needs_label') }}</div>
|
||||
<div class="nx">
|
||||
@if ($needsCount === 0)
|
||||
{{ __('stakeholder.overview.nothing_needs_attention') }}
|
||||
@else
|
||||
{{-- At-risk goals — named. The block loses its meaning as a plain count;
|
||||
a board wants to know WHICH goal is at risk to decide what to do. --}}
|
||||
@if (count($goalsAtRisk) > 0)
|
||||
<div class="na-grp">
|
||||
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_goals_hd'), count($goalsAtRisk)) }}</div>
|
||||
<ul class="na-items">
|
||||
@foreach (array_slice($goalsAtRisk, 0, 3) as $goal)
|
||||
@php
|
||||
$goal = (object) $goal;
|
||||
$isMiss = (string) $goal->status === 'status_miss';
|
||||
$title = trim((string) ($goal->title ?? $goal->description ?? __('stakeholder.goals.untitled')));
|
||||
$progress = round((float) ($goal->goalProgress ?? 0));
|
||||
@endphp
|
||||
<li>
|
||||
<span class="na-name">{{ $title }}</span>
|
||||
<span class="na-badge @if ($isMiss) miss @endif">{{ $isMiss ? __('stakeholder.goals.miss') : __('stakeholder.goals.atrisk') }}</span>
|
||||
<span class="na-meta">· {{ $progress }}%</span>
|
||||
</li>
|
||||
@endforeach
|
||||
@if (count($goalsAtRisk) > 3)
|
||||
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($goalsAtRisk) - 3) }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Overdue milestones — named + project. --}}
|
||||
@if (count($overdueMilestones) > 0)
|
||||
<div class="na-grp">
|
||||
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_milestones_hd'), count($overdueMilestones)) }}</div>
|
||||
<ul class="na-items">
|
||||
@foreach (array_slice($overdueMilestones, 0, 3) as $m)
|
||||
@php
|
||||
$m = (object) $m;
|
||||
$mname = trim((string) ($m->headline ?? __('stakeholder.overview.na_untitled_milestone')));
|
||||
$projName = trim((string) ($m->projectName ?? ''));
|
||||
@endphp
|
||||
<li>
|
||||
<span class="na-name">{{ $mname }}</span>
|
||||
@if ($projName !== '')
|
||||
<span class="na-meta">· {{ $projName }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
@if (count($overdueMilestones) > 3)
|
||||
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($overdueMilestones) - 3) }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stale/silent projects — quiet flag, one line per project. --}}
|
||||
@if (count($staleProjects) > 0)
|
||||
<div class="na-grp">
|
||||
<div class="na-hd">{{ sprintf(__('stakeholder.overview.na_silent_hd'), count($staleProjects)) }}</div>
|
||||
<ul class="na-items">
|
||||
@foreach (array_slice($staleProjects, 0, 3) as $p)
|
||||
@php $p = (object) $p; @endphp
|
||||
<li>
|
||||
<span class="na-name">{{ $p->name ?? '' }}</span>
|
||||
<span class="na-meta">· {{ __('stakeholder.overview.na_no_update_30d') }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
@if (count($staleProjects) > 3)
|
||||
<li class="na-more">{{ sprintf(__('stakeholder.overview.na_more'), count($staleProjects) - 3) }}</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Status narrative — authored, verbatim, per-program ──────────
|
||||
The one place on this page where a human says WHY and what they're
|
||||
doing about it. Everything else is computed. Renders portfolio note
|
||||
first (strategy scope) or the program's own note (program scope),
|
||||
then per-program notes newest-first, cap 4 total. Silent when zero
|
||||
notes exist — never apologizes. --}}
|
||||
@php
|
||||
// Assemble a normalized note list with a "portfolio" flag so the
|
||||
// portfolio note leads and per-program notes follow.
|
||||
$narrativeNotes = [];
|
||||
// strategyUpdates is keyed by projectId => array of updates (same shape as
|
||||
// programUpdates below) — take the newest note of the report subject itself.
|
||||
foreach (($strategyUpdates ?? []) as $notes) {
|
||||
$notesArr = is_array($notes) ? $notes : [$notes];
|
||||
if (empty($notesArr)) {
|
||||
continue;
|
||||
}
|
||||
$newest = $notesArr[0]; // repo returns newest first
|
||||
$narrativeNotes[] = [
|
||||
'label' => __('stakeholder.overview.narrative_portfolio'),
|
||||
'text' => trim(strip_tags((string) ($newest->text ?? ''))),
|
||||
'date' => (string) ($newest->date ?? ''),
|
||||
'portfolio' => true,
|
||||
'sortKey' => (string) ($newest->date ?? ''),
|
||||
];
|
||||
}
|
||||
// programUpdates is keyed by projectId => array of updates. Take the
|
||||
// newest update per program (already newest-first from the repo).
|
||||
$programNameById = [];
|
||||
foreach (($programRows ?? []) as $pr) {
|
||||
$pid = (int) (is_array($pr) ? ($pr['id'] ?? 0) : ($pr->id ?? 0));
|
||||
$nm = (string) (is_array($pr) ? ($pr['name'] ?? '') : ($pr->name ?? ''));
|
||||
if ($pid > 0) $programNameById[$pid] = $nm;
|
||||
}
|
||||
foreach (($programUpdates ?? []) as $projectId => $notes) {
|
||||
$projectId = (int) $projectId;
|
||||
$notesArr = is_array($notes) ? $notes : [];
|
||||
if (empty($notesArr)) continue;
|
||||
$newest = $notesArr[0]; // repo returns newest first
|
||||
$narrativeNotes[] = [
|
||||
'label' => $programNameById[$projectId] ?? __('stakeholder.overview.narrative_program_fallback'),
|
||||
'text' => trim(strip_tags((string) ($newest->text ?? ''))),
|
||||
'date' => (string) ($newest->date ?? ''),
|
||||
'portfolio' => false,
|
||||
'sortKey' => (string) ($newest->date ?? ''),
|
||||
];
|
||||
}
|
||||
// Portfolio always first, then per-program by date desc, cap at 4.
|
||||
usort($narrativeNotes, function ($a, $b) {
|
||||
if ($a['portfolio'] !== $b['portfolio']) return $a['portfolio'] ? -1 : 1;
|
||||
return strcmp($b['sortKey'], $a['sortKey']);
|
||||
});
|
||||
$narrativeNotes = array_values(array_filter($narrativeNotes, fn ($n) => $n['text'] !== ''));
|
||||
$narrativeNotes = array_slice($narrativeNotes, 0, 4);
|
||||
|
||||
$fmtNoteDate = function (string $iso) {
|
||||
if ($iso === '') return '';
|
||||
try { return (new \DateTimeImmutable(substr($iso, 0, 19)))->format('M j'); }
|
||||
catch (\Exception $e) { return ''; }
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="p1-narrative">
|
||||
<div class="lb"><i class="fa fa-message"></i> {{ __('stakeholder.overview.narrative_label') }}</div>
|
||||
<div class="nn">
|
||||
@forelse ($narrativeNotes as $n)
|
||||
<div class="nr @if ($n['portfolio']) portfolio @endif">
|
||||
<b>{{ $n['label'] }}</b> — {{ $n['text'] }}
|
||||
@if (($d = $fmtNoteDate($n['date'])) !== '')
|
||||
<span class="dt">{{ $d }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="nempty">{{ __('stakeholder.overview.narrative_empty') }}</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Theory of Change narrative (stage-colored) ────────────────── --}}
|
||||
@if ($hasLM && ! empty($logicModel['narrative']['hasItems']))
|
||||
@php $stageTexts = $logicModel['narrative']['stageTexts'] ?? []; @endphp
|
||||
<div class="p1-toc collapsed" id="p1TocSection">
|
||||
<div class="tl">
|
||||
<span class="lbl-inner">
|
||||
{{ __('stakeholder.overview.toc_label') }}
|
||||
<span class="p1-info" tabindex="0">
|
||||
<span class="ii" aria-hidden="true">i</span>
|
||||
<span class="pop" role="tooltip">
|
||||
<span class="h">{{ __('stakeholder.overview.color_legend') }}</span>
|
||||
<span class="lg"><span class="sw" style="background:var(--rd-s1)"></span><span class="nm">{{ __('box.logicmodel.inputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.inputs') }}</span></span>
|
||||
<span class="lg"><span class="sw" style="background:var(--rd-s2)"></span><span class="nm">{{ __('box.logicmodel.activities') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.activities') }}</span></span>
|
||||
<span class="lg"><span class="sw" style="background:var(--rd-s3)"></span><span class="nm">{{ __('box.logicmodel.outputs') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outputs') }}</span></span>
|
||||
<span class="lg"><span class="sw" style="background:var(--rd-s4)"></span><span class="nm">{{ __('box.logicmodel.outcomes') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.outcomes') }}</span></span>
|
||||
<span class="lg"><span class="sw" style="background:var(--rd-s5)"></span><span class="nm">{{ __('box.logicmodel.impact') }}</span><span class="sub">{{ __('stakeholder.lm.stage_sub.impact') }}</span></span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="toc-toggle" onclick="p1TocToggle(this)" aria-label="{{ __('stakeholder.overview.toc_toggle') }}">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tx">
|
||||
{{ __('stakeholder.overview.toc_by_investing') }}
|
||||
<span class="n1">{{ $stageTexts['inputs'] ?? '['.__('box.logicmodel.inputs').']' }}</span>
|
||||
{{ __('stakeholder.overview.toc_and_delivering') }}
|
||||
<span class="n2">{{ $stageTexts['activities'] ?? '['.__('box.logicmodel.activities').']' }}</span>,
|
||||
{{ __('stakeholder.overview.toc_we_produce') }}
|
||||
<span class="n3">{{ $stageTexts['outputs'] ?? '['.__('box.logicmodel.outputs').']' }}</span>
|
||||
— {{ __('stakeholder.overview.toc_toward') }}
|
||||
<span class="n4">{{ $stageTexts['outcomes'] ?? '['.__('box.logicmodel.outcomes').']' }}</span>,
|
||||
{{ __('stakeholder.overview.toc_in_service_of') }}
|
||||
<span class="n5">{{ $stageTexts['impact'] ?? '['.__('box.logicmodel.impact').']' }}</span>.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="p1-toc empty">{{ __('stakeholder.overview.toc_empty') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Theory-health strip — full state, not just warnings ──────── --}}
|
||||
@if ($hasLM)
|
||||
@php
|
||||
$badges = $logicModel['healthBadges'] ?? [];
|
||||
// Ensure we render all 4 connector slots even when a row is missing;
|
||||
// the missing case reads as "no assessment yet" (grey), not "ok".
|
||||
$bySlot = [];
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$b = $badges[$i] ?? null;
|
||||
$status = $b && ! empty($b['has_data']) ? (string) ($b['health_status'] ?? '') : '';
|
||||
$bySlot[$i] = [
|
||||
'status' => $status !== '' ? $status : 'none',
|
||||
'label' => $b['connector_label'] ?? '',
|
||||
'assumption' => trim((string) ($b['assumption_text'] ?? '')),
|
||||
'evidence' => trim((string) ($b['evidence_notes'] ?? '')),
|
||||
];
|
||||
}
|
||||
$counts = ['ok' => 0, 'warning' => 0, 'risk' => 0, 'none' => 0];
|
||||
$risky = [];
|
||||
foreach ($bySlot as $slot) {
|
||||
$counts[$slot['status']]++;
|
||||
if (in_array($slot['status'], ['warning', 'risk'], true)) {
|
||||
$risky[] = $slot;
|
||||
}
|
||||
}
|
||||
// Sort by severity so a `risk` link leads the detail callout over any
|
||||
// `warning` links — critical always takes priority in the board's read.
|
||||
usort($risky, fn ($a, $b) => ($b['status'] === 'risk' ? 1 : 0) <=> ($a['status'] === 'risk' ? 1 : 0));
|
||||
$solid = $counts['ok'];
|
||||
$fragile = $counts['warning'] + $counts['risk'];
|
||||
$unassessed = $counts['none'];
|
||||
$tone = $fragile > 0 ? 'risk' : ($unassessed > 0 ? 'partial' : 'solid');
|
||||
@endphp
|
||||
|
||||
<div class="p1-theory">
|
||||
<div class="hd">
|
||||
<span class="l">
|
||||
{{ __('stakeholder.overview.theory_health_label') }}
|
||||
<span class="p1-info" tabindex="0">
|
||||
<span class="ii" aria-hidden="true">i</span>
|
||||
<span class="pop" role="tooltip">
|
||||
{{ __('stakeholder.overview.theory_health_explain') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="rd @if ($fragile > 0) risk @endif">
|
||||
@if ($fragile === 0 && $unassessed === 0)
|
||||
<b>{{ sprintf(__('stakeholder.overview.theory_all_solid'), $solid) }}</b>
|
||||
@elseif ($fragile === 0)
|
||||
<b>{{ sprintf(__('stakeholder.overview.theory_solid_and_unassessed'), $solid, $unassessed) }}</b>
|
||||
@else
|
||||
<b>{{ sprintf(__('stakeholder.overview.theory_summary_mixed'), $solid, $fragile) }}</b>@if ($unassessed > 0) · {{ sprintf(__('stakeholder.overview.theory_plus_unassessed'), $unassessed) }}@endif
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Chain: 5 stage chips + 4 colored connectors between. Each connector
|
||||
is a line with the health icon as a badge; hover for detail. --}}
|
||||
@php
|
||||
$stageNames = [
|
||||
1 => __('box.logicmodel.inputs'),
|
||||
2 => __('box.logicmodel.activities'),
|
||||
3 => __('box.logicmodel.outputs'),
|
||||
4 => __('box.logicmodel.outcomes'),
|
||||
5 => __('box.logicmodel.impact'),
|
||||
];
|
||||
@endphp
|
||||
<div class="segs">
|
||||
@for ($s = 1; $s <= 5; $s++)
|
||||
<span class="stage s{{ $s }}">{{ $stageNames[$s] }}</span>
|
||||
@if ($s < 5)
|
||||
@php $slot = $bySlot[$s]; @endphp
|
||||
<div class="conn {{ $slot['status'] }}" tabindex="0">
|
||||
<div class="badge">
|
||||
{{-- fa-circle-* family, matching the LM canvas's status pills
|
||||
(Logicmodelcanvas::STATUS_LABELS uses the same set). --}}
|
||||
<i class="fa
|
||||
@if ($slot['status'] === 'ok') fa-circle-check
|
||||
@elseif ($slot['status'] === 'warning') fa-circle-exclamation
|
||||
@elseif ($slot['status'] === 'risk') fa-triangle-exclamation
|
||||
@else fa-circle-question
|
||||
@endif"></i>
|
||||
</div>
|
||||
@if ($slot['assumption'] !== '' || $slot['evidence'] !== '')
|
||||
<div class="tip">
|
||||
<b>{{ $slot['label'] !== '' ? $slot['label'] : 'Link '.$s }}</b>
|
||||
@if ($slot['assumption'] !== '')
|
||||
<em>{{ $slot['assumption'] }}</em>
|
||||
@endif
|
||||
@if ($slot['evidence'] !== '')
|
||||
<div style="margin-top:6px;"><b>{{ __('stakeholder.overview.theory_evidence') }}</b>{{ $slot['evidence'] }}</div>
|
||||
@elseif ($slot['status'] !== 'ok')
|
||||
<div style="margin-top:6px;opacity:.85;">{{ __('stakeholder.overview.theory_no_evidence_short') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
{{-- Fragile-link detail. Only if there's something to flag. --}}
|
||||
@if ($fragile > 0)
|
||||
@php $first = $risky[0]; @endphp
|
||||
@php $others = array_slice($risky, 1); $otherCount = count($others); @endphp
|
||||
<div class="detail @if ($first['status'] === 'risk') crit @endif">
|
||||
<i class="fa @if ($first['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
|
||||
<div>
|
||||
<b>{{ $first['label'] }}</b> {{ $first['status'] === 'risk' ? __('stakeholder.overview.theory_is_critical') : __('stakeholder.overview.theory_needs_work') }}
|
||||
@if ($first['assumption'] !== '')
|
||||
— {{ __('stakeholder.overview.theory_it_rests_on') }} <em>{{ $first['assumption'] }}</em>
|
||||
@endif
|
||||
@if ($first['evidence'] === '')
|
||||
<span class="p1-info detail-info evidence-tag" tabindex="0">
|
||||
<i class="fa fa-circle-exclamation"></i>
|
||||
<span class="tag">{{ __('stakeholder.overview.theory_unproven') }}</span>
|
||||
<span class="pop" role="tooltip">{{ __('stakeholder.overview.theory_unproven_explain') }}</span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Secondary fragile links as compact "Also fragile" chip row —
|
||||
visible (not hidden in a tooltip) so a board sees the full
|
||||
fragile set at once. --}}
|
||||
@if ($otherCount > 0)
|
||||
<div class="also-fragile">
|
||||
<span class="lbl">{{ __('stakeholder.overview.theory_also_fragile') }}</span>
|
||||
@foreach ($others as $o)
|
||||
<span class="af-chip {{ $o['status'] }}" title="{{ $o['assumption'] }}">
|
||||
<i class="fa @if ($o['status'] === 'risk') fa-triangle-exclamation @else fa-circle-exclamation @endif"></i>
|
||||
<span class="nm">{{ $o['label'] }}</span>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="calm-line">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
{{ __('stakeholder.overview.theory_all_ok') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
/* Theory of Change collapse — click chevron to toggle the narrative. Persists
|
||||
the state in localStorage so it survives page reloads. */
|
||||
(function () {
|
||||
if (window.__p1TocInit) return;
|
||||
window.__p1TocInit = true;
|
||||
|
||||
// Default state is COLLAPSED (rendered server-side with .collapsed). Only
|
||||
// remove it if the user has previously explicitly expanded it.
|
||||
var KEY = 'rd.p1.toc.collapsed';
|
||||
var section = document.getElementById('p1TocSection');
|
||||
if (section && localStorage.getItem(KEY) === '0') {
|
||||
section.classList.remove('collapsed');
|
||||
}
|
||||
|
||||
window.p1TocToggle = function (btn) {
|
||||
var s = btn.closest('.p1-toc');
|
||||
if (!s) return;
|
||||
var isNowCollapsed = s.classList.toggle('collapsed');
|
||||
try { localStorage.setItem(KEY, isNowCollapsed ? '1' : '0'); } catch (e) {}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
{{--
|
||||
Stakeholder Report — Page 4 (Programs & Narrative)
|
||||
|
||||
§5 page 4: Two columns — program rows with RAG + completed count on the
|
||||
left, status narrative from statusUpdates on the right. "Also this period"
|
||||
(secondary closures) at the bottom.
|
||||
|
||||
Vars in:
|
||||
$scope 'strategy' | 'program'
|
||||
$programRows object[] (strategy scope; empty at program scope)
|
||||
$programUpdates array<int,object[]> (byProject at strategy scope)
|
||||
--}}
|
||||
|
||||
|
||||
<div class="p4-two">
|
||||
{{-- ── Programs column (strategy scope only) ─────────────────── --}}
|
||||
<div>
|
||||
<div class="p4-lbl">{{ $scope === 'strategy' ? __('stakeholder.programs.label_programs') : __('stakeholder.programs.label_child_projects') }}</div>
|
||||
@if (count($programRows) === 0)
|
||||
<div class="p4-empty">{{ $scope === 'strategy' ? __('stakeholder.programs.none') : __('stakeholder.programs.none_projects') }}</div>
|
||||
@else
|
||||
@foreach ($programRows as $row)
|
||||
@php
|
||||
$row = (array) $row;
|
||||
// Status → dot color. programRows carries a status field from Marcel's
|
||||
// buildProgramRows (worst-of-children rollup); values: green/yellow/red/null.
|
||||
$status = (string) ($row['status'] ?? '');
|
||||
$dotColor = match ($status) {
|
||||
'green' => 'var(--rd-ok)',
|
||||
'yellow' => 'var(--rd-warn)',
|
||||
'red' => 'var(--rd-danger)',
|
||||
default => 'var(--rd-text-4)',
|
||||
};
|
||||
$statusLabel = match ($status) {
|
||||
'green' => __('stakeholder.programs.status_ontrack'),
|
||||
'yellow' => __('stakeholder.programs.status_atrisk'),
|
||||
'red' => __('stakeholder.programs.status_off'),
|
||||
default => __('stakeholder.programs.status_none'),
|
||||
};
|
||||
$completedCt = (int) ($row['completedCount'] ?? 0);
|
||||
@endphp
|
||||
<div class="p4-prog">
|
||||
<span class="pd" style="background:{{ $dotColor }}"></span>
|
||||
<span class="pn">{{ $row['name'] ?? '' }}</span>
|
||||
<span class="pm">{{ $statusLabel }} · {{ sprintf(__('stakeholder.programs.done_count'), $completedCt) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ── Status narrative column ───────────────────────────────── --}}
|
||||
<div>
|
||||
<div class="p4-lbl">{{ __('stakeholder.programs.label_narrative') }}</div>
|
||||
@php
|
||||
// programUpdates is keyed by projectId. Flatten with a name lookup from
|
||||
// programRows for the bold label per line.
|
||||
$namesByProject = [];
|
||||
foreach ($programRows as $row) {
|
||||
$row = (array) $row;
|
||||
$namesByProject[(int) ($row['id'] ?? 0)] = $row['name'] ?? '';
|
||||
}
|
||||
$flatUpdates = [];
|
||||
foreach ($programUpdates as $pid => $updates) {
|
||||
foreach ($updates as $update) {
|
||||
$update = (object) $update;
|
||||
$update->_projectName = $namesByProject[(int) $pid] ?? '';
|
||||
$flatUpdates[] = $update;
|
||||
}
|
||||
}
|
||||
// Sort newest first — Marcel returns in the same order per project;
|
||||
// for cross-project flat display we re-sort by date desc.
|
||||
usort($flatUpdates, fn ($a, $b) => strcmp((string) ($b->date ?? ''), (string) ($a->date ?? '')));
|
||||
$flatUpdates = array_slice($flatUpdates, 0, 5); // top 5 for the packet view
|
||||
@endphp
|
||||
@if (count($flatUpdates) === 0)
|
||||
<div class="p4-empty">{{ __('stakeholder.programs.no_updates') }}</div>
|
||||
@else
|
||||
@foreach ($flatUpdates as $u)
|
||||
@php
|
||||
$date = ! empty($u->dateParsed) ? $u->dateParsed->setToUserTimezone()->format('M j') : '';
|
||||
$text = trim(strip_tags((string) ($u->text ?? '')));
|
||||
// Truncate long updates — board views want the executive summary.
|
||||
if (mb_strlen($text) > 220) $text = mb_substr($text, 0, 217).'…';
|
||||
@endphp
|
||||
<div class="p4-exec">
|
||||
@if (! empty($u->_projectName))
|
||||
<b>{{ $u->_projectName }}</b> —
|
||||
@endif
|
||||
{{ $text }}
|
||||
@if ($date !== '') <span class="ed">{{ $date }}</span> @endif
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- "Also this period" — secondary closures beyond the peak-this-period hero.
|
||||
Nomination surface for closures that could be attached to an outcome.
|
||||
Requires the nomination pass that also feeds the p1 hero; render coming-soon
|
||||
until that lands, to avoid pretending an empty state is a full one. --}}
|
||||
<div class="p4-also">
|
||||
<span class="al-lb">{{ __('stakeholder.programs.also_label') }}</span>
|
||||
<span style="color:var(--rd-text-3);"> — {{ __('stakeholder.programs.also_coming') }}</span>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
37
app/Domain/Reports/Templates/partials/statTiles.blade.php
Normal file
37
app/Domain/Reports/Templates/partials/statTiles.blade.php
Normal file
@@ -0,0 +1,37 @@
|
||||
{{--
|
||||
Row of summary stat tiles with optional period-over-period deltas.
|
||||
|
||||
Expects:
|
||||
$tiles: array of [
|
||||
'label' => string,
|
||||
'value' => string|int|float,
|
||||
'tone' => 'default'|'danger' (danger colors the value red when > 0),
|
||||
'delta' => null|['value' => float, 'goodWhenUp' => bool|null, 'vs' => string],
|
||||
]
|
||||
--}}
|
||||
<div class="reportStatTiles">
|
||||
@foreach ($tiles as $tile)
|
||||
@php
|
||||
$isDanger = ($tile['tone'] ?? 'default') === 'danger' && (float) $tile['value'] > 0;
|
||||
$delta = $tile['delta'] ?? null;
|
||||
$deltaClass = '';
|
||||
if ($delta !== null && $delta['value'] != 0 && ($delta['goodWhenUp'] ?? null) !== null) {
|
||||
$isGood = ($delta['value'] > 0) === $delta['goodWhenUp'];
|
||||
$deltaClass = $isGood ? 'deltaGood' : 'deltaBad';
|
||||
}
|
||||
@endphp
|
||||
<div class="reportStatTile">
|
||||
<span class="tileLabel">{{ $tile['label'] }}</span>
|
||||
<span class="tileValue @if ($isDanger) tileValueDanger @endif">{{ $tile['value'] }}</span>
|
||||
@if ($delta !== null)
|
||||
<span class="tileDelta {{ $deltaClass }}">
|
||||
@if ($delta['value'] > 0) ▲ +{{ \Illuminate\Support\Number::format($delta['value'], maxPrecision: 1) }}
|
||||
@elseif ($delta['value'] < 0) ▼ {{ \Illuminate\Support\Number::format(abs($delta['value']), maxPrecision: 1) }}
|
||||
@else ±0
|
||||
@endif
|
||||
{{ $delta['vs'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
{{--
|
||||
Status narrative: the period's status updates as a human-readable feed, grouped by
|
||||
project, newest first, colored by their green/yellow/red status.
|
||||
|
||||
Expects:
|
||||
$updatesByProject: array<int, object[]> - projectId => status updates (engine output)
|
||||
$summaries: array<int, object> - project summaries keyed by id (for names)
|
||||
$showProjects: bool
|
||||
$emptyText: string
|
||||
--}}
|
||||
@php
|
||||
$showProjects = $showProjects ?? false;
|
||||
$narrativeColors = ['green' => 'var(--green)', 'yellow' => 'var(--yellow)', 'red' => 'var(--red)'];
|
||||
@endphp
|
||||
|
||||
@if (count($updatesByProject) === 0)
|
||||
<div class="tw-opacity-60 tw-text-sm tw-py-2">{{ $emptyText }}</div>
|
||||
@else
|
||||
<div class="reportStatusNarrative tw-flex tw-flex-col tw-gap-3">
|
||||
@foreach ($updatesByProject as $projectId => $updates)
|
||||
@if ($showProjects && isset($summaries[$projectId]))
|
||||
<strong class="tw-mt-1">{{ $tpl->escape($summaries[$projectId]->name) }}</strong>
|
||||
@endif
|
||||
@foreach ($updates as $update)
|
||||
<div class="reportStatusUpdate tw-pl-3 tw-py-1" style="border-left: 4px solid {{ $narrativeColors[$update->status] ?? 'var(--grey)' }};">
|
||||
<div class="tw-text-xs tw-opacity-60">
|
||||
{{ $tpl->escape(trim(($update->authorFirstname ?? '').' '.($update->authorLastname ?? ''))) }}
|
||||
· {{ $update->dateParsed?->formatDateForUser() ?? '' }}
|
||||
</div>
|
||||
<div class="tw-text-sm">{!! $tpl->escapeMinimal($update->text) !!}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
34
app/Domain/Reports/Templates/partials/statusPill.blade.php
Normal file
34
app/Domain/Reports/Templates/partials/statusPill.blade.php
Normal file
@@ -0,0 +1,34 @@
|
||||
{{--
|
||||
Colored project status pill (green/yellow/red from the latest status update).
|
||||
|
||||
Expects:
|
||||
$status: string|null - green|yellow|red (null = no update yet)
|
||||
$date: \Carbon\CarbonImmutable|null - when the status was posted (optional)
|
||||
--}}
|
||||
@php
|
||||
$pillColors = [
|
||||
'green' => 'var(--green)',
|
||||
'yellow' => 'var(--yellow)',
|
||||
'red' => 'var(--red)',
|
||||
];
|
||||
$pillLabels = [
|
||||
'green' => __('label.status_on_track'),
|
||||
'yellow' => __('label.status_at_risk'),
|
||||
'red' => __('label.status_off_track'),
|
||||
];
|
||||
@endphp
|
||||
|
||||
@if (!empty($status) && isset($pillColors[$status]))
|
||||
<span class="reportStatusPill tw-text-sm">
|
||||
<span class="statusDot" style="background:{{ $pillColors[$status] }};"></span>
|
||||
{{ $pillLabels[$status] }}
|
||||
@if (!empty($date))
|
||||
<span class="tw-opacity-60">· {{ $date->formatDateForUser() }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="reportStatusPill tw-text-sm tw-opacity-60">
|
||||
<span class="statusDot" style="background:var(--grey);"></span>
|
||||
{{ __('label.status_no_update') }}
|
||||
</span>
|
||||
@endif
|
||||
46
app/Domain/Reports/Templates/project.blade.php
Normal file
46
app/Domain/Reports/Templates/project.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$summary = $report['summaries'][$projectId] ?? null;
|
||||
@endphp
|
||||
|
||||
<x-global::pageheader :icon="'fa fa-chart-bar'">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<h5>{{ session('currentProjectClient') ? session('currentProjectClient') . ' // ' : '' }}{{ session('currentProjectName') }}</h5>
|
||||
<h1>{!! __('headlines.status_report') !!}</h1>
|
||||
</div>
|
||||
<div class="col-lg-4" style="text-align: right;">
|
||||
<x-global::forms.button tag="button" inputType="button" onclick="window.print();" class="btn-secondary hideOnPrint">
|
||||
<i class="fa fa-print"></i> {{ __('label.print_report') }}
|
||||
</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</x-global::pageheader>
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-flex-wrap tw-gap-2 tw-mb-4 hideOnPrint">
|
||||
<ul class="tabs-list tw-m-0" style="display:inline-flex; gap: 4px;">
|
||||
<li class="active"><a href="{{ BASE_URL }}/reports/project">{{ __('label.status_report_tab') }}</a></li>
|
||||
<li><a href="{{ BASE_URL }}/reports/show">{{ __('label.delivery_metrics_tab') }}</a></li>
|
||||
</ul>
|
||||
|
||||
<x-global::periodpicker
|
||||
:period="$period"
|
||||
:url="BASE_URL.'/reports/project'"
|
||||
:hxUrl="BASE_URL.'/hx/reports/projectReport/get'"
|
||||
target="#reportBody" />
|
||||
</div>
|
||||
|
||||
@include('reports::partials.projectReportBody', ['report' => $report, 'period' => $period, 'projectId' => $projectId])
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
296
app/Domain/Reports/Templates/show.blade.php
Normal file
296
app/Domain/Reports/Templates/show.blade.php
Normal file
@@ -0,0 +1,296 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="pageheader">
|
||||
<div class="pageicon"><span class="fa fa-chart-bar"></span></div>
|
||||
<div class="pagetitle">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<h5>{{ session('currentProjectClient') . ' // ' . session('currentProjectName') }}</h5>
|
||||
<h1>{!! __('headlines.reports') !!}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<ul class="tabs-list tw-mb-4" style="display:inline-flex; gap: 4px;">
|
||||
<li><a href="{{ BASE_URL }}/reports/project">{{ __('label.status_report_tab') }}</a></li>
|
||||
<li class="active"><a href="{{ BASE_URL }}/reports/show">{{ __('label.delivery_metrics_tab') }}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<div class="row" id="yourToDoContainer">
|
||||
<div class="col-md-12">
|
||||
|
||||
<h5 class="subtitle">{!! __('subtitles.summary') !!} @if ($fullReportLatest)({{ format($fullReportLatest['date'])->date() }})@endif </h5>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="boxedHighlight">
|
||||
|
||||
<span class="headline">{!! __('label.planned_hours') !!}</span>
|
||||
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_planned_hours'] != null){{ format($fullReportLatest['sum_planned_hours'])->decimal() }}@else{{ 0 }}@endif</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="boxedHighlight">
|
||||
|
||||
|
||||
<span class="headline">{!! __('label.estimated_hours_remaining') !!}</span>
|
||||
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_estremaining_hours'] != null){{ format($fullReportLatest['sum_estremaining_hours'])->decimal() }}@else{{ 0 }}@endif</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="boxedHighlight">
|
||||
|
||||
|
||||
<span class="headline">{!! __('label.booked_hours') !!}</span>
|
||||
<span class="value">@if ($fullReportLatest !== false && $fullReportLatest['sum_logged_hours'] != null){{ format($fullReportLatest['sum_logged_hours'])->decimal() }}@else{{ 0 }}@endif</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="boxedHighlight">
|
||||
<span class="headline">{!! __('label.open_todos') !!}</span>
|
||||
<span class="value">
|
||||
{{-- Open To-Dos is a COUNT of tickets (SUM of CASE WHEN
|
||||
status = X THEN 1 in the repo), so it's inherently
|
||||
an integer. ->decimal() rendered "1" as "1.00" —
|
||||
read as a broken chart value in the audit. --}}
|
||||
@if ($fullReportLatest !== false)
|
||||
{{ (int) ($fullReportLatest['sum_open_todos'] + $fullReportLatest['sum_progres_todos']) }}
|
||||
@else
|
||||
{{ 0 }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Hide the whole Sprint Burndown section when the project has
|
||||
no sprints. Previously the outer guard was just `!== false`, so an
|
||||
empty array (project without sprints) rendered the title + toggle
|
||||
buttons + empty canvas with no chart underneath — read as broken
|
||||
in the audit. --}}
|
||||
@if ($allSprints !== false && count($allSprints) > 0)
|
||||
<h5 class="subtitle">{!! __('subtitles.sprint_burndown') !!}</h5>
|
||||
<br />
|
||||
<span class="pull-left">
|
||||
@if (true)
|
||||
<select data-placeholder="{{ __('input.placeholders.filter_by_sprint') }}" title="{{ __('input.placeholders.filter_by_sprint') }}" name="sprint" class="mainSprintSelector" onchange="location.href='{{ BASE_URL }}/reports/show?sprint='+jQuery(this).val()" id="sprintSelect">
|
||||
|
||||
<option value="" >{!! __('input.placeholders.filter_by_sprint') !!}</option>
|
||||
@php $dates = ''; @endphp
|
||||
@foreach ($allSprints as $sprintRow)
|
||||
<option value="{{ $sprintRow->id }}"
|
||||
@if ($currentSprint !== false && $sprintRow->id == $currentSprint)
|
||||
selected="selected"
|
||||
@php $dates = sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()); @endphp
|
||||
@endif
|
||||
>{{ $tpl->escape($sprintRow->name) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
</span>
|
||||
|
||||
<div class="pull-right">
|
||||
<div class="btn-group mt-1 mx-auto" role="group">
|
||||
<x-global::forms.button tag="a" id="NumChartButtonSprint" class="btn-sm btn-secondary active chartButtons" link="javascript:void(0)">{!! __('label.num_tickets') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" id="EffortChartButtonSprint" class="btn-sm btn-secondary chartButtons" link="javascript:void(0)">{!! __('label.effort') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" id="HourlyChartButtonSprint" class="btn-sm btn-secondary chartButtons" link="javascript:void(0)">{!! __('label.hours') !!}</x-global::forms.button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="width:100%; height:350px;">
|
||||
<canvas id="sprintBurndown"></canvas>
|
||||
</div>
|
||||
|
||||
|
||||
@endif
|
||||
|
||||
<div class="clearall"></div>
|
||||
<br />
|
||||
<br />
|
||||
<h5 class="subtitle">{!! __('subtitles.cummulative_flow') !!}</h5>
|
||||
|
||||
<div class="pull-right">
|
||||
<div class="btn-group mt-1 mx-auto" role="group">
|
||||
<x-global::forms.button tag="a" id="NumChartButtonBacklog" class="btn-sm btn-secondary active backlogChartButtons" link="javascript:void(0)">{!! __('label.num_tickets') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" id="EffortChartButtonBacklog" class="btn-sm btn-secondary backlogChartButtons" link="javascript:void(0)">{!! __('label.effort') !!}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" id="HourlyChartButtonBacklog" class="btn-sm btn-secondary backlogChartButtons" link="javascript:void(0)">{!! __('label.hours') !!}</x-global::forms.button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="width:100%; height:350px;">
|
||||
<canvas id="backlogBurndown"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="clearall"></div>
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
|
||||
<div class="row" id="projectProgressContainer">
|
||||
<div class="col-md-12">
|
||||
|
||||
<h5 class="subtitle">{!! __('subtitles.project_progress') !!}</h5>
|
||||
|
||||
<div id="canvas-holder" style="width:100%; height:250px;">
|
||||
<canvas id="chart-area" ></canvas>
|
||||
</div>
|
||||
<br /><br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" id="milestoneProgressContainer">
|
||||
<div class="col-md-12">
|
||||
<h5 class="subtitle">{!! __('headline.milestones') !!}</h5>
|
||||
<ul class="sortableTicketList" >
|
||||
@if (count($milestones) == 0)
|
||||
<div class='center'><br /><h4>{!! __('headlines.no_milestones') !!}</h4>
|
||||
{!! __('text.milestones_help_organize_projects') !!}<br /><br /><a href="{{ BASE_URL }}/tickets/roadmap">{!! __('links.goto_milestones') !!}</a>
|
||||
@endif
|
||||
@foreach ($milestones as $row)
|
||||
<li class="ui-state-default" id="milestone_{{ $row->id }}" >
|
||||
<div class="ticketBox fixed">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<strong><a href="{{ BASE_URL }}/tickets/editMilestone/{{ $row->id }}" class="milestoneModal">{{ $tpl->escape($row->headline) }}</a></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-7">
|
||||
{!! __('label.due') !!}
|
||||
{{ format($row->editTo)->date(__('text.no_date_defined')) }}
|
||||
</div>
|
||||
<div class="col-md-5" style="text-align:right">
|
||||
{!! sprintf(__('text.percent_complete'), $row->percentDone) !!}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" aria-valuenow="{{ $row->percentDone }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ $row->percentDone }}%">
|
||||
<span class="sr-only">{!! sprintf(__('text.percent_complete'), $row->percentDone) !!}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
|
||||
leantime.dashboardController.prepareHiddenDueDate();
|
||||
leantime.ticketsController.initEffortDropdown();
|
||||
leantime.ticketsController.initMilestoneDropdown();
|
||||
leantime.ticketsController.initStatusDropdown();
|
||||
|
||||
leantime.dashboardController.initProgressChart("chart-area", {{ round($projectProgress['percent']) }}, {{ round((100 - $projectProgress['percent'])) }});
|
||||
|
||||
@if ($sprintBurndown !== false)
|
||||
var sprintBurndownChart = leantime.dashboardController.initBurndown([@foreach ($sprintBurndown as $value)'{{ $value['date'] }}',@endforeach], [@foreach ($sprintBurndown as $value)'{{ round($value['plannedNum'], 2) }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualNum'] !== '')'{{ $value['actualNum'] }}',@endif @endforeach ]);
|
||||
leantime.dashboardController.initChartButtonClick('HourlyChartButtonSprint', '{!! __('label.hours') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedHours'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualHours'] !== '')'{{ round($value['actualHours']) }}',@endif @endforeach ], sprintBurndownChart);
|
||||
leantime.dashboardController.initChartButtonClick('EffortChartButtonSprint', '{!! __('label.effort') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedEffort'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualEffort'] !== '')'{{ $value['actualEffort'] }}',@endif @endforeach ], sprintBurndownChart);
|
||||
leantime.dashboardController.initChartButtonClick('NumChartButtonSprint', '{!! __('label.num_tickets') !!}', [@foreach ($sprintBurndown as $value)'{{ $value['plannedNum'] }}',@endforeach], [ @foreach ($sprintBurndown as $value)@if ($value['actualNum'] !== '')'{{ $value['actualNum'] }}',@endif @endforeach ], sprintBurndownChart);
|
||||
|
||||
@endif
|
||||
|
||||
@if ($backlogBurndown !== false)
|
||||
var statusBurnupNum = [];
|
||||
|
||||
statusBurnupNum['open'] = {
|
||||
'label': 'Open',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualNum'] !== '')'{{ $value['open']['actualNum'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupNum['progress'] = {
|
||||
'label': 'Progress',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualNum'] !== '')'{{ $value['progress']['actualNum'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupNum['done'] = {
|
||||
'label': 'Done',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualNum'] !== '')'{{ $value['done']['actualNum'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
var backlogBurndown = leantime.dashboardController.initBacklogBurndown([@foreach ($backlogBurndown as $value)'{{ $value['date'] }}',@endforeach], statusBurnupNum);
|
||||
|
||||
|
||||
var statusBurnupEffort = [];
|
||||
|
||||
statusBurnupEffort['open'] = {
|
||||
'label': 'Open',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualEffort'] !== '')'{{ $value['open']['actualEffort'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupEffort['progress'] = {
|
||||
'label': 'Progress',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualEffort'] !== '')'{{ $value['progress']['actualEffort'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupEffort['done'] = {
|
||||
'label': 'Done',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualEffort'] !== '')'{{ $value['done']['actualEffort'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
var statusBurnupHours = [];
|
||||
|
||||
statusBurnupHours['open'] = {
|
||||
'label': 'Open',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['open']['actualHours'] !== '')'{{ $value['open']['actualHours'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupHours['progress'] = {
|
||||
'label': 'Progress',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['progress']['actualHours'] !== '')'{{ $value['progress']['actualHours'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
statusBurnupHours['done'] = {
|
||||
'label': 'Done',
|
||||
'data': [@foreach ($backlogBurndown as $value)@if ($value['done']['actualHours'] !== '')'{{ $value['done']['actualHours'] }}',@endif @endforeach]
|
||||
};
|
||||
|
||||
leantime.dashboardController.initBacklogChartButtonClick('HourlyChartButtonBacklog', statusBurnupHours, '{!! __('label.hours') !!}', backlogBurndown);
|
||||
leantime.dashboardController.initBacklogChartButtonClick('EffortChartButtonBacklog', statusBurnupEffort, '{!! __('label.effort') !!}', backlogBurndown);
|
||||
leantime.dashboardController.initBacklogChartButtonClick('NumChartButtonBacklog', statusBurnupNum, '{!! __('label.num_tickets') !!}', backlogBurndown);
|
||||
|
||||
@endif
|
||||
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
|
||||
@endsection
|
||||
49
app/Domain/Reports/register.php
Normal file
49
app/Domain/Reports/register.php
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user