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

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

View File

@@ -0,0 +1,90 @@
<?php
/**
* Controller / Edit Canvas Item
*/
namespace Leantime\Domain\Goalcanvas\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\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Goal board (big rock) create/edit dialog. Standalone, independent of the canvas domain.
*/
class BigRock extends Controller
{
private GoalcanvaService $goalService;
public function init(
GoalcanvaService $goalService
): void {
$this->goalService = $goalService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params): Response
{
if (isset($params['id'])) {
// getSingleCanvas authorizes VIEW against the board's real project and returns
// false for a missing/foreign/unauthorized board.
$bigrock = $this->goalService->getSingleCanvas($params['id']);
if ($bigrock === false) {
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
}
} else {
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
}
$this->tpl->assign('bigRock', $bigrock);
return $this->tpl->displayPartial('goalcanvas.bigRockDialog');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params): Response
{
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
// Update
$bigrock['id'] = $id;
$bigrock['title'] = $params['title'];
$this->goalService->updateGoalboard($bigrock);
$this->tpl->setNotification('notification.goalboard_updated_successfully', 'success', 'goalcanvas_updated');
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id);
} else {
// New
$bigrock['title'] = $params['title'];
$bigrock['projectId'] = session('currentProject');
$bigrock['author'] = session('userdata.id');
$id = $this->goalService->createGoalboard($bigrock);
if ($id) {
$this->tpl->setNotification('notification.goalboard_created_successfully', 'success', 'wiki_created');
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id.'?closeModal=1');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id.'');
}
}
}

View File

@@ -0,0 +1,469 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Mailer;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Services\Projects;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepo;
use Symfony\Component\HttpFoundation\Response;
class Dashboard extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private Projects $projectService;
private Goalcanvas $goalService;
private object $canvasRepo;
/**
* Initializes dependencies.
*/
public function init(
Projects $projectService,
Goalcanvas $goalService
): void {
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* Displays the goal canvas dashboard.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$allCanvas = $this->ensureDefaultCanvas($allCanvas);
$goalAnalytics = $this->calculateGoalAnalytics($allCanvas);
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_REQUEST['searchCanvas'])) {
$currentCanvasId = (int) $_REQUEST['searchCanvas'];
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->assignTemplateVars($currentCanvasId, $allCanvas, $goalAnalytics);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.dashboard');
}
return new Response;
}
/**
* Handles goal canvas mutations (create, edit, clone, merge, import).
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT)]
public function post(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$allCanvas = $this->ensureDefaultCanvas($allCanvas);
$goalAnalytics = $this->calculateGoalAnalytics($allCanvas);
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['cloneCanvas']) && $currentCanvasId > 0) {
$result = $this->handleCloneCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['mergeCanvas']) && $currentCanvasId > 0) {
$result = $this->handleMergeCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['importCanvas'])) {
$result = $this->handleImportCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->assignTemplateVars($currentCanvasId, $allCanvas, $goalAnalytics);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.dashboard');
}
return new Response;
}
/**
* Creates a default canvas if none exist.
*/
private function ensureDefaultCanvas(array $allCanvas): array
{
if (! $allCanvas) {
$values = [
'title' => $this->language->__('label.board'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// View-time convenience: lazily create a default board (in the CURRENT project) when
// none exist. Kept repo-direct/UNGATED on purpose — gating it through createGoalboard's
// CREATE check would 403 a VIEW-only user just for opening an empty goals page. It is
// not an IDOR (always the session project). Same landmine pattern as the Blueprints/
// Wiki/Ideas default-board/notebook bootstrap.
$this->canvasRepo->addCanvas($values);
return $this->canvasRepo->getAllCanvas(session('currentProject'));
}
return $allCanvas;
}
/**
* Calculates goal analytics across all canvases.
*/
private function calculateGoalAnalytics(array $allCanvas): array
{
$goalAnalytics = [
'numCanvases' => count($allCanvas),
'numGoals' => '0',
'goalsOnTrack' => 0,
'goalsAtRisk' => 0,
'goalsMiss' => 0,
'avgPercentComplete' => '0',
];
$totalPercent = 0;
foreach ($allCanvas as $canvas) {
$canvasItems = $this->canvasRepo->getCanvasItemsById($canvas['id']);
foreach ($canvasItems as $item) {
$goalAnalytics['numGoals']++;
if ($item['status'] == 'status_ontrack') {
$goalAnalytics['goalsOnTrack']++;
}
if ($item['status'] == 'status_atrisk') {
$goalAnalytics['goalsAtRisk']++;
}
if ($item['status'] == 'status_miss') {
$goalAnalytics['goalsMiss']++;
}
$total = $item['endValue'] - $item['startValue'];
$progressValue = $item['currentValue'] - $item['startValue'];
if ($total != 0) {
$percentDone = max(0, min(100, round($progressValue / $total * 100, 2)));
} else {
$percentDone = 0;
}
$totalPercent = $totalPercent + $percentDone;
}
}
if ($goalAnalytics['numGoals'] > 0) {
$goalAnalytics['avgPercentComplete'] = $totalPercent / $goalAnalytics['numGoals'];
}
return $goalAnalytics;
}
/**
* Resolves the current canvas ID from session or request parameters.
*/
private function resolveCurrentCanvasId(array $allCanvas, array $params): int
{
$sessionKey = 'current'.strtoupper(static::CANVAS_NAME).'Canvas';
$currentCanvasId = -1;
if (session()->exists($sessionKey)) {
$currentCanvasId = session($sessionKey);
$found = false;
foreach ($allCanvas as $row) {
if ($currentCanvasId == $row['id']) {
$found = true;
break;
}
}
if (! $found) {
$currentCanvasId = -1;
session([$sessionKey => '']);
}
} else {
session([$sessionKey => '']);
}
if (count($allCanvas) > 0 && session($sessionKey) == '') {
$currentCanvasId = $allCanvas[0]['id'];
session([$sessionKey => $currentCanvasId]);
}
if (isset($params['id'])) {
// Only honor an explicit board id that belongs to the CURRENT project's boards;
// a foreign/unknown id must not become the active board (cross-project item read).
$requestedId = (int) $params['id'];
$projectBoardIds = array_map(static fn ($row) => (int) $row['id'], $allCanvas);
if (in_array($requestedId, $projectBoardIds, true)) {
$currentCanvasId = $requestedId;
session([$sessionKey => $currentCanvasId]);
}
}
return $currentCanvasId;
}
/**
* Handles creating a new canvas board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
$values = [
'title' => $_POST['canvastitle'],
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createGoalboard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->goalService->createGoalboard($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->notifyBoardCreated($values['title'], 'notification.board_created', 'email_notifications.canvas_created_message');
$this->tpl->setNotification($this->language->__('notification.board_created'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles editing a canvas board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// updateGoalboard authorizes EDIT against the board's real project.
$values = ['title' => $_POST['canvastitle'], 'id' => $currentCanvasId];
$currentCanvasId = $this->goalService->updateGoalboard($values);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles cloning a canvas board.
*/
private function handleCloneCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// copyGoalBoard authorizes VIEW on the source board's project and CREATE on the target.
$currentCanvasId = $this->goalService->copyGoalBoard(
$currentCanvasId,
(int) session('currentProject'),
(int) session('userdata.id'),
$_POST['canvastitle']
);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles merging two canvas boards.
*/
private function handleMergeCanvas(int $currentCanvasId): ?Response
{
if (! isset($_POST['canvasid']) || $_POST['canvasid'] <= 0) {
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
return null;
}
// mergeGoalBoard authorizes EDIT on the target board's project and VIEW on the source's.
$status = $this->goalService->mergeGoalBoard($currentCanvasId, (int) $_POST['canvasid']);
if ($status) {
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
return null;
}
/**
* Handles importing a canvas from an XML file.
*/
private function handleImportCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_FILES['canvasfile']) || $_FILES['canvasfile']['error'] !== 0) {
return null;
}
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
if (! move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$services = app()->make(BlueprintsService::class);
// Blueprints service expects the canvas slug (e.g. "goal"), not the full type ("goalcanvas").
$importCanvasId = $services->import(
$uploadfile,
static::CANVAS_NAME,
projectId: session('currentProject'),
authorId: session('userdata.id')
);
unlink($uploadfile);
if ($importCanvasId === false) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$currentCanvasId = $importCanvasId;
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
// getSingleCanvas returns a single row (Goalcanvas repo override) or false.
$canvas = $this->goalService->getSingleCanvas($currentCanvasId);
$this->notifyBoardCreated(
strip_tags($canvas !== false ? ($canvas['title'] ?? '') : ''),
'notification.board_imported',
'email_notifications.canvas_imported_message'
);
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Sends board creation/import notifications to project users.
*/
private function notifyBoardCreated(string $title, string $subjectKey, string $messageKey): void
{
$mailer = app()->make(Mailer::class);
$users = $this->projectService->getUsersToNotify(session('currentProject'));
$mailer->setSubject($this->language->__($subjectKey));
$message = sprintf(
$this->language->__($messageKey),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($title).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepo::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__($subjectKey),
session('currentProject')
);
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas, array $goalAnalytics): void
{
$filter['status'] = $_GET['filter_status'] ?? (session('filter_status') ?? 'all');
session(['filter_status' => $filter['status']]);
$filter['relates'] = $_GET['filter_relates'] ?? (session('filter_relates') ?? 'all');
session(['filter_relates' => $filter['relates']]);
$this->tpl->assign('filter', $filter);
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('goalStats', $goalAnalytics);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('relatesLabels', $this->canvasRepo->getRelatesLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
$this->tpl->assign('disclaimer', $this->canvasRepo->getDisclaimer());
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->goalService->getCanvasItemsById($currentCanvasId));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles deletion of a goal canvas board.
*/
class DelCanvas extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private GoalcanvaRepository $canvasRepo;
private GoalcanvaService $goalService;
/**
* Initializes dependencies.
*/
public function init(GoalcanvaRepository $canvasRepo, GoalcanvaService $goalService): void
{
$this->canvasRepo = $canvasRepo;
$this->goalService = $goalService;
}
/**
* Displays the delete goal canvas confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE)]
public function get(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvas');
}
/**
* Handles goal canvas board deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
// The service resolves the board's REAL project and authorizes DELETE against it
// (throwing for a missing/foreign board) — closing the by-id board-delete IDOR the
// previous role-only Auth::authOrRedirect left open.
$this->goalService->deleteGoalBoard($id);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $allCanvas[0]['id'] ?? -1]);
$this->tpl->setNotification($this->language->__('notification.board_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvas_deleted');
if (! $allCanvas) {
return Frontcontroller::redirect(BASE_URL.'/blueprints/showBoards');
}
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas');
}
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvas');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles deletion of a goal canvas item.
*/
class DelCanvasItem extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private GoalcanvaService $goalService;
/**
* Initializes dependencies.
*/
public function init(GoalcanvaService $goalService): void
{
$this->goalService = $goalService;
}
/**
* Displays the delete goal item confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE)]
public function get(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvasItem');
}
/**
* Handles goal item deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
// The service resolves the item's REAL project and authorizes DELETE against it
// (throwing for a missing/foreign item) — closing the by-id item-delete IDOR.
$this->goalService->deleteGoalItem($id);
$this->tpl->setNotification($this->language->__('notification.element_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvasitem_deleted');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas');
}
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvasItem');
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* editCanvasComment class - Generic canvas controller / Edit Comments
*/
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
class EditCanvasComment extends Controller
{
/**
* Constant that must be redefined
*/
protected const CANVAS_NAME = 'goal';
private CommentRepository $commentsRepo;
private ProjectService $projectService;
private GoalcanvaService $goalService;
private object $canvasRepo;
/**
* init - initialize private variables
*/
public function init(
CommentRepository $commentsRepo,
ProjectService $projectService,
GoalcanvaService $goalService
) {
$this->commentsRepo = $commentsRepo;
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* get - handle get requests
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params)
{
$canvasTypes = $this->canvasRepo->getCanvasTypes();
if (isset($params['id'])) {
// Resolve + VIEW-authorize the item against its real project first.
$canvasItem = $this->goalService->getGoalItem((int) $params['id']);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
// Delete comment — only when it belongs to THIS gated item (module + moduleId).
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === static::CANVAS_NAME.'canvasitem'
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvascomment_deleted');
}
}
$comments = $this->commentsRepo->getComments(static::CANVAS_NAME.'canvasitem', $canvasItem['id']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(static::CANVAS_NAME.'canvasitem', $canvasItem['id']));
} else {
if (isset($params['type'])) {
$type = strip_tags($params['type']);
} else {
$type = array_key_first($canvasTypes);
}
$canvasItem = [
'id' => '',
'box' => $type,
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => array_key_first($this->canvasRepo->getRelatesLabels()),
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
$comments = [];
}
$this->tpl->assign('comments', $comments);
$this->tpl->assign('canvasTypes', $canvasTypes);
$this->tpl->assign('canvasItem', $canvasItem);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.canvasComment');
}
/**
* post - handle post requests
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params)
{
if (isset($params['changeItem'])) {
if (isset($params['itemId']) && $params['itemId'] != '') {
if (isset($params['description']) && ! empty($params['description'])) {
$currentCanvasId = (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'description' => $params['description'],
'status' => $params['status'],
'relates' => $params['relates'],
'assumptions' => $params['assumptions'],
'data' => $params['data'],
'conclusion' => $params['conclusion'],
'itemId' => $params['itemId'],
'id' => $params['itemId'],
'canvasId' => $currentCanvasId,
'milestoneId' => $params['milestoneId'],
'dependentMilstone' => '',
];
// Resolves the item's real project from itemId and authorizes EDIT there.
$this->goalService->updateGoalItem($canvasItem);
$comments = $this->commentsRepo->getComments(static::CANVAS_NAME.'canvasitem', $params['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
static::CANVAS_NAME.'canvasitem',
$params['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success', strtoupper(static::CANVAS_NAME).'canvasitem_updated');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $params['itemId'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = static::CANVAS_NAME.'canvas';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_edited');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item_update_message'),
session('userdata.name'),
$canvasItem['description']
);
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$params['itemId']);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
} else {
if (isset($_POST['description']) && ! empty($_POST['description'])) {
$currentCanvasId = (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'description' => $params['description'],
'status' => $params['status'],
'relates' => $params['relates'],
'assumptions' => $params['assumptions'],
'data' => $params['data'],
'conclusion' => $params['conclusion'],
'canvasId' => $currentCanvasId,
];
// Resolves the target board's real project from canvasId and authorizes CREATE.
$id = $this->goalService->createGoalItem($canvasItem);
$canvasItem['id'] = $id;
$canvasTypes = $this->canvasRepo->getCanvasTypes();
$this->tpl->setNotification($canvasTypes[$params['box']].' successfully created', 'success', strtoupper(static::CANVAS_NAME).'canvasitem_created');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $params['itemId'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = static::CANVAS_NAME.'canvas';
$notification->action = 'created';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_item_created');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item_created_message'),
session('userdata.name'),
$canvasItem['description']
);
$this->projectService->notifyProjectUsers($notification);
$this->tpl->setNotification($this->language->__('notification.element_created'), 'success', strtoupper(static::CANVAS_NAME).'canvasitem_created');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$id);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
}
}
if (isset($params['comment']) === true) {
// Only allow commenting on a goal item the user can view in their project.
if (! $this->goalService->getGoalItem((int) ($_GET['id'] ?? 0))) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $params['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $_GET['id'],
'commentParent' => ($params['father']),
];
$message = $this->commentsRepo->addComment($values, static::CANVAS_NAME.'canvasitem');
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success', strtoupper(static::CANVAS_NAME).'canvasitemcomment_created');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $_GET['id'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = static::CANVAS_NAME.'canvas';
$notification->action = 'commented';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_comment_created');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item__comment_created_message'),
session('userdata.name')
);
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$_GET['id']);
}
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('canvasItem', $this->goalService->getGoalItem((int) ($_GET['id'] ?? 0)));
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.canvasComment');
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,419 @@
<?php
/**
* Controller / Edit Canvas Item
*/
namespace Leantime\Domain\Goalcanvas\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Support\FromFormat;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
/**
* Goal canvas item editor. Standalone (own get()/post()), independent of the canvas domain.
*/
class EditCanvasItem extends Controller
{
protected const CANVAS_NAME = 'goal';
private GoalcanvaRepository $canvasRepo;
private CommentRepository $commentsRepo;
private TicketService $ticketService;
private ProjectService $projectService;
private GoalcanvaService $goalService;
public function init(
GoalcanvaRepository $canvasRepo,
CommentRepository $commentsRepo,
TicketService $ticketService,
ProjectService $projectService,
GoalcanvaService $goalService
): void {
$this->canvasRepo = $canvasRepo;
$this->commentsRepo = $commentsRepo;
$this->ticketService = $ticketService;
$this->projectService = $projectService;
$this->goalService = $goalService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params): Response
{
if (isset($params['id'])) {
// Resolve + VIEW-authorize the item against its real project BEFORE any mutation.
// false = missing / foreign project / unauthorized (indistinguishable -> no oracle).
$canvasItem = $this->goalService->getGoalItem((int) $params['id']);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
// Delete comment — only when it belongs to THIS gated item (module + moduleId);
// deleteComment() filters on the comment id alone, so the bind prevents deleting a
// foreign item's / project's comment.
if (isset($params['delComment'])) {
$commentId = (int) ($params['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === 'goalcanvasitem'
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
}
}
$comments = $this->commentsRepo->getComments('goalcanvasitem', $canvasItem['id']);
$this->tpl->assign(
'numComments',
$this->commentsRepo->countComments('goalcanvasitem', $canvasItem['id'])
);
} else {
$canvasItem = [
'id' => '',
'box' => 'goal',
'title' => '',
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => '',
'startValue' => '',
'currentValue' => '',
'canvasId' => $_GET['canvasId'] ?? (int) session('currentGOALCanvas'),
'endValue' => '',
'kpi' => '',
'startDate' => '',
'endDate' => '',
'setting' => '',
'metricType' => '',
'assignedTo' => '',
'parent' => '',
];
$comments = [];
}
$this->tpl->assign('id', $canvasItem['id'] ?? '');
$this->tpl->assign('canvasId', $canvasItem['canvasId']);
$this->tpl->assign('comments', $comments);
// Scope the milestone options to the GOAL's real project (goal↔milestone
// is same-project), not the session project — the dialog can be opened
// for a goal outside the current project. New goals fall back to session.
$goalProjectId = $canvasItem['projectId'] ?? session('currentProject');
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $goalProjectId]);
$this->tpl->assign('milestones', $allProjectMilestones);
// Linked-milestone chips + status summary for the goal editor (edge model).
if (($canvasItem['id'] ?? '') !== '') {
$goalMilestones = $this->goalService->getGoalMilestones((int) $canvasItem['id']);
$this->tpl->assign('goalMilestones', $goalMilestones['milestones']);
$this->tpl->assign('milestoneSummary', $goalMilestones['summary']);
} else {
$this->tpl->assign('goalMilestones', []);
$this->tpl->assign('milestoneSummary', ['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0]);
}
$this->tpl->assign('currentCanvas', $canvasItem['canvasId']);
$this->tpl->assign('canvasItem', $canvasItem);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
return $this->tpl->displayPartial('goalcanvas.canvasDialog');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params): Response
{
// Detach a milestone edge. State-changing, so it goes through POST (not a
// GET link) with a CSRF token — the chip's remove control is an hx-post.
// Authorized by the service (EDIT against the item's real project; a
// view-only user is denied). Returns the re-rendered milestones section
// (hx-target="#goalMsSection" outerHTML) so the summary counts and
// scroll arrow update with the removed chip, not just the chip node.
if (isset($params['removeMilestone']) && isset($params['id'])) {
$itemId = (int) $params['id'];
$this->goalService->removeMilestoneFromGoal($itemId, (int) $params['removeMilestone']);
// getGoalItem() always stamps the goal's REAL projectId on success;
// false only for a missing/foreign/unauthorized goal — fail closed
// rather than fall back to the SESSION project, which could load
// another project's milestone options into the re-rendered picker.
$canvasItem = $this->goalService->getGoalItem($itemId);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
$goalMilestones = $this->goalService->getGoalMilestones($itemId);
$this->tpl->assign('id', $itemId);
$this->tpl->assign('goalMilestones', $goalMilestones['milestones']);
$this->tpl->assign('milestoneSummary', $goalMilestones['summary']);
$this->tpl->assign('milestones', $this->ticketService->getAllMilestones([
'sprint' => '', 'type' => 'milestone',
'currentProject' => $canvasItem['projectId'],
]));
return $this->tpl->displayPartial('goalcanvas::partials.milestonesSection');
}
if (isset($params['comment']) && isset($params['id'])) {
$itemId = (int) $params['id'];
// Only allow commenting on a goal item the user can view in their project.
if (! $this->goalService->getGoalItem($itemId)) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $params['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $itemId,
'commentParent' => ($params['father']),
];
if ($params['text'] != '') {
$commentId = $this->commentsRepo->addComment($values, 'goalcanvasitem');
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
$values['id'] = $commentId;
$subject = $this->language->__('email_notifications.canvas_board_comment_created');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.$itemId;
$message = sprintf(
$this->language->__('email_notifications.canvas_item__comment_created_message'),
session('userdata.name')
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = 'goalcanvas';
$notification->action = 'commented';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/editCanvasItem/'.$itemId);
}
}
if (isset($params['changeItem'])) {
$currentCanvasId = $params['canvasId'] ?? (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
if (isset($params['itemId']) && ! empty($params['itemId'])) {
if (isset($params['title']) && ! empty($params['title'])) {
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => $params['title'],
'description' => $params['description'] ?? '',
'status' => $params['status'] ?? '',
'relates' => '',
'startValue' => $params['startValue'] ?? '',
'currentValue' => $params['currentValue'] ?? '',
'endValue' => $params['endValue'] ?? '',
'itemId' => $params['itemId'],
'canvasId' => $params['canvasId'],
'parent' => $params['parent'] ?? null,
'id' => $params['itemId'],
'kpi' => $params['kpi'] ?? '',
'startDate' => format(value: $params['startDate'] ?? '', fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(),
'endDate' => format(value: $params['endDate'] ?? '', fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime(),
'setting' => $params['setting'] ?? '',
'metricType' => $params['metricType'] ?? '',
'assignedTo' => $params['assignedTo'] ?? '',
// milestoneId intentionally omitted — milestone links are
// now edges, managed by add/removeMilestoneToGoal, so a
// goal save must not reconcile them down to one value.
];
// Resolves the item's real project from itemId and authorizes EDIT there.
$this->goalService->updateGoalItem($canvasItem);
// Append a milestone link (new or existing) — leaves the
// goal's other linked milestones intact.
$milestoneToLink = 0;
if (isset($params['newMilestone']) && $params['newMilestone'] != '') {
// Create the milestone in the GOAL's real project (goal↔milestone
// is same-project), not the session project — otherwise a
// cross-project dialog would create it in the wrong project and
// the same-project link guard would then reject it.
$goalItem = $this->goalService->getGoalItem((int) $params['itemId']);
$params['projectId'] = ($goalItem['projectId'] ?? null) ?: session('currentProject');
$params['headline'] = $params['newMilestone'];
$params['tags'] = '#ccc';
$params['editFrom'] = dtHelper()->userNow()->formatDateForUser();
$params['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
$params['dependentMilestone'] = '';
$newId = $this->ticketService->quickAddMilestone($params);
if ($newId !== false) {
$milestoneToLink = (int) $newId;
}
} elseif (isset($params['existingMilestone']) && $params['existingMilestone'] != '') {
$milestoneToLink = (int) $params['existingMilestone'];
}
if ($milestoneToLink > 0) {
$this->goalService->addMilestoneToGoal((int) $params['itemId'], $milestoneToLink);
}
$comments = $this->commentsRepo->getComments('goalcanvasitem', $params['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
'goalcanvasitem',
$params['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success', 'goal_created');
$subject = $this->language->__('email_notifications.canvas_board_edited');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.(int) $params['itemId'];
$message = sprintf(
$this->language->__('email_notifications.canvas_item_update_message'),
session('userdata.name'),
strip_tags($canvasItem['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = 'goalcanvas';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/editCanvasItem/'.$params['itemId']);
} else {
if (isset($_POST['title']) && ! empty($_POST['title'])) {
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => $params['title'],
'description' => $params['description'] ?? '',
'status' => $params['status'] ?? '',
'relates' => '',
'startValue' => $params['startValue'] ?? '',
'currentValue' => $params['currentValue'] ?? '',
'endValue' => $params['endValue'] ?? '',
'canvasId' => $params['canvasId'],
'parent' => $params['parent'] ?? null,
'kpi' => $params['kpi'] ?? '',
'startDate' => format(value: $params['startDate'] ?? '', fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(),
'endDate' => format(value: $params['endDate'] ?? '', fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime(),
'setting' => $params['setting'] ?? '',
'metricType' => $params['metricType'] ?? '',
'assignedTo' => $params['assignedTo'] ?? '',
];
// Resolves the target board's real project from canvasId and authorizes CREATE.
$id = $this->goalService->createGoalItem($canvasItem);
$canvasTypes = $this->canvasRepo->getCanvasTypes();
$this->tpl->setNotification($canvasTypes[$params['box']]['title'].' successfully created', 'success', 'goal_item_created');
$subject = $this->language->__('email_notifications.canvas_board_item_created');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.(int) $params['itemId'];
$message = sprintf(
$this->language->__('email_notifications.canvas_item_created_message'),
session('userdata.name'),
strip_tags($canvasItem['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = 'goalcanvas';
$notification->action = 'created';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
$this->tpl->setNotification($this->language->__('notification.element_created'), 'success');
} else {
$id = '';
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/editCanvasItem/'.$id);
}
}
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
if (isset($params['id'])) {
$canvasItemId = (int) $params['id'];
$comments = $this->commentsRepo->getComments('goalcanvasitem', $canvasItemId);
$this->tpl->assign('canvasItem', $this->goalService->getGoalItem($canvasItemId));
} else {
$value = [
'id' => '',
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => '',
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => array_key_first($this->canvasRepo->getRelatesLabels()),
'startValue' => '',
'currentValue' => '',
'endValue' => '',
'kpi' => '',
'startDate' => '',
'endDate' => '',
'setting ' => '',
'metricType' => '',
'assignedTo' => session('userdata.id'),
];
$comments = [];
$this->tpl->assign('canvasItem', $value);
}
$this->tpl->assign('comments', $comments);
return $this->tpl->displayPartial('goalcanvas.editCanvasItem');
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Goal canvas XML export. Standalone, independent of the canvas domain.
*/
class Export extends Controller
{
private const CANVAS_TYPE = 'goalcanvas';
private const SESSION_KEY = 'currentGOALCanvas';
private GoalcanvaRepository $canvasRepo;
private GoalcanvaService $goalService;
/**
* init - resolve dependencies.
*
* @param GoalcanvaRepository $canvasRepo Goal canvas repository (label definitions)
* @param GoalcanvaService $goalService Goal canvas service (VIEW-authorized board reads)
*/
public function init(GoalcanvaRepository $canvasRepo, GoalcanvaService $goalService): void
{
$this->canvasRepo = $canvasRepo;
$this->goalService = $goalService;
}
/**
* get - generate and return the goal canvas as an XML file.
*
* @param array<string, mixed> $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get(array $params): Response
{
if (isset($params['id']) && $params['id'] !== '') {
$canvasId = (int) $params['id'];
} elseif (session()->exists(self::SESSION_KEY)) {
$canvasId = (int) session(self::SESSION_KEY);
} else {
return new Response('', 204);
}
// getSingleCanvas authorizes VIEW against the board's real project and returns false for
// a missing/foreign/unauthorized board (export is reachable by arbitrary board id).
$canvas = $this->goalService->getSingleCanvas($canvasId);
if (! $canvas) {
return new Response('Canvas not found', 404);
}
$records = $this->goalService->getCanvasItemsById($canvasId);
$canvasTypes = $this->canvasRepo->getCanvasTypes();
// The Goalcanvas repo's getSingleCanvas returns a single row (not array-of-rows).
$exportData = $this->buildXml($canvas['title'] ?? '', $records, $canvasTypes);
clearstatcache();
$response = new Response($exportData);
$response->headers->set('Content-type', 'application/xml');
$response->headers->set('Content-Disposition', 'attachment; filename="'.self::CANVAS_TYPE.'-'.$canvasId.'.xml"');
$response->headers->set('Cache-Control', 'no-cache');
return $response;
}
/**
* buildXml - render the canvas board and its items as XML.
*
* @param string $canvasTitle Board title
* @param array<int, array<string, mixed>> $records Canvas item records
* @param array<string, array<string, mixed>> $canvasTypes Translated box definitions
*/
private function buildXml(string $canvasTitle, array $records, array $canvasTypes): string
{
$tab = str_repeat(' ', 4);
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'.PHP_EOL.PHP_EOL;
$xml .= '<canvas key="'.self::CANVAS_TYPE.'">'.PHP_EOL;
$xml .= $tab.'<title>'.$canvasTitle.'</title>'.PHP_EOL;
$xml .= $tab.'<content>'.PHP_EOL;
foreach ($canvasTypes as $key => $data) {
$xml .= $tab.$tab.'<element key="'.$key.'">'.PHP_EOL;
foreach ($records as $record) {
if ($record['box'] === $key) {
$xml .= $tab.$tab.$tab.'<item>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<created>'.($record['created'] ?? '').'</created>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<modified>'.($record['modified'] ?? '').'</modified>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<author id="'.($record['author'] ?? '').'" firstname="'.($record['authorFirstname'] ?? '').'" '.
'lastname="'.($record['authorLastname'] ?? '').'"/>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<description>'.($record['description'] ?? '').'</description>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<status key="'.($record['status'] ?? '').'" />'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<relates key="'.($record['relates'] ?? '').'" />'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<assumptions>'.($record['assumptions'] ?? '').'</assumptions>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<data>'.($record['data'] ?? '').'</data>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<conclusion>'.($record['conclusion'] ?? '').'</conclusion>'.PHP_EOL;
$xml .= $tab.$tab.$tab.'</item>'.PHP_EOL;
}
}
$xml .= $tab.$tab.'</element>'.PHP_EOL;
}
$xml .= $tab.'</content>'.PHP_EOL;
$xml .= '</canvas>'.PHP_EOL;
return $xml;
}
}

View File

@@ -0,0 +1,397 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Mailer;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Services\Projects;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepo;
use Symfony\Component\HttpFoundation\Response;
class ShowCanvas extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private $canvasRepo;
private Projects $projectService;
private Goalcanvas $goalService;
/**
* Initializes dependencies.
*/
public function init(Projects $projectService, Goalcanvas $goalService): void
{
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* Displays the goal canvas board view.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_REQUEST['searchCanvas'])) {
$currentCanvasId = (int) $_REQUEST['searchCanvas'];
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.showCanvas');
}
return new Response;
}
/**
* Handles goal canvas mutations (create, edit, clone, merge, import).
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT)]
public function post(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['cloneCanvas']) && $currentCanvasId > 0) {
$result = $this->handleCloneCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['mergeCanvas']) && $currentCanvasId > 0) {
$result = $this->handleMergeCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['importCanvas'])) {
$result = $this->handleImportCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.showCanvas');
}
return new Response;
}
/**
* Resolves the current canvas ID from session, GET params, or creates a default.
*/
private function resolveCurrentCanvasId(array &$allCanvas, array $params): int
{
$sessionKey = 'current'.strtoupper(static::CANVAS_NAME).'Canvas';
if (! $allCanvas) {
$values = [
'title' => $this->language->__('label.board'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// View-time convenience: lazily create a default board (in the CURRENT project) when
// none exist. Kept repo-direct/UNGATED on purpose — gating it through createGoalboard's
// CREATE check would 403 a VIEW-only user just for opening an empty goals page. It is
// not an IDOR (always the session project). Same landmine pattern as the Blueprints/
// Wiki/Ideas default-board/notebook bootstrap.
$currentCanvasId = $this->canvasRepo->addCanvas($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
return $currentCanvasId;
}
$currentCanvasId = -1;
if (session()->exists($sessionKey)) {
$currentCanvasId = session($sessionKey);
$found = false;
foreach ($allCanvas as $row) {
if ($currentCanvasId == $row['id']) {
$found = true;
break;
}
}
if (! $found) {
$currentCanvasId = -1;
session([$sessionKey => '']);
}
} else {
session([$sessionKey => '']);
}
if (session($sessionKey) == '') {
$currentCanvasId = $allCanvas[0]['id'];
session([$sessionKey => $currentCanvasId]);
}
if (isset($params['id'])) {
// Only honor an explicit board id that belongs to the CURRENT project's boards
// ($allCanvas is project-scoped). A foreign/unknown id must not become the active
// board — otherwise assignTemplateVars would read another project's items.
$requestedId = (int) $params['id'];
$projectBoardIds = array_map(static fn ($row) => (int) $row['id'], $allCanvas);
if (in_array($requestedId, $projectBoardIds, true)) {
$currentCanvasId = $requestedId;
session([$sessionKey => $currentCanvasId]);
}
}
return $currentCanvasId;
}
/**
* Handles creating a new canvas board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
$values = [
'title' => $_POST['canvastitle'],
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createGoalboard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->goalService->createGoalboard($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->notifyBoardCreated($values['title'], 'notification.board_created', 'email_notifications.canvas_created_message');
$this->tpl->setNotification($this->language->__('notification.board_created'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles editing a canvas board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// updateGoalboard authorizes EDIT against the board's real project.
$values = ['title' => $_POST['canvastitle'], 'id' => $currentCanvasId];
$currentCanvasId = $this->goalService->updateGoalboard($values);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles cloning a canvas board.
*/
private function handleCloneCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// copyGoalBoard authorizes VIEW on the source board's project and CREATE on the target.
$currentCanvasId = $this->goalService->copyGoalBoard(
$currentCanvasId,
(int) session('currentProject'),
(int) session('userdata.id'),
$_POST['canvastitle']
);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles merging two canvas boards.
*/
private function handleMergeCanvas(int $currentCanvasId): ?Response
{
if (! isset($_POST['canvasid']) || $_POST['canvasid'] <= 0) {
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
return null;
}
// mergeGoalBoard authorizes EDIT on the target board's project and VIEW on the source's.
$status = $this->goalService->mergeGoalBoard($currentCanvasId, (int) $_POST['canvasid']);
if ($status) {
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
return null;
}
/**
* Handles importing a canvas from an XML file.
*/
private function handleImportCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_FILES['canvasfile']) || $_FILES['canvasfile']['error'] !== 0) {
return null;
}
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
if (! move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$services = app()->make(BlueprintsService::class);
// Blueprints service expects the canvas slug (e.g. "goal"), not the full type ("goalcanvas").
$importCanvasId = $services->import(
$uploadfile,
static::CANVAS_NAME,
projectId: session('currentProject'),
authorId: session('userdata.id')
);
unlink($uploadfile);
if ($importCanvasId === false) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$currentCanvasId = $importCanvasId;
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
// The just-imported board is in the current project; getSingleCanvas returns a single
// row (Goalcanvas repo override) or false.
$canvas = $this->goalService->getSingleCanvas($currentCanvasId);
$this->notifyBoardCreated(
strip_tags($canvas !== false ? ($canvas['title'] ?? '') : ''),
'notification.board_imported',
'email_notifications.canvas_imported_message'
);
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Sends board creation/import notifications to project users.
*/
private function notifyBoardCreated(string $title, string $subjectKey, string $messageKey): void
{
$mailer = app()->make(Mailer::class);
$users = $this->projectService->getUsersToNotify(session('currentProject'));
$mailer->setSubject($this->language->__($subjectKey));
$message = sprintf(
$this->language->__($messageKey),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($title).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepo::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__($subjectKey),
session('currentProject')
);
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas): void
{
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('relatesLabels', $this->canvasRepo->getRelatesLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
$this->tpl->assign('disclaimer', $this->canvasRepo->getDisclaimer());
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->goalService->getCanvasItemsById($currentCanvasId));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Goalcanvas\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
/**
* Inline progress updates for the goal dialog — the RA budget/hours pattern:
* the readout's number IS the input; blur-when-changed posts here and the
* re-rendered readout (recomputed bar) swaps in. No Save ceremony for the
* recurring monitoring action.
*/
class GoalProgress extends HtmxController
{
protected static string $view = 'goalcanvas::partials.progressReadout';
private GoalcanvasService $goalService;
/**
* init - DI via init(), not __construct (HtmxController contract).
*/
public function init(GoalcanvasService $goalService): void
{
$this->goalService = $goalService;
}
/**
* Persist an inline current-value edit and re-render the readout.
* Authorization (EDIT against the goal's real project) and the
* zp_goal_history record both happen inside patchGoalItem.
*/
public function updateValue(): void
{
$itemId = (int) ($_POST['itemId'] ?? 0);
$value = $_POST['currentValue'] ?? null;
if ($itemId > 0 && is_numeric($value)) {
$goal = $this->goalService->getGoalItem($itemId);
// linkAndReport goals compute their current value from children —
// an inline write would be silently overridden, so refuse it.
if (is_array($goal) && ($goal['setting'] ?? '') !== 'linkAndReport') {
$this->goalService->patchGoalItem($itemId, ['currentValue' => (float) $value]);
}
}
$goal = $this->goalService->getGoalItem($itemId);
if (! is_array($goal)) {
// Unknown/foreign goal: render an empty readout rather than leak.
$goal = ['id' => $itemId, 'metricType' => 'number', 'startValue' => 0, 'currentValue' => 0, 'endValue' => 0, 'setting' => ''];
}
$this->tpl->assign('canvasItem', $goal);
}
}

View File

@@ -0,0 +1,274 @@
leantime.goalCanvasController = (function () {
var canvasName = 'goal';
var setRowHeights = function () {
var nbRows = 2;
var rowHeight = jQuery("html").height() - 320 - 20 * nbRows - 25;
/*
var firstRowHeight = rowHeight / nbRows;
jQuery("#firstRow div.contentInner").each(function(){
if(jQuery(this).height() > firstRowHeight){
firstRowHeight = jQuery(this).height() + 50;
}
});
jQuery("#firstRow .column .contentInner").css("height", firstRowHeight);
var secondRowHeight = rowHeight / nbRows;
jQuery("#secondRow div.contentInner").each(function(){
if(jQuery(this).height() > secondRowHeight){
secondRowHeight = jQuery(this).height() + 50;
}
});
jQuery("#secondRow .column .contentInner").css("height", secondRowHeight);
*/
};
// --- Internal (not to be changed beyond this point) ---
var closeModal = false;
//Variables
var canvasoptions = function () {
return {
sizes: {
minW: 700,
minH: 1000,
},
resizable: true,
autoSizable: true,
callbacks: {
beforeShowCont: function () {
jQuery(".showDialogOnLoad").show();
if (closeModal == true) {
closeModal = false;
location.reload();
}
},
afterShowCont: function () {
window.htmx.process('.nyroModalCont');
jQuery("." + canvasName + "CanvasModal, #commentForm, #commentForm .deleteComment, ." + canvasName + "CanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
},
beforeClose: function () {
location.reload();
}
},
titleFromIframe: true
}
};
var _initModals = function () {
jQuery("." + canvasName + "CanvasModal, #commentForm, #commentForm .deleteComment, ." + canvasName + "CanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
};
var openModalManually = function (url) {
jQuery.nmManual(url, canvasoptions);
};
var toggleMilestoneSelectors = function (trigger) {
if (trigger == 'existing') {
jQuery('#newMilestone, #milestoneSelectors').hide('fast');
jQuery('#existingMilestone').show();
_initModals();
}
if (trigger == 'new') {
jQuery('#newMilestone').show();
jQuery('#existingMilestone, #milestoneSelectors').hide('fast');
_initModals();
}
if (trigger == 'hide') {
jQuery('#newMilestone, #existingMilestone').hide('fast');
jQuery('#milestoneSelectors').show('fast');
}
};
var setCloseModal = function () {
closeModal = true;
};
var initUserDropdown = function () {
jQuery("body").on(
"click",
".userDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("_");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 3) {
var canvasId = dataValue[0];
var userId = dataValue[1];
var profileImageId = dataValue[2];
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/' + canvasName + 'canvas',
data:
{
id : canvasId,
author:userId
}
}
).done(
function () {
jQuery("#userDropdownMenuLink" + canvasId + " span.text span#userImage" + canvasId + " img").attr("src", leantime.appUrl + "/users/profileImage/" + encodeURIComponent(userId));
jQuery.growl({message: leantime.i18n.__("short_notifications.user_updated"), style: "success"});
}
);
}
}
);
};
var initStatusDropdown = function () {
jQuery("body").on(
"click",
".statusDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("/");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 2) {
var canvasItemId = dataValue[0];
var status = dataValue[1];
var statusClass = jQuery(this).attr('class');
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/' + canvasName + 'canvas',
data:
{
id : canvasItemId,
status: status
}
}
).done(
function () {
jQuery("#statusDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
jQuery("#statusDropdownMenuLink" + canvasItemId).removeClass().addClass(statusClass + " dropdown-toggle f-left status ");
jQuery.growl({message: leantime.i18n.__("short_notifications.status_updated")});
}
);
}
}
);
};
var initRelatesDropdown = function () {
jQuery("body").on(
"click",
".relatesDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("/");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 2) {
var canvasItemId = dataValue[0];
var relates = dataValue[1];
var relatesClass = jQuery(this).attr('class');
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/' + canvasName + 'canvas',
data:
{
id : canvasItemId,
relates: relates
}
}
).done(
function () {
jQuery("#relatesDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
jQuery("#relatesDropdownMenuLink" + canvasItemId).removeClass().addClass(relatesClass + " dropdown-toggle f-left relates ");
jQuery.growl({message: leantime.i18n.__("short_notifications.relates_updated")});
}
);
}
}
);
};
var initProgressChart = function (chartId, complete, incomplete ) {
var config = {
type: 'doughnut',
data: {
datasets: [{
data: [
complete,
incomplete
],
backgroundColor: [
leantime.dashboardController.chartColors.green,
leantime.dashboardController.chartColors.grey
],
label: leantime.i18n.__("label.project_done")
}],
labels: [
complete + '%',
]
},
options: {
maintainAspectRatio : true,
responsive: true,
plugins: {
legend: {
position: 'none',
},
title: {
display: false,
text: 'Complete'
}
},
animation: {
animateScale: true,
animateRotate: true
}
}
};
var ctx = document.getElementById(chartId).getContext('2d');
_progressChart = new Chart(ctx, config);
};
// Make public what you want to have public, everything else is private
return {
setCloseModal:setCloseModal,
toggleMilestoneSelectors: toggleMilestoneSelectors,
openModalManually:openModalManually,
initUserDropdown:initUserDropdown,
initStatusDropdown:initStatusDropdown,
initRelatesDropdown:initRelatesDropdown,
setRowHeights:setRowHeights,
initProgressChart:initProgressChart
};
})();

View File

@@ -0,0 +1,46 @@
<?php
namespace Leantime\Domain\Goalcanvas\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Goalcanvas (Goals / OKRs) permission vocabulary — the verbs only.
*
* Goals are a distinct headline feature (OKR-style goal boards), even though they are stored
* in the shared `zp_canvas` (type `goalcanvas`) / `zp_canvas_items` (box `goal`) tables. They
* get their own `goals.*` vocabulary — separate from the generic `blueprints.*` strategy
* canvases — so a role/permission admin can grant goal access independently.
*
* Goal boards/items are PROJECT-scoped (each board belongs to one project), so every capability
* is evaluated against the user's role IN the board's project (projectScoped = true, the
* default). The standard verbs auto-grant via the central matrix (readonly = view; editor =
* create/edit/delete; manager+ = all), so no
* {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions} change is required.
*/
final class GoalcanvasPermissions implements ProvidesPermissions
{
public const VIEW = 'goals.view';
public const CREATE = 'goals.create';
public const EDIT = 'goals.edit';
public const DELETE = 'goals.delete';
public function domain(): string
{
return 'goals';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View goals'),
new Permission(self::CREATE, 'Create goals and goal boards'),
new Permission(self::EDIT, 'Edit goals and goal boards'),
new Permission(self::DELETE, 'Delete goals and goal boards'),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,907 @@
<?php
namespace Leantime\Domain\Goalcanvas\Services;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
/**
* Goalcanvas (Goals / OKRs) service.
*
* Goal boards/items live in the shared zp_canvas (type "goalcanvas") / zp_canvas_items (box
* "goal") tables. Every by-id board/item operation routes through this service, which resolves
* the entity's REAL project via the repository's fail-closed resolvers (inherited from the
* Blueprints repo, scoped to the "goalcanvas" type) and authorizes the matching
* {@see GoalcanvasPermissions} verb against it. A resolver returning null (missing id, or an id
* whose board is a different canvas type) is treated as DENY — reads soft-deny (neutral value)
* so they are not a cross-project existence oracle; writes throw an AuthorizationException
* without writing. Never falls back to the session project for a by-id entity.
*
* @api
*/
class Goalcanvas extends BaseService
{
/** Database canvas type for goal boards (CANVAS_NAME "goal" + "canvas"). */
private const CANVAS_TYPE = 'goalcanvas';
private GoalcanvaRepository $goalRepository;
private ProjectService $projectService;
/** @var array<int, int>|null Memoized accessibleProjectIds() result. */
private ?array $accessibleProjectIdsCache = null;
public array $reportingSettings = [
'linkonly',
'linkAndReport',
'nolink',
];
public function __construct(GoalcanvaRepository $goalRepository, ProjectService $projectService)
{
$this->goalRepository = $goalRepository;
$this->projectService = $projectService;
}
/**
* List the goals on a board (by board id), authorized for VIEW against the board's project.
* Returns [] for a missing/foreign/unauthorized board (neutral — no oracle).
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function getCanvasItemsById(int $id): array
{
$projectId = $this->goalRepository->getCanvasProjectId($id, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return [];
}
$goals = $this->goalRepository->getCanvasItemsById($id);
if ($goals) {
// Attach each goal's tracked_by milestone chips (one batched read),
// so the board/dashboard render the edge model, not the legacy column.
$milestonesByGoal = $this->goalRepository->getMilestonesForGoals(
array_map(static fn ($g) => (int) $g['id'], $goals)
);
foreach ($goals as &$goal) {
$goal['milestones'] = $this->filterAccessibleMilestones($milestonesByGoal[(int) $goal['id']] ?? []);
$progressValue = 0;
$goal['goalProgress'] = 0;
$total = $goal['endValue'] - $goal['startValue'];
// Skip if start and end are the same (no range to measure).
if ($total == 0) {
continue;
}
if ($goal['setting'] == 'linkAndReport') {
// GetAll Child elements
$currentValueSum = $this->getChildGoalsForReporting($goal['id']);
$goal['currentValue'] = $currentValueSum;
$progressValue = $currentValueSum - $goal['startValue'];
} else {
$progressValue = $goal['currentValue'] - $goal['startValue'];
}
$goal['goalProgress'] = max(0, min(100, round($progressValue / $total, 2) * 100));
}
}
return $goals;
}
/**
* Sum the linked children's current values for a parent goal, authorized for VIEW against
* the PARENT goal's project. The cross-project roll-up of linked children is the feature;
* the gate is on the parent the caller asked about. Returns 0 for a missing/foreign/
* unauthorized parent.
*
* @return int|mixed
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function getChildGoalsForReporting($parentId): mixed
{
$projectId = $this->goalRepository->getCanvasItemProjectId((int) $parentId, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return 0;
}
// Goals come back as rows for levl1 and lvl2 being columns, so
// goal A | goalChildA
// goal A | goalChildB
// goal B
// Checks if first level is also link+report or just link
$goals = $this->goalRepository->getCanvasItemsByKPI($parentId);
$currentValueSum = 0;
foreach ($goals as $child) {
if ($child['setting'] == 'linkAndReport') {
$currentValueSum = $currentValueSum + $child['childCurrentValue'];
} else {
$currentValueSum = $currentValueSum + $child['currentValue'];
}
}
return $currentValueSum;
}
/**
* The child-goal hierarchy for a parent goal, authorized for VIEW against the PARENT goal's
* project. Returns [] for a missing/foreign/unauthorized parent.
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function getChildrenbyKPI($parentId): array
{
$projectId = $this->goalRepository->getCanvasItemProjectId((int) $parentId, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return [];
}
$goals = [];
// Goals come back as rows for levl1 and lvl2 being columns, so
// goal A | goalChildA
// goal A | goalChildB
// goal B
// Checks if first level is also link+report or just link
$children = $this->goalRepository->getCanvasItemsByKPI($parentId);
foreach ($children as $child) {
// Added Child already? Look for child of child
if (! isset($goals[$child['id']])) {
$goals[$child['id']] = [
'id' => $child['id'],
'title' => $child['title'],
'startValue' => $child['startValue'],
'endValue' => $child['endValue'],
'currentValue' => $child['currentValue'],
'metricType' => $child['metricType'],
'boardTitle' => $child['boardTitle'],
'canvasId' => $child['canvasId'],
'projectName' => $child['projectName'],
];
}
if ($child['childId'] != '') {
if (isset($goals[$child['childId']]) === false) {
$goals[$child['childId']] = [
'id' => $child['childId'],
'title' => $child['childTitle'],
'startValue' => $child['childStartValue'],
'endValue' => $child['childEndValue'],
'currentValue' => $child['childCurrentValue'],
'metricType' => $child['childMetricType'],
'boardTitle' => $child['childBoardTitle'],
'canvasId' => $child['childCanvasId'],
'projectName' => $child['childProjectName'],
];
}
}
}
return $goals;
}
/**
* Available parent KPIs for linking, authorized for VIEW against $projectId (dispatch gate).
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')]
public function getParentKPIs($projectId): array
{
$kpis = $this->goalRepository->getAllAvailableKPIs($projectId);
$goals = [];
// Checks if first level is also link+report or just link
foreach ($kpis as $kpi) {
$goals[$kpi['id']] = [
'id' => $kpi['id'],
'description' => $kpi['description'],
'project' => $kpi['projectName'],
'board' => $kpi['boardTitle'],
];
}
return $goals;
}
/**
* Goals linked to a milestone, authorized for VIEW against the milestone's
* project. Reachable beyond the milestone UI (MCP getGoalsByMilestone wraps
* it verbatim), so the caller's access to the milestone cannot be assumed —
* a missing/foreign/unauthorized milestone returns [] (neutral, no oracle).
*/
public function getGoalsByMilestone($milestoneId): array
{
// One cast, used for BOTH the authorization resolve and the read —
// authorizing one value and reading another invites drift.
$milestoneId = (int) $milestoneId;
$projectId = $this->goalRepository->getMilestoneProjectId($milestoneId);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return [];
}
return $this->goalRepository->getGoalsByMilestone($milestoneId);
}
/**
* The milestone chips for a goal + a status summary, authorized for VIEW
* against the goal's project. Chips arrive already sorted (in-progress →
* not-started → done, then due date). The summary drives the one-line
* roll-up above the chips.
*
* @return array{milestones: array<int, array<string, mixed>>, summary: array{total: int, done: int, inProgress: int, notStarted: int}}
*
* @api
*/
public function getGoalMilestones(int $goalId): array
{
$empty = ['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0];
$projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return ['milestones' => [], 'summary' => $empty];
}
// Same defensive strip the rollup reads apply — keeps the editor chips
// consistent with getMilestonesByGoal/getGoalRollup for any legacy
// cross-project rows, and the summary counts what is actually shown.
$milestones = $this->filterAccessibleMilestones(
$this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? []
);
$summary = ['total' => count($milestones)] + $empty;
foreach ($milestones as $m) {
$type = $m['statusType'];
if ($type === 'DONE') {
$summary['done']++;
} elseif ($type === 'INPROGRESS') {
$summary['inProgress']++;
} else {
$summary['notStarted']++;
}
}
return ['milestones' => $milestones, 'summary' => $summary];
}
/**
* Batch sibling of getGoalMilestones(): hydrate milestone chips for many
* goals in a single pass, so callers with a goal set (e.g. the reports
* engine) avoid an N+1. Each goal is authorized for VIEW against its real
* project; goals the caller can't see are silently omitted. Returns a map
* keyed by goal id: every AUTHORIZED goal is present, mapping to an empty
* array when it has no milestones — so callers get a predictable key set.
* Only unauthorized/invisible goals are absent.
*
* @param int[] $goalIds
* @return array<int, array<int, array<string, mixed>>>
*
* @api
*/
public function getMilestonesForGoals(array $goalIds): array
{
// Resolve every goal's project in ONE query (not a query per goal), then
// authorize per DISTINCT project with a cached VIEW check — so the
// auth phase stays O(1) queries regardless of goal-set size.
$projectByGoal = $this->goalRepository->getCanvasItemProjectIds($goalIds, self::CANVAS_TYPE);
if ($projectByGoal === []) {
return [];
}
$accessByProject = [];
$authorized = [];
foreach ($projectByGoal as $goalId => $projectId) {
if (! array_key_exists($projectId, $accessByProject)) {
$accessByProject[$projectId] = $this->can(GoalcanvasPermissions::VIEW, $projectId);
}
if ($accessByProject[$projectId]) {
$authorized[] = (int) $goalId;
}
}
if ($authorized === []) {
return [];
}
// Single hydration pass for the whole authorized set (the expensive part
// — status labels + progress — is batched inside the repository). Fill
// an empty entry for every authorized goal so an @api caller gets a
// predictable key set, not just the goals that happen to have chips.
// Each goal's chips get the same defensive cross-project strip as the
// single-goal reads (accessibleProjectIds is memoized, so this stays
// one projects lookup for the whole batch).
return array_replace(
array_fill_keys($authorized, []),
array_map(
fn (array $milestones) => $this->filterAccessibleMilestones($milestones),
$this->goalRepository->getMilestonesForGoals($authorized)
)
);
}
/**
* Link one milestone to a goal (append — leaves existing links intact).
* Authorized for EDIT against the goal's project.
*
* @throws AuthorizationException When the goal is unknown/foreign or EDIT is denied.
*
* @api
*/
public function addMilestoneToGoal(int $goalId, int $milestoneId): bool
{
$projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $projectId);
return $this->goalRepository->addGoalMilestoneLink($goalId, $milestoneId, (int) session('userdata.id'));
}
/**
* Unlink one milestone from a goal (leaves the goal's other links intact).
* Authorized for EDIT against the goal's project.
*
* @throws AuthorizationException When the goal is unknown/foreign or EDIT is denied.
*
* @api
*/
public function removeMilestoneFromGoal(int $goalId, int $milestoneId): bool
{
$projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $projectId);
return $this->goalRepository->removeGoalMilestoneLink($goalId, $milestoneId);
}
/**
* Cascade: drop every goal's link to a milestone that is being deleted.
* Called from the (already-authorized) milestone-delete path. Intentionally
* NOT @api — it skips authorization by design, so it must never be reachable
* as an unauthenticated JSON-RPC method.
*/
public function detachMilestoneFromGoals(int $milestoneId): bool
{
return $this->goalRepository->removeMilestoneFromAllGoals($milestoneId);
}
/**
* A goal's milestones (edge model) for the mobile Progress feature —
* authorized for VIEW against the goal's project, and each milestone is
* further filtered to the caller's accessible projects as defense-in-depth
* (goal→milestone links are same-project only, so normally a no-op). Returns []
* for a missing/foreign/unauthorized goal. Dates are returned as stored
* (UTC). A milestone may appear under several goals — this is many-to-many.
*
* @return array<int, array<string, mixed>>
*
* @api
*/
public function getMilestonesByGoal(int $goalId): array
{
$projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return [];
}
$milestones = $this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? [];
return $this->filterAccessibleMilestones($milestones);
}
/**
* Aggregate rollup for a goal's milestones — the payload the mobile
* Progress "arc" is drawn from. Authorized for VIEW against the goal's
* project; milestones filtered to the caller's accessible projects.
* Computed over the batched milestone read (no N+1). Dates returned as
* stored (UTC); `currentMilestoneId` is the first not-done milestone in
* the working order (in-progress -> not-started -> done, by due date).
*
* @return array{goalId: int, total: int, done: int, inProgress: int, notStarted: int, percentComplete: int, startDate: string|null, endDate: string|null, currentMilestoneId: int|null}
*
* @api
*/
public function getGoalRollup(int $goalId): array
{
$empty = [
'goalId' => $goalId, 'total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0,
'percentComplete' => 0, 'startDate' => null, 'endDate' => null, 'currentMilestoneId' => null,
];
$projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return $empty;
}
$milestones = $this->filterAccessibleMilestones(
$this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? []
);
if ($milestones === []) {
return $empty;
}
$done = 0;
$inProgress = 0;
$notStarted = 0;
$progressSum = 0;
$start = null;
$end = null;
$current = null;
foreach ($milestones as $m) {
$type = $m['statusType'] ?? 'NEW';
if ($type === 'DONE') {
$done++;
} elseif ($type === 'INPROGRESS') {
$inProgress++;
} else {
$notStarted++;
}
$progressSum += (int) $m['percentDone'];
$from = $this->validDate($m['editFrom'] ?? null);
$to = $this->validDate($m['editTo'] ?? null);
if ($from !== null && ($start === null || $from < $start)) {
$start = $from;
}
if ($to !== null && ($end === null || $to > $end)) {
$end = $to;
}
if ($current === null && $type !== 'DONE') {
$current = (int) $m['id'];
}
}
$total = count($milestones);
return [
'goalId' => $goalId,
'total' => $total,
'done' => $done,
'inProgress' => $inProgress,
'notStarted' => $notStarted,
'percentComplete' => (int) round($progressSum / $total),
'startDate' => $start,
'endDate' => $end,
'currentMilestoneId' => $current,
];
}
/**
* Defensive strip: keep only milestones in projects the caller can access.
* Goal→milestone links are same-project only (addGoalMilestoneLink fails
* closed on a foreign milestone), so in normal data this is a no-op — it
* only guards against any legacy cross-project rows.
*
* @param array<int, array<string, mixed>> $milestones
* @return array<int, array<string, mixed>>
*/
private function filterAccessibleMilestones(array $milestones): array
{
$accessible = array_flip($this->accessibleProjectIds());
return array_values(array_filter(
$milestones,
static fn ($m) => isset($accessible[(int) ($m['projectId'] ?? 0)])
));
}
/**
* Project ids the current user may access. Memoized per request — the
* filter now runs per goal in batched reads, and the underlying projects
* lookup is expensive.
*
* @return array<int, int>
*/
private function accessibleProjectIds(): array
{
if ($this->accessibleProjectIdsCache !== null) {
return $this->accessibleProjectIdsCache;
}
$projects = $this->projectService->getProjectsUserHasAccessTo();
if (! is_array($projects)) {
return $this->accessibleProjectIdsCache = [];
}
return $this->accessibleProjectIdsCache = array_values(array_filter(
array_map(static fn ($p) => (int) ($p['id'] ?? 0), $projects),
static fn ($id) => $id > 0
));
}
/**
* Return a stored datetime if it's a real value, else null (filters the
* '0000-00-00 00:00:00' / empty sentinels milestones can carry).
*/
private function validDate(mixed $value): ?string
{
$s = trim((string) $value);
if ($s === '' || str_starts_with($s, '0000-00-00')) {
return null;
}
return $s;
}
// ---------------------------------------------------------------------------------------
// Secured by-id board/item CRUD chokepoint (controllers call these instead of the repo).
// ---------------------------------------------------------------------------------------
/**
* Fetch a single goal item by id, authorized for VIEW against the item's real project.
*
* @return array<string, mixed>|false False when missing/foreign/unauthorized.
*/
public function getGoalItem(int $id): array|false
{
$projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return false;
}
$item = $this->goalRepository->getSingleCanvasItem($id);
if (is_array($item)) {
// Surface the item's REAL (authorized) project so callers scope
// project-dependent UI to the goal's project, not the session's —
// the dialog can be opened for a goal outside the current project.
$item['projectId'] = $projectId;
}
return $item;
}
/**
* Fetch a single goal board by id, authorized for VIEW against the board's real project.
*
* @return array<string, mixed>|false False when missing/foreign/unauthorized.
*/
public function getSingleCanvas($id)
{
$projectId = $this->goalRepository->getCanvasProjectId((int) $id, self::CANVAS_TYPE);
if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) {
return false;
}
return $this->goalRepository->getSingleCanvas((int) $id);
}
/**
* Create a goal board, authorized for CREATE against the target project.
*
* @throws AuthorizationException When projectId is missing or CREATE is denied.
*/
public function createGoalboard($values)
{
$projectId = (int) ($values['projectId'] ?? 0);
if ($projectId === 0) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::CREATE, $projectId);
return $this->goalRepository->addCanvas($values);
}
/**
* Rename a goal board, authorized for EDIT against the board's real project.
*
* @throws AuthorizationException When the board is unknown/foreign or EDIT is denied.
*/
public function updateGoalboard($values)
{
$projectId = $this->goalRepository->getCanvasProjectId((int) ($values['id'] ?? 0), self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $projectId);
return $this->goalRepository->updateCanvas($values);
}
/**
* Copy a goal board into a target project. Requires VIEW on the source board's project and
* CREATE in the target project.
*
* @throws AuthorizationException When the source is unknown/foreign, or VIEW/CREATE is denied.
*/
public function copyGoalBoard(int $sourceCanvasId, int $targetProjectId, int $authorId, string $title): int
{
$sourceProjectId = $this->goalRepository->getCanvasProjectId($sourceCanvasId, self::CANVAS_TYPE);
if ($sourceProjectId === null || $targetProjectId <= 0) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::VIEW, $sourceProjectId);
$this->authorize(GoalcanvasPermissions::CREATE, $targetProjectId);
return $this->goalRepository->copyCanvas($targetProjectId, $sourceCanvasId, $authorId, $title);
}
/**
* Merge a source goal board's items into a target board. Requires EDIT on the target board's
* project and VIEW on the source board's project — both resolved by id.
*
* @throws AuthorizationException When either board is unknown/foreign, or EDIT/VIEW is denied.
*/
public function mergeGoalBoard(int $targetCanvasId, int $sourceCanvasId): bool
{
$targetProjectId = $this->goalRepository->getCanvasProjectId($targetCanvasId, self::CANVAS_TYPE);
$sourceProjectId = $this->goalRepository->getCanvasProjectId($sourceCanvasId, self::CANVAS_TYPE);
if ($targetProjectId === null || $sourceProjectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $targetProjectId);
$this->authorize(GoalcanvasPermissions::VIEW, $sourceProjectId);
return $this->goalRepository->mergeCanvas($targetCanvasId, $sourceCanvasId);
}
/**
* Delete a goal board (and its items), authorized for DELETE against the board's real project.
*
* @throws AuthorizationException When the board is unknown/foreign or DELETE is denied.
*/
public function deleteGoalBoard(int $canvasId): void
{
$projectId = $this->goalRepository->getCanvasProjectId($canvasId, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::DELETE, $projectId);
$this->goalRepository->deleteCanvas($canvasId);
}
/**
* Create a goal item, authorized for CREATE against the target board's real project.
*
* @param array<string, mixed> $values Item values (must include `canvasId`)
* @return false|string New item id, or false on insert failure
*
* @throws AuthorizationException When the target board is unknown/foreign or CREATE is denied.
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::CREATE, entityScoped: true)]
public function createGoal($values)
{
$projectId = $this->goalRepository->getCanvasProjectId((int) ($values['canvasId'] ?? 0), self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::CREATE, $projectId);
$newId = $this->goalRepository->createGoal($values);
if ($newId !== false && array_key_exists('milestoneId', $values)) {
$this->syncGoalMilestoneEdges((int) $newId, $values['milestoneId'], (int) session('userdata.id'));
}
return $newId;
}
/**
* Create a goal item (controller add-item path), authorized for CREATE against the target
* board's real project.
*
* @param array<string, mixed> $values Item values (must include `canvasId`)
* @return false|string New item id, or false on insert failure
*
* @throws AuthorizationException When the target board is unknown/foreign or CREATE is denied.
*/
public function createGoalItem(array $values): false|string
{
$projectId = $this->goalRepository->getCanvasProjectId((int) ($values['canvasId'] ?? 0), self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::CREATE, $projectId);
$newId = $this->goalRepository->addCanvasItem($values);
// Only reconcile edges when a real milestone id is supplied. A brand-new
// item has no edges to clear, so an empty milestoneId — controllers post
// '' for every box via a hidden input — would just cost a wasted lookup.
// The update/patch paths still process empty values there, where clearing
// an existing link is a meaningful edit.
$milestoneIdValue = $values['milestoneId'] ?? null;
if ($newId !== false
&& is_scalar($milestoneIdValue)
&& filter_var($milestoneIdValue, FILTER_VALIDATE_INT) > 0
) {
$this->syncGoalMilestoneEdges((int) $newId, $milestoneIdValue, (int) session('userdata.id'));
}
return $newId;
}
/**
* Update a goal item, authorized for EDIT against the item's real project. The project is
* resolved from the existing item's id, so the payload's canvasId cannot relocate it.
*
* @param array<string, mixed> $values Item values (must include `itemId` or `id`)
*
* @throws AuthorizationException When the item is unknown/foreign or EDIT is denied.
*/
public function updateGoalItem(array $values): void
{
$itemId = (int) ($values['itemId'] ?? $values['id'] ?? 0);
$projectId = $this->goalRepository->getCanvasItemProjectId($itemId, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $projectId);
$this->goalRepository->editCanvasItem($values);
if (array_key_exists('milestoneId', $values)) {
$this->syncGoalMilestoneEdges($itemId, $values['milestoneId'], (int) session('userdata.id'));
}
}
/**
* Reconcile a goal's tracked_by milestone edges against the desired set.
* Accepts a single milestone id (scalar — the transitional single-select
* write) OR an array (multi-select). Set-based, so an unchanged save
* doesn't churn edges and an empty value/array clears all links.
*
* @param mixed $milestoneIdValue int|string|array<int|string> milestone id(s), or '' to clear
*/
private function syncGoalMilestoneEdges(int $goalId, mixed $milestoneIdValue, int $userId): void
{
if ($goalId <= 0) {
return;
}
$desired = [];
foreach (is_array($milestoneIdValue) ? $milestoneIdValue : [$milestoneIdValue] as $value) {
// Strict int validation — (int) would coerce '42abc' to 42 and could
// silently link the wrong milestone if a malformed value reaches this
// path (e.g. via JSON-RPC). Non-scalar / non-int values are dropped.
$milestoneId = is_scalar($value) ? filter_var($value, FILTER_VALIDATE_INT) : false;
if ($milestoneId !== false && $milestoneId > 0) {
$desired[] = $milestoneId;
}
}
$desired = array_values(array_unique($desired));
$current = $this->goalRepository->getMilestoneIdsForGoal($goalId);
foreach (array_diff($current, $desired) as $remove) {
$this->goalRepository->removeGoalMilestoneLink($goalId, (int) $remove);
}
foreach (array_diff($desired, $current) as $add) {
$this->goalRepository->addGoalMilestoneLink($goalId, (int) $add, $userId);
}
}
/**
* Patch allowlisted columns of a goal item, authorized for EDIT against the item's real
* project.
*
* @param array<string, mixed> $params Fields to patch (allowlisted in the repository)
*
* @throws AuthorizationException When the item is unknown/foreign or EDIT is denied.
*/
public function patchGoalItem(int $id, array $params): bool
{
$projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::EDIT, $projectId);
$result = $this->goalRepository->patchCanvasItem($id, $params);
// Only mirror the milestoneId change into the tracked_by edges when the
// column patch actually persisted — otherwise the edges would drift from
// the milestoneId column and break the dual-write invariant.
if ($result && array_key_exists('milestoneId', $params)) {
$this->syncGoalMilestoneEdges($id, $params['milestoneId'], (int) session('userdata.id'));
}
return $result;
}
/**
* Delete a goal item, authorized for DELETE against the item's real project.
*
* @throws AuthorizationException When the item is unknown/foreign or DELETE is denied.
*/
public function deleteGoalItem(int $id): void
{
$projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE);
if ($projectId === null) {
throw new AuthorizationException;
}
$this->authorize(GoalcanvasPermissions::DELETE, $projectId);
$this->goalRepository->delCanvasItem($id);
$this->goalRepository->removeAllGoalMilestoneLinks($id);
}
/**
* Poll all goals the user can access (optionally scoped to a project/board). The repository
* query already filters to the user's accessible projects; the dispatch gate adds the
* capability check.
*
* @return array
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')]
public function pollGoals(?int $projectId = null, ?int $board = null)
{
$goals = $this->goalRepository->getAllAccountGoals($projectId, $board);
foreach ($goals as $key => $goal) {
$goals[$key] = $this->prepareDatesForApiResponse($goal);
}
return $goals;
}
/**
* @return array
*
* @api
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForUpdatedGoals(?int $projectId = null, ?int $board = null): array|false
{
$goals = $this->goalRepository->getAllAccountGoals($projectId, $board);
foreach ($goals as $key => $goal) {
$goals[$key] = $this->prepareDatesForApiResponse($goal);
$goals[$key]['id'] = $goal['id'].'-'.$goal['modified'];
}
return $goals;
}
private function prepareDatesForApiResponse($goal)
{
if (dtHelper()->isValidDateString($goal['created'])) {
$goal['created'] = dtHelper()->parseDbDateTime($goal['created'])->toIso8601ZuluString();
} else {
$goal['created'] = null;
}
if (dtHelper()->isValidDateString($goal['modified'])) {
$goal['modified'] = dtHelper()->parseDbDateTime($goal['modified'])->toIso8601ZuluString();
} else {
$goal['modified'] = null;
}
if (dtHelper()->isValidDateString($goal['startDate'])) {
$goal['startDate'] = dtHelper()->parseDbDateTime($goal['startDate'])->toIso8601ZuluString();
} else {
$goal['startDate'] = null;
}
if (dtHelper()->isValidDateString($goal['endDate'])) {
$goal['endDate'] = dtHelper()->parseDbDateTime($goal['endDate'])->toIso8601ZuluString();
} else {
$goal['endDate'] = null;
}
return $goal;
}
}

View File

@@ -0,0 +1,47 @@
<h4 class="widgettitle title-light">
<i class="fa-solid fa-mountain"></i>
{{ empty($bigRock['title']) ? __('label.create_new_goalboard') : __('label.goalboard') }} {{ $bigRock['title'] }}
</h4>
<form class="formModal" method="post"
action="{{ BASE_URL }}/goalcanvas/bigRock/{{ !empty($bigRock['id']) ? $bigRock['id'] : '' }}">
<br />
<label>{{ __('label.goal_description') }}</label>
<x-global::forms.text-input name="title" id="wikiTitle" value="{{ $bigRock['title'] }}" style="width:100%;" /><br />
<br />
<div class="row">
<div class="col-md-6">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="saveBtn" />
</div>
<div class="col-md-6 align-right padding-top-sm">
</div>
</div>
</form>
<script>
jQuery(document).ready(function() {
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
if (jQuery("#wikiTitle").val().length >= 2) {
jQuery("#saveBtn").removeAttr("disabled");
} else {
jQuery("#saveBtn").attr("disabled", "disabled");
}
jQuery("#wikiTitle").keypress(function() {
if (jQuery("#wikiTitle").val().length >= 2) {
jQuery("#saveBtn").removeAttr("disabled");
} else {
jQuery("#saveBtn").attr("disabled", "disabled");
}
})
});
</script>

View File

@@ -0,0 +1,65 @@
@extends($layout)
@section('content')
@php
/**
* canvasComment.inc template - Generic template for comments
*
*/
@endphp
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
//It's not a modal
location.href = "{{ BASE_URL }}/goalcanvas/showCanvas?showModal={{ $canvasItem['id'] }}";
}
}
</script>
<div class="showDialogOnLoad" style="display:none;">
<h4 class="widgettitle title-light" style="padding-bottom: 0">
<i class="fas {{ $canvasTypes[$canvasItem['box']]['icon'] }}"></i>
{{ $canvasTypes[$canvasItem['box']]['title'] }}
</h4>
<hr style="margin-top: 5px; margin-bottom: 15px;">
<h5 style="padding-left: 40px"><strong>{{ $canvasItem['description'] }}</strong></h5>
@if ($id !== '')
<br />
<input type="hidden" name="comment" value="1" />
<h4 class="widgettitle title-light">
<span class="fa fa-comments"></span>{{ __('subtitles.discussion') }}
</h4>
@include('comments::submodules.generalComment', ['formUrl' => '/goalcanvas/editCanvasComment/' . $id])
@endif
</div>
<script type="text/javascript">
jQuery(document).ready(function() {
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
@if(!$login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif;
@if($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif;
})
</script>
@endsection

View File

@@ -0,0 +1,471 @@
@extends($layout)
@section('content')
@php
$hiddenRelatesLabels = $relatesLabels ?? [];
// Metric preview — from the SAVED values; re-renders on save.
$mType = $canvasItem['metricType'] ?: 'number'; // new goals default to # (no math)
$mStart = (float) ($canvasItem['startValue'] ?? 0);
$mCur = (float) ($canvasItem['currentValue'] ?? 0);
$mGoal = (float) ($canvasItem['endValue'] ?? 0);
$mPct = ($mGoal - $mStart) != 0 ? max(0, min(100, (($mCur - $mStart) / ($mGoal - $mStart)) * 100)) : 0;
$fmtM = function ($v) use ($mType) {
$v = (float) $v;
if ($mType === 'percent') return rtrim(rtrim(number_format($v, 2), '0'), '.') . '%';
if ($mType === 'currency') return '$' . number_format($v, $v == floor($v) ? 0 : 2);
return rtrim(rtrim(number_format($v, 2), '0'), '.');
};
@endphp
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
location.href = "{{ BASE_URL }}/goalcanvas/showCanvas?showModal={{ $canvasItem['id'] }}";
}
}
</script>
<style>
/* ── Goal dialog v1 — tabbed (Goal / Progress / Milestones) so only one
zone shows at a time. Presentation only: fields keep name/id/class. ── */
/* System design tokens, not a private palette (alignment pass
2026-08-03): ink/lines/accent/sizes derive from the theme, so the
dialog matches the rest of the app in BOTH themes. */
.gvDialog{width:860px;max-width:min(860px, 94vw);padding:6px 18px 14px;
--gv-acc:var(--accent1);
--gv-acc2:var(--accent1);
--gv-line:var(--main-border-color);
--gv-line-soft:color-mix(in srgb, var(--main-border-color) 55%, transparent);
--gv-ink:var(--primary-font-color);
--gv-ink2:color-mix(in srgb, var(--primary-font-color) 68%, transparent);
--gv-soft:var(--secondary-background);}
/* Two-column split (Docs-article pattern, review 2026-08-03): main
work area left, "Details" rail right with the essentials. The rail
hugs its content (no full-height stretch — that stranded Delete in
dead space) and keeps a tight label-over-field rhythm. */
.gv-cols{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:0 28px;align-items:start;}
.gv-side{border-left:1px solid var(--gv-line-soft);padding-left:24px;display:flex;flex-direction:column;gap:16px;}
.gv-side-head{margin:0;}
.gv-side label{margin-bottom:5px;}
.gv-side .gv-dates{grid-template-columns:1fr;max-width:none;gap:12px;}
.gv-side .gv-delete-slot{padding-top:16px;border-top:1px solid var(--gv-line-soft);}
/* Discussion sits under the MAIN column (its own form — kept outside
the goal form so the nested-form parse never orphans the Save
buttons again). */
.gv-discussion{margin-right:calc(240px + 28px);}
/* Discussion stays quiet — smaller avatar so the profile color doesn't
compete with the work area (review 2026-08-03). */
.gv-discussion .commentImage img,.gv-discussion .commentImage .profileImage{width:30px!important;height:30px!important;}
@media (max-width:900px){.gv-cols{grid-template-columns:1fr;}.gv-side{border-left:none;padding-left:0;}.gv-discussion{margin-right:0;}}
/* Section headers ride the SYSTEM recipe (h4.widgettitle.title-light,
same as the task modal) — no dialog-private eyebrow styles. */
.gvDialog > h4.widgettitle{margin:2px 0 12px;}
.gvDialog label{font-size:var(--font-size-s);font-weight:600;color:var(--gv-ink2);display:block;margin:0 0 6px;}
.gv-field-lbl{font-size:var(--font-size-s);color:var(--gv-ink2);margin:0 0 6px;}
.gv-unit{font-size:var(--font-size-xs);font-weight:700;color:var(--gv-acc);opacity:.85;}
/* tab bar — report deck style (gradient bar + translucent group + white active pill) */
/* Tab visuals come from the shared floating-pill standard
(tab-group.css: .lt-tabs--floating + --onlight for this white
modal surface); only the dialog-specific spacing stays here. */
.gv-tabs{margin:0 0 16px;}
.gv-tab i,.gv-tab span[class*="fa"]{font-size:12px;}
.gv-panel{min-height:170px;}
.gv-row{margin-bottom:18px;}
/* inputs + selects */
.gvDialog input[name="title"]{font-size:var(--font-size-xxl)!important;font-weight:600!important;line-height:1.25!important;color:var(--gv-ink)!important;border:none!important;border-bottom:1px solid var(--gv-line)!important;border-radius:0!important;padding:4px 2px 9px!important;background:transparent!important;box-shadow:none!important;height:auto!important;width:100%!important;}
.gvDialog input[name="title"]:focus{border-bottom-color:var(--gv-acc)!important;outline:none!important;box-shadow:none!important;}
.gvDialog input[name="title"]::placeholder{color:var(--gv-ink2);font-weight:500;}
.gvDialog input[type="number"]:not(.gv-mb-input),.gvDialog input[type="text"]:not([name="title"]),.gvDialog select[name="metricType"],.gvDialog input.startDate,.gvDialog input.endDate{border:1px solid var(--gv-line)!important;border-radius:var(--input-radius, 9px)!important;padding:9px 11px!important;font-size:var(--base-font-size)!important;color:var(--gv-ink)!important;background:var(--input-background, #fff)!important;box-shadow:none!important;height:auto!important;width:100%!important;}
.gvDialog input:focus:not([name="title"]):not(.gv-mb-input),.gvDialog select:focus{border-color:var(--gv-acc)!important;outline:none!important;box-shadow:0 0 0 3px rgba(0,100,122,.09)!important;}
/* Progress readout — deliberately QUIET (review 2026-08-03: the big
teal number + gradient card pulled the eye away from the actual
task). Ink-colored number, thin flat bar, no card, no gradient —
the page's color budget belongs to the action (Save / the input). */
.gv-metric-bar{padding:2px 2px 0;margin:4px 0 0;}
.gv-mb-metric{font-size:var(--font-size-s);color:var(--gv-ink2);margin-bottom:7px;}
.gv-metric-bar .gv-mb-top{display:flex;align-items:baseline;gap:6px;margin-bottom:8px;}
.gv-mb-togo{margin-left:auto;font-size:var(--font-size-s);color:var(--gv-ink2);}
.gv-mb-barrow{display:grid;grid-template-columns:minmax(0,1fr) 38px;gap:12px;align-items:start;}
.gv-mb-pct{font-size:var(--font-size-s);color:var(--gv-ink2);text-align:right;font-variant-numeric:tabular-nums;align-self:start;line-height:12px;}
/* Scored track — the RA planbar recipe: striped remaining segment,
quarter score marks, and a position marker with a halo. */
.gv-track--scored{position:relative;height:12px;border-radius:6px;overflow:visible;
background:repeating-linear-gradient(135deg, var(--gv-line-soft) 0 5px, color-mix(in srgb, var(--main-border-color) 30%, transparent) 5px 10px);}
.gv-track--scored .gv-fill{position:absolute;inset:0 auto 0 0;border-radius:6px 0 0 6px;opacity:1;}
.gv-tick{position:absolute;top:2px;bottom:2px;width:1px;background:color-mix(in srgb, var(--gv-ink) 22%, transparent);}
.gv-marker{position:absolute;top:-3px;bottom:-3px;width:2px;border-radius:2px;background:var(--gv-ink);box-shadow:0 0 0 1.5px var(--primary-background, #fff);transform:translateX(-1px);}
.gv-scale{display:flex;justify-content:space-between;margin-top:5px;font-size:var(--font-size-xs);color:var(--gv-ink2);font-variant-numeric:tabular-nums;}
/* The input hugs its digits (no fill-in-the-blank dashes past the
number) in browsers with field-sizing; others keep the fixed ch. */
@supports (field-sizing: content){
.gvDialog .gv-metric-bar input.gv-mb-input{field-sizing:content;width:auto!important;min-width:2.5ch!important;max-width:10ch!important;}
}
.gv-metric-bar .gv-mb-now{font-size:var(--font-size-xl);font-weight:700;letter-spacing:-.2px;color:var(--gv-ink);line-height:1;}
.gv-metric-bar .gv-mb-of{font-size:var(--font-size-s);color:var(--gv-ink2);}
.gv-metric-bar .gv-mb-of b{color:var(--gv-ink);font-weight:600;}
.gv-track{height:6px;background:var(--gv-line-soft);border-radius:4px;overflow:hidden;}
.gv-fill{height:100%;border-radius:4px;background:var(--gv-acc);opacity:.8;}
.gv-values{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;max-width:480px;}
/* Inline value edit — the RA pattern: the number IS the input.
(Selector carries the element+class so the dialog's generic
input[type=number] rule — same importance — can never outrank it.) */
.gvDialog .gv-metric-bar input.gv-mb-input{font-size:var(--font-size-xl)!important;font-weight:700!important;color:var(--gv-ink)!important;border:none!important;border-bottom:1px dashed var(--gv-line)!important;border-radius:0!important;background:transparent!important;padding:0 2px 2px!important;width:5.5ch!important;height:auto!important;box-shadow:none!important;-moz-appearance:textfield;}
.gv-mb-input::-webkit-outer-spin-button,.gv-mb-input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
.gvDialog .gv-metric-bar input.gv-mb-input:hover{border-bottom-color:var(--gv-ink2)!important;}
.gvDialog .gv-metric-bar input.gv-mb-input:focus{border-bottom:1px solid var(--gv-acc)!important;outline:none!important;box-shadow:none!important;}
/* Milestone bars on the Progress tab — read-only rows, quiet. */
.gv-ms-bars{margin-top:22px;padding-top:16px;border-top:1px solid var(--gv-line-soft);display:flex;flex-direction:column;gap:11px;}
.gv-msb-row{display:grid;grid-template-columns:9px minmax(0,1fr) 130px 38px;align-items:center;gap:12px;}
.gv-msb-dot{width:9px;height:9px;border-radius:50%;}
.gv-msb-name{font-size:var(--font-size-s);color:var(--gv-ink2);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.gv-msb-name:hover{color:var(--gv-acc);text-decoration:underline;text-underline-offset:3px;}
.gv-msb-track{height:5px;background:var(--gv-line-soft);border-radius:4px;overflow:hidden;}
.gv-msb-fill{height:100%;border-radius:4px;opacity:.75;}
.gv-msb-pct{font-size:var(--font-size-s);color:var(--gv-ink2);text-align:right;font-variant-numeric:tabular-nums;}
/* Milestones tab — MANAGEMENT list (RA line-item style). */
.gv-ms-list{display:flex;flex-direction:column;}
.gv-ms-item{display:flex;align-items:center;gap:10px;padding:9px 2px;border-bottom:1px solid var(--gv-line-soft);}
.gv-ms-name{font-size:var(--base-font-size);color:var(--gv-ink);text-decoration:none;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.gv-ms-name:hover{color:var(--gv-acc);text-decoration:underline;text-underline-offset:3px;}
.gv-ms-due{font-size:var(--font-size-s);color:var(--gv-ink2);flex:none;}
.gv-ms-remove{flex:none;background:transparent;border:none;cursor:pointer;padding:6px 8px;opacity:.55;color:var(--gv-ink2);}
.gv-ms-remove:hover{opacity:1;color:var(--gv-acc);}
.gv-values .gv-field-lbl{font-size:11px;}
.gv-dates{display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:360px;}
/* milestones panel */
.gv-ms-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;}
.gv-ms-summary{font-size:var(--font-size-s);color:var(--gv-ink2);}
.gv-ms-summary b{color:var(--gv-ink);font-weight:700;}
.gv-ms-actions{margin-left:auto;display:flex;align-items:center;gap:14px;}
.gv-ms-act{background:none!important;border:none!important;cursor:pointer;color:var(--gv-ink2)!important;font-size:15px;padding:0;line-height:1;opacity:.65;transition:opacity .12s,color .12s;}
.gv-ms-act:hover{opacity:1;color:var(--gv-acc)!important;}
.gv-ms-actions .helperTooltip{color:var(--gv-ink2)!important;opacity:.55;}
/* discussion sub-heading inside the Goal tab */
/* actions (always visible under the tabs) */
.gv-actions{display:flex;align-items:center;gap:10px;margin-top:22px;padding-top:16px;border-top:1px solid var(--gv-line);}
.gv-actions .gv-delete{margin-left:auto;}
@media (max-width:560px){.gv-values{grid-template-columns:1fr 1fr;}}
</style>
<div class="gvDialog">
{{-- Section headers use the SYSTEM modal recipe (h4.widgettitle
.title-light same as Subtasks/Discussion/Schedule on the task
modal), not a dialog-private eyebrow style. --}}
<h4 class="widgettitle title-light"><i class="fas {{ $canvasTypes[$canvasItem['box']]['icon'] }}"></i> {{ $canvasTypes[$canvasItem['box']]['title'] }}</h4>
<form class="formModal" method="post" action="{{ BASE_URL . "/goalcanvas/editCanvasItem/$id" }}">
<input type="hidden" value="{{ $currentCanvas }}" name="canvasId">
<input type="hidden" value="{{ $canvasItem['box'] }}" name="box" id="box">
<input type="hidden" value="{{ $id }}" name="itemId" id="itemId">
<input type="hidden" name="changeItem" value="1">
<div class="gv-cols">
<div class="gv-main">
{{-- ── Tabs on top the nav frames the whole workspace (review
2026-08-03). The old Goal tab dissolved: name lives below the
tabs (shared by both), status/dates/relates in the Details
rail. New goals have only Progress, so no bar at all. ── --}}
@if ($id !== '')
<div class="gv-tabs lt-tabs lt-tabs--floating lt-tabs--onlight" role="tablist" aria-label="{{ __('goalcanvas.tabs_label') }}">
<div class="gv-tab-group lt-tabs-group">
<button type="button" class="gv-tab lt-tab" role="tab" id="gvTab-edit" aria-controls="gvPanel-edit" aria-selected="false" data-tab="edit"><i class="fa-solid fa-pen" aria-hidden="true"></i> {{ __('links.edit') }}</button>
<button type="button" class="gv-tab lt-tab" role="tab" id="gvTab-progress" aria-controls="gvPanel-progress" aria-selected="false" data-tab="progress"><i class="fa-solid fa-ranking-star" aria-hidden="true"></i> {{ __('goalcanvas.tab_progress') }}</button>
<button type="button" class="gv-tab lt-tab" role="tab" id="gvTab-milestones" aria-controls="gvPanel-milestones" aria-selected="false" data-tab="milestones"><span class="fa fa-flag-checkered" aria-hidden="true"></span> {{ __("headlines.milestones") }}</button>
</div>
</div>
@endif
{{-- Name one label only (the placeholder); the modal's GOAL
header already names the object, so no third repetition. --}}
<div class="gv-row">
<x-global::forms.text-input name="title" id="goalTitleInput" value="{{ $canvasItem['title'] }}" placeholder="{{ __('goalcanvas.name_goal') }}" aria-label="{{ __('goalcanvas.name_goal') }}" style="width:100%" />
</div>
{{-- ── Tab: Edit — the goal's DEFINITION (metric, type, start,
target). One-time setup, separated from the recurring
monitoring job (review 2026-08-03). --}}
<div class="gv-panel" data-panel="edit" role="tabpanel" id="gvPanel-edit" aria-labelledby="gvTab-edit" tabindex="0">
<div id="measureGoalContainer" class="gv-row">
<label class="gv-field-lbl" for="goalDescriptionInput">{{ __('goalcanvas.metric_label') }}</label>
<x-global::forms.text-input name="description" id="goalDescriptionInput" value="{{ $canvasItem['description'] }}" style="width:100%" />
</div>
<div class="gv-values">
<div>
<label class="gv-field-lbl" for="goalMetricType">{{ __('label.type') }}</label>
<select name="metricType" id="goalMetricType">
<option value="number" @if ($mType == 'number') selected @endif>{{ __('goalcanvas.type_number') }}</option>
<option value="percent" @if ($mType == 'percent') selected @endif>{{ __('goalcanvas.type_percent') }}</option>
<option value="currency" @if ($mType == 'currency') selected @endif>{{ __('goalcanvas.type_currency') }}</option>
</select>
</div>
<div>
<label class="gv-field-lbl" for="goalStartValue">{{ __('goalcanvas.v_start') }} <span class="gv-unit"></span></label>
<x-global::forms.text-input type="number" step="0.01" name="startValue" id="goalStartValue" value="{{ $canvasItem['startValue'] }}" style="width:100%" />
</div>
<div>
<label class="gv-field-lbl" for="goalEndValue">{{ __('goalcanvas.v_goal') }} <span class="gv-unit"></span></label>
<x-global::forms.text-input type="number" step="0.01" name="endValue" id="goalEndValue" value="{{ $canvasItem['endValue'] }}" style="width:100%" />
</div>
</div>
</div>
{{-- ── Tab: Progress MONITORING. The RA pattern: the readout's
number IS the input (click in, type, blur = saved via HTMX).
All the bars live here — the goal metric plus each linked
milestone's own read-only bar. The Milestones tab MANAGES the
links; this tab watches them. --}}
<div class="gv-panel" data-panel="progress" role="tabpanel" id="gvPanel-progress" aria-labelledby="gvTab-progress" tabindex="0">
@include('goalcanvas::partials.progressReadout')
{{-- Milestone bars read-only context, one quiet row per
linked milestone. Goal progress stays metric-defined
(Marcel): these never aggregate into the bar above. --}}
@if (count($goalMilestones ?? []) > 0)
<div class="gv-ms-bars">
@foreach ($goalMilestones as $ms)
<div class="gv-msb-row">
{{-- Status dot the monitoring signal, moved here
from the management list; it also carries the
status color when the bar sits at 0%. --}}
<span class="gv-msb-dot" style="background:{{ $ms['color'] }};" aria-hidden="true"></span>
<a class="gv-msb-name" href="#/tickets/editMilestone/{{ (int) $ms['id'] }}" title="{{ __('links.edit_milestone') }}: {{ $ms['headline'] }}">{{ $ms['headline'] }}</a>
<div class="gv-msb-track"><div class="gv-msb-fill" style="width:{{ (int) $ms['percentDone'] }}%;background:{{ $ms['color'] }};"></div></div>
<span class="gv-msb-pct">{{ (int) $ms['percentDone'] }}%</span>
</div>
@endforeach
</div>
@endif
</div>
{{-- ── Tab: Milestones ── --}}
@if ($id !== '')
<div class="gv-panel" data-panel="milestones" role="tabpanel" id="gvPanel-milestones" aria-labelledby="gvTab-milestones" tabindex="0">
{{-- Summary + chips live in a partial so the chip-remove
hx-post re-renders the whole section (counts + arrow
stay correct deleting only the chip left them stale). --}}
@include('goalcanvas::partials.milestonesSection')
@if ($login::userIsAtLeast($roles::$editor))
<div class="row" id="newMilestone" style="display:none;">
<div class="col-md-12">
<x-global::forms.text-input width="50%" name="newMilestone" /><br />
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="goalcanvasitemid" value="{{ $id }}" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.goalCanvasController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
</div>
</div>
<div class="row" id="existingMilestone" style="display:none;">
<div class="col-md-12">
<select data-placeholder="{{ __("input.placeholders.filter_by_milestone") }}" name="existingMilestone" class="user-select">
<option value=""></option>
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}">{{ $milestoneRow->headline }}</option>
@endforeach
</select>
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="goalcanvasitemid" value="{{ $id }}" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.goalCanvasController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
</div>
</div>
@endif
</div>
@endif
{{-- ── Actions (main column; Delete lives in the Details rail) ── --}}
@if ($login::userIsAtLeast($roles::$editor))
<div class="gv-actions">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="primaryCanvasSubmitButton" />
<x-global::forms.button inputType="submit" contentRole="secondary" id="saveAndClose" value="closeModal"
onclick="leantime.goalCanvasController.setCloseModal();">{{ __('buttons.save_and_close') }}</x-global::forms.button>
</div>
@endif
</div>{{-- /gv-main --}}
{{-- ── Details rail the essentials (Docs-article pattern). ── --}}
<aside class="gv-side">
<h4 class="widgettitle title-light gv-side-head"><i class="fa fa-circle-info" aria-hidden="true"></i> {{ __('goalcanvas.side_details') }}</h4>
<div>
<label for="statusCanvas">{{ __('label.status') }}</label>
@if (!empty($statusLabels))
<select name="status" id="statusCanvas"></select>
@else
<input type="hidden" name="status" value="{{ $canvasItem['status'] ?? array_key_first($hiddenStatusLabels) }}" />
@endif
</div>
{{-- One label per field ("Due Dates" + "Start Date" + "End
Date" was triple-labeling part of the clutter). --}}
<div class="gv-dates">
<div>
<label for="goalStartDate">{{ __('label.start_date') }}</label>
<input type="text" autocomplete="off" id="goalStartDate" value="{{ format($canvasItem['startDate'])->date() }}" name="startDate" class="startDate"/>
</div>
<div>
<label for="goalEndDate">{{ __('label.end_date') }}</label>
<input type="text" autocomplete="off" id="goalEndDate" value="{{ format($canvasItem['endDate'])->date() }}" name="endDate" class="endDate"/>
</div>
</div>
<div>
@dispatchEvent('beforeMeasureGoalContainer', $canvasItem)
@if (!empty($relatesLabels))
<label class="gv-field-lbl" for="relatesCanvas">{{ __('label.relates') }}</label><select name="relates" id="relatesCanvas"></select>
@else
<input type="hidden" name="relates" value="{{ $canvasItem['relates'] ?? array_key_first($hiddenRelatesLabels) }}">
@endif
</div>
@if ($login::userIsAtLeast($roles::$editor) && $id != '')
<div class="gv-delete-slot">
<x-global::forms.button tag="a" link="{{ BASE_URL }}/goalcanvas/delCanvasItem/{{ $id }}" class="formModal delete gv-delete" state="danger" variant="outline">
<i class='fa fa-trash-can'></i> {{ __('links.delete') }}
</x-global::forms.button>
</div>
@endif
</aside>
</div>{{-- /gv-cols --}}
</form>
{{-- Discussion the comments submodule brings its OWN <form>; it must
stay a SIBLING of the goal form: nested, the HTML parser drops the
inner form tag and its close tag closes the OUTER form, orphaning
every field and button after it (Save did nothing). --}}
@if ($id !== '')
<div class="gv-discussion">
<hr />
<h4 class="widgettitle title-light"><span class="fa-solid fa-comments" aria-hidden="true"></span> {{ __('subtitles.discussion') }}</h4>
@include('comments::submodules.generalComment', ['formUrl' => '/goalcanvas/editCanvasItem/' . $id])
</div>
@endif
</div>
<script type="text/javascript">
jQuery(document).ready(function() {
leantime.dateController.initDateRangePicker(".startDate", ".endDate");
// Live unit cue on Start/Now/Goal so it's clear they're numbers.
(function () {
var typeSel = document.querySelector('.gvDialog select[name="metricType"]');
if (typeSel) {
var units = { number: '#', percent: '%', currency: '$' };
var apply = function () {
var u = units[typeSel.value] || '#';
document.querySelectorAll('.gvDialog .gv-unit').forEach(function (s) { s.textContent = '(' + u + ')'; });
};
apply();
typeSel.addEventListener('change', apply);
}
})();
// Tabs — one zone at a time; remembers the last-used tab.
(function () {
var tabs = document.querySelectorAll('.gvDialog .gv-tab');
var panels = document.querySelectorAll('.gvDialog .gv-panel');
if (!tabs.length) return;
function show(name) {
var found = false;
panels.forEach(function (p) { var m = p.getAttribute('data-panel') === name; p.hidden = !m; p.style.display = m ? '' : 'none'; if (m) found = true; });
tabs.forEach(function (t) {
var active = t.getAttribute('data-tab') === name;
t.classList.toggle('is-active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false');
t.tabIndex = active ? 0 : -1;
});
if (found) { try { localStorage.setItem('gvActiveTab', name); } catch (e) {} }
return found;
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { show(t.getAttribute('data-tab')); });
// Roving-tabindex arrow-key navigation (WAI-ARIA tabs pattern).
t.addEventListener('keydown', function (e) {
var next = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { next = tabs[(i + 1) % tabs.length]; }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { next = tabs[(i - 1 + tabs.length) % tabs.length]; }
else if (e.key === 'Home') { next = tabs[0]; }
else if (e.key === 'End') { next = tabs[tabs.length - 1]; }
if (next) { e.preventDefault(); show(next.getAttribute('data-tab')); next.focus(); }
});
});
var saved = null; try { saved = localStorage.getItem('gvActiveTab'); } catch (e) {}
// Default = Progress: updating the value is the recurring job
// this dialog exists for (stale saved names fall through too).
if (!saved || !show(saved)) { if (!show('progress')) { show(tabs[0].getAttribute('data-tab')); } }
})();
@if (!empty($statusLabels))
new SlimSelect({
select: '#statusCanvas',
showSearch: false,
valuesUseText: false,
data: [
@foreach ($statusLabels as $key => $data)
@if ($data['active'])
{
innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i>&nbsp;{{ $data['title'] }}',
text: "{{ $data['title'] }}",
value: "{{ $key }}",
selected: {{ $canvasItem['status'] == $key ? 'true' : 'false' }}
},
@endif
@endforeach
]
});
@endif
@if (!empty($relatesLabels))
new SlimSelect({
select: '#relatesCanvas',
showSearch: false,
valuesUseText: false,
data: [
@foreach ($relatesLabels as $key => $data)
@if ($data['active'])
{
innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i>&nbsp;{{ $data['title'] }}',
text: "{{ $data['title'] }}",
value: "{{ $key }}",
selected: {{ $canvasItem['relates'] == $key ? 'true' : 'false' }}
},
@endif
@endforeach
]
});
@endif
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
@if (!$login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if ($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
});
</script>
@endsection

View File

@@ -0,0 +1,355 @@
@extends($layout)
@section('content')
@php
use Leantime\Domain\Comments\Repositories\Comments;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
$elementName = 'goal';
/**
* showCanvasTop.inc template - Top part of the main canvas page
*
* Required variables:
* - goal Name of current canvas
*/
$canvasTitle = '';
//get canvas title
foreach ($allCanvas as $canvasRow) {
if ($canvasRow["id"] == $currentCanvas) {
$canvasTitle = $canvasRow["title"];
$canvasId = $canvasRow["id"];
break;
}
}
@endphp
<style>
.canvas-row { margin-left: 0px; margin-right: 0px;}
.canvas-title-only { border-radius: var(--box-radius-small); }
h4.canvas-element-title-empty { background: white !important; border-color: white !important; }
div.canvas-element-center-middle { text-align: center; }
</style>
<div class="pageheader">
<div class="pageicon"><span class='fa {{ $canvasIcon }}'></span></div>
<div class="pagetitle">
@if (count($allCanvas) > 0)
<x-global::subjectSwitcher
:parent="__('headline.goal.dashboardboard')"
current="All Goal Groups">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/goalcanvas/bigRock">{!! __("links.icon.create_new_board") !!}</a></li>
@endif
<li class="border"></li>
@foreach ($allCanvas as $canvasRow)
<li><a href="{{ BASE_URL }}/goalcanvas/showCanvas/{{ $canvasRow['id'] }}">{{ $canvasRow['title'] }}</a></li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{{ __("headline.goal.dashboardboard") }}</h1>
@endif
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="row" style="margin-bottom:20px; ">
<div class="col-md-4">
<div class="bigNumberBox" style="padding: 29px 15px;">
<h2>Progress: {{ round($goalStats['avgPercentComplete']) }}%</h2>
<div class="progress" style="margin-top:5px;">
<div class="progress-bar progress-bar-success" role="progressbar"
aria-valuenow="{{ round($goalStats['avgPercentComplete']) }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ $goalStats['avgPercentComplete'] }}%">
<span class="sr-only">{{ sprintf(__("text.percent_complete"), round($goalStats['avgPercentComplete'])) }}</span>
</div>
</div>
</div>
</div>
<div class="col-md-2"></div>
<div class="col-md-2">
<div class="bigNumberBox priority-border-4">
<h2>On Track</h2>
<span class="content">{{ $goalStats['goalsOnTrack'] }}</span>
</div>
</div>
<div class="col-md-2">
<div class="bigNumberBox priority-border-3">
<h2>At Risk</h2>
<span class="content">{{ $goalStats['goalsAtRisk'] }}</span>
</div>
</div>
<div class="col-md-2">
<div class="bigNumberBox priority-border-1">
<h2>Miss</h2>
<span class="content">{{ $goalStats['goalsMiss'] }}</span>
</div>
</div>
</div>
<div class="maincontentinner">
<div class="row">
<div class="col-md-6"></div>
</div>
@if (count($allCanvas) > 0)
@foreach ($allCanvas as $canvasRow)
<div class="row">
<div class="col-md-12">
<a href='#/goalcanvas/editCanvasItem?type=goal&canvasId={{ $canvasRow["id"] }}' class='btn btn-primary pull-right'><i class="fa fa-plus"></i> Create New Goal</a>
<h5 class='subtitle'><a href='{{ BASE_URL }}/goalcanvas/showCanvas/{{ $canvasRow["id"] }}'>{{ $canvasRow["title"] }}</a></h5>
</div>
</div>
<div class="row" style="border-bottom:1px solid var(--main-border-color); margin-bottom:20px">
@php
$canvasSvc = app()->make(Goalcanvas::class);
$canvasItems = $canvasSvc->getCanvasItemsById($canvasRow["id"]);
@endphp
<div id="sortableCanvasKanban-{{ $canvasRow['id'] }}" class="sortableTicketList disabled col-md-12" style="padding-top:15px;">
<div class="row">
<div class="col-md-12">
<div class="row">
@if (!is_countable($canvasItems) || count($canvasItems) == 0)
<div class='col-md-12'>No goals on this board yet. Open the <a href='{{ BASE_URL }}/goalcanvas/showCanvas/{{ $canvasRow["id"] }}'>board</a> to start adding goals</div>
@endif
@foreach ($canvasItems as $row)
@php
$filterStatus = $filter['status'] ?? 'all';
$filterRelates = $filter['relates'] ?? 'all';
@endphp
@if ($row['box'] === $elementName && ($filterStatus == 'all' || $filterStatus == $row['status']) && ($filterRelates == 'all' || $filterRelates == $row['relates']))
@php
$comments = app()->make(Comments::class);
$nbcomments = $comments->countComments(moduleId: $row['id']);
@endphp
<div class="col-md-4">
<div class="ticketBox" id="item_{{ $row["id"] }}">
<div class="row">
<div class="col-md-12">
<div class="inlineDropDownContainer" style="float:right;">
@if ($login::userIsAtLeast($roles::$editor))
<a href="javascript:void(0)" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
@endif
@if ($login::userIsAtLeast($roles::$editor))
&nbsp;&nbsp;&nbsp;
<ul class="dropdown-menu">
<li class="nav-header">{{ __("subtitles.edit") }}</li>
<li><a href="#/goalcanvas/editCanvasItem/{{ $row["id"] }}" class="goalCanvasModal" data="item_{{ $row["id"] }}">{!! __("links.edit_canvas_item") !!}</a></li>
<li><a href="#/goalcanvas/delCanvasItem/{{ $row["id"] }}" class="delete goalCanvasModal" data="item_{{ $row["id"] }}">{!! __("links.delete_canvas_item") !!}</a></li>
</ul>
@endif
</div>
<h4>
<strong>Goal:</strong>
<a href="#/goalcanvas/editCanvasItem/{{ $row['id'] }}" class="goalCanvasModal" data="item_{{ $row['id'] }}">
{{ $row["title"] }}
</a>
</h4>
<br />
<strong>Metric:</strong> {{$row["description"] }}
<br /><br />
@php
$percentDone = $row["goalProgress"];
$metricTypeFront = '';
$metricTypeBack = '';
if ($row["metricType"] == "percent") {
$metricTypeBack = '%';
} elseif ($row["metricType"] == "currency") {
$metricTypeFront = __("language.currency");
}
@endphp
<div class="row">
<div class="col-md-4"></div>
<div class="col-md4 center">
<small>{{ sprintf(__("text.percent_complete"), $percentDone) }}</small>
</div>
<div class="col-md-4"></div>
</div>
<div class="progress" style="margin-bottom:0px;">
<div class="progress-bar progress-bar-success" role="progressbar" aria-valuenow="{{ $percentDone }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ $percentDone }}%">
<span class="sr-only">{{ sprintf(__("text.percent_complete"), $percentDone) }}</span>
</div>
</div>
<div class="row" style="padding-bottom:0px;">
<div class="col-md-4">
<small>Start:<br />{{ $metricTypeFront . $row["startValue"] . $metricTypeBack }}</small>
</div>
<div class="col-md-4 center">
<small>{{ __('label.current') }}:<br />{{ $metricTypeFront . $row["currentValue"] . $metricTypeBack }}</small>
</div>
<div class="col-md-4" style="text-align:right">
<small>{{ __('label.goal') }}:<br />{{ $metricTypeFront . $row["endValue"] . $metricTypeBack }}</small>
</div>
</div>
<div class="clearfix" style="padding-bottom: 8px;"></div>
@if (!empty($statusLabels))
<div class="dropdown ticketDropdown statusDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left status label-{{ $row['status'] != "" ? $statusLabels[$row['status']]['dropdown'] : "" }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $row['status'] != "" ? $statusLabels[$row['status']]['title'] : "" }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.choose_status") }}</li>
@foreach ($statusLabels as $key => $data)
@if ($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}" data-label='{{ $data["title"] }}' data-value="{{ $row['id'] . "/" . $key }}" id="ticketStatusChange{{ $row['id'] . $key }}">{!! $data['title'] !!}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if (!empty($relatesLabels))
<div class="dropdown ticketDropdown relatesDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left relates label-{{ $relatesLabels[$row['relates']]['dropdown'] }}" href="javascript:void(0);" role="button" id="relatesDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $relatesLabels[$row['relates']]['title'] }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="relatesDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.choose_relates") }}</li>
@foreach ($relatesLabels as $key => $data)
@if ($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}" data-label='{{ $data["title"] }}' data-value="{{ $row['id'] . "/" . $key }}" id="ticketRelatesChange{{ $row['id'] . $key }}">{!! $data['title'] !!}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
<div class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
<a class="dropdown-toggle f-left" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if ($row["authorFirstname"] != "")
<span id='userImage{{ $row['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage={{ $row['author'] }}' width='25' style='vertical-align: middle;'/>
</span>
<span id='user{{ $row['id'] }}'></span>
@else
<span id='userImage{{ $row['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage=false' width='25' style='vertical-align: middle;'/>
</span>
<span id='user{{ $row['id'] }}'></span>
@endif
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.choose_user") }}</li>
@foreach ($users as $user)
<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='{{ sprintf(__("text.full_name"), $tpl->escape($user["firstname"]), $tpl->escape($user['lastname'])) }}' data-value='{{ $row['id'] . "_" . $user['id'] . "_" . $user['profileId'] }}' id='userStatusChange{{ $row['id'] . $user['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage={{ $user['id'] }}' width='25' style='vertical-align: middle; margin-right:5px;'/>
{{ sprintf(__("text.full_name"), $tpl->escape($user["firstname"]), $tpl->escape($user['lastname'])) }}
</a>
</li>
@endforeach
</ul>
</div>
<div class="right" style="margin-right:10px;">
<span class="fas fa-comments"></span>
<small>{{ $nbcomments }}</small>
</div>
</div>
</div>
@include('goalcanvas::partials.milestoneChips', ['milestones' => $row['milestones'] ?? []])
</div>
</div>
@endif
@endforeach
</div>
<br />
</div>
</div>
</div>
</div>
@endforeach
@endif
</div>
{{--
* showCanvasBottom.blade.php template - Bottom part of the main canvas page
*
* Required variables:
* - goal Name of current canvas
--}}
@if (count($allCanvas) > 0)
{{-- --}}
@else
<br><br>
<div class='center'>
<div class='svgContainer'>
{!! file_get_contents(ROOT . "/dist/images/svg/undraw_design_data_khdb.svg") !!}
</div>
<h3>{{ __("headlines.goal.analysis") }}</h3>
<br>{!! __("text.goal.helper_content") !!}
@if ($login::userIsAtLeast($roles::$editor))
<br><br>
<a href='javascript:void(0)' class='addCanvasLink btn btn-primary'>
{{ __("links.icon.create_new_board") }}
</a>
@endif
</div>
@endif
@if (!empty($disclaimer) && count($allCanvas) > 0)
<small class="align-center">{{ $disclaimer }}</small>
@endif
{!! $tpl->viewFactory->make($tpl->getTemplatePath('canvas', 'modals'), $__data)->render() !!}
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function() {
if(jQuery('#searchCanvas').length > 0) {
new SlimSelect({ select: '#searchCanvas' });
}
leantime.goalCanvasController.setRowHeights();
leantime.canvasController.setCanvasName('goal');
leantime.canvasController.initFilterBar();
@if ($login::userIsAtLeast($roles::$editor))
leantime.canvasController.initCanvasLinks();
leantime.canvasController.initUserDropdown();
leantime.canvasController.initStatusDropdown();
leantime.canvasController.initRelatesDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
@if (isset($_GET['showModal']))
@php
$modalUrl = $_GET['showModal'] == ""
? "&type=" . array_key_first($canvasTypes)
: "/" . (int)$_GET['showModal'];
@endphp
leantime.canvasController.openModalManually("{{ BASE_URL }}/goalcanvas/editCanvasItem{{ $modalUrl }}");
window.history.pushState({}, document.title, '{{ BASE_URL }}/goalcanvas/showCanvas/');
@endif
});
</script>
@endsection

View File

@@ -0,0 +1,15 @@
@extends($layout)
@section('content')
<h4 class="widgettitle title-light">{!!__("subtitles.delete") !!}</h4>
<form method="post" action="{{ BASE_URL."/goalcanvas/delCanvas/$id" }}">
@if(isset($csrf_token))
<input type="hidden" name="csrf_token" value="{{ $csrf_token }}">
@endif
<p>{{ __('text.confirm_board_deletion') }}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/goalcanvas/showCanvas">{{ __('buttons.back') }}</x-global::forms.button>
</form>
@endsection

View File

@@ -0,0 +1,12 @@
@extends($layout)
@section('content')
<h4 class="widgettitle title-light">{!! __("subtitles.delete") !!}</h4>
<hr style="margin-top: 5px; margin-bottom: 15px;">
<form method="post" action="{{ BASE_URL }}/goalcanvas/delCanvasItem/{{ $id }}">
<p>{{ __('text.confirm_board_item_deletion') }}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/goalcanvas/showCanvas">{{ __('buttons.back') }}</x-global::forms.button>
</form>
@endsection

View File

@@ -0,0 +1,32 @@
{{--
Milestone chip row for a goal (board + dashboard).
Expects: $milestones = [{ id, headline, color, percentDone, statusType }, ...]
The fill is the milestone's OWN color growing with progress — deliberately
not a status color (status lives on the goal card, never the chips).
Each chip links to its milestone; stopPropagation keeps the click from
also opening the goal card behind it.
--}}
@if (! empty($milestones))
{{-- This partial is included once per goal card; emit the shared chip CSS
only on the first include so a board of N cards doesn't ship N copies. --}}
@once
<style>
.goalMsBoardRow{scrollbar-width:thin;scrollbar-color:#9aa7ad transparent;}
.goalMsBoardRow::-webkit-scrollbar{height:8px;}
.goalMsBoardRow::-webkit-scrollbar-thumb{background:#9aa7ad;border-radius:10px;border:2px solid transparent;background-clip:padding-box;}
.goalMsBoardRow::-webkit-scrollbar-track{background:transparent;}
.goalMsBoardChip{text-decoration:none;color:inherit;cursor:pointer;transition:border-color .12s;}
.goalMsBoardChip:hover{border-color:var(--primary-color,#004666)!important;}
</style>
@endonce
<div class="goalMsBoardRow" style="display:flex;gap:6px;overflow-x:auto;padding-bottom:4px;margin-top:6px;">
@foreach ($milestones as $ms)
<a href="#/tickets/editMilestone/{{ (int) $ms['id'] }}" onclick="event.stopPropagation();" class="goalMsBoardChip" title="{{ __('links.edit_milestone') }}: {{ $ms['headline'] }}"
style="position:relative;flex:0 0 auto;min-width:120px;max-width:180px;height:32px;border-radius:8px;border:1px solid var(--main-border-color,#e4e7ec);background:var(--secondary-background,#f2f4f7);overflow:hidden;display:flex;align-items:center;padding:0 9px;">
<span style="position:absolute;left:0;top:0;bottom:0;width:{{ (int) $ms['percentDone'] }}%;background:{{ $ms['color'] }};opacity:.18;border-right:2px solid {{ $ms['color'] }};"></span>
<span style="position:relative;z-index:1;flex:1;min-width:0;font-size:11.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ $ms['headline'] }}</span>
<span style="position:relative;z-index:1;flex:none;font-size:10px;font-weight:600;opacity:.65;margin-left:5px;">{{ (int) $ms['percentDone'] }}%</span>
</a>
@endforeach
</div>
@endif

View File

@@ -0,0 +1,62 @@
{{--
Goal editor linked-milestones MANAGEMENT list (summary + one row per
milestone). Progress bars deliberately live on the Progress tab, not here
(review 2026-08-03: chips carrying percent duplicated the Progress view)
this list is for linking/unlinking, RA line-item style.
Lives in its own partial so the row-remove hx-post can re-render the WHOLE
section (hx-target="#goalMsSection" outerHTML): the summary count stays
correct with the removal.
Expects: $id, $goalMilestones, $milestoneSummary, $milestones (project
milestone options drives the "link existing" button visibility).
--}}
<div id="goalMsSection">
<div class="gv-ms-head">
@if (($milestoneSummary['total'] ?? 0) > 0)
<span class="gv-ms-summary"><b>{{ $milestoneSummary['total'] }}</b> {{ $milestoneSummary['total'] == 1 ? __("goalcanvas.summary_milestone_one") : __("goalcanvas.summary_milestones") }}
@if ($milestoneSummary['inProgress'] > 0)&middot; {{ $milestoneSummary['inProgress'] }} {{ __("goalcanvas.summary_in_progress") }} @endif
@if ($milestoneSummary['notStarted'] > 0)&middot; {{ $milestoneSummary['notStarted'] }} {{ __("goalcanvas.summary_not_started") }} @endif
@if ($milestoneSummary['done'] > 0)&middot; {{ $milestoneSummary['done'] }} {{ __("goalcanvas.summary_done") }} @endif
</span>
@endif
<span class="gv-ms-actions">
@if ($login::userIsAtLeast($roles::$editor))
<button type="button" class="gv-ms-act helperTooltip" onclick="leantime.goalCanvasController.toggleMilestoneSelectors('new');" data-tippy-content="{{ __('goalcanvas.ms_new') }}" title="{{ __('goalcanvas.ms_new') }}" aria-label="{{ __('goalcanvas.ms_new') }}"><i class="fa fa-plus" aria-hidden="true"></i></button>
@if (count($milestones) > 0)
<button type="button" class="gv-ms-act helperTooltip" onclick="leantime.goalCanvasController.toggleMilestoneSelectors('existing');" data-tippy-content="{{ __('goalcanvas.ms_link') }}" title="{{ __('goalcanvas.ms_link') }}" aria-label="{{ __('goalcanvas.ms_link') }}"><i class="fa fa-link" aria-hidden="true"></i></button>
@endif
@endif
<i class="fa fa-question-circle-o helperTooltip" aria-hidden="true" data-tippy-content="{{ __("tooltip.link_milestones_tooltip") }}"></i>
</span>
</div>
@if (count($goalMilestones) > 0)
<div class="gv-ms-list">
@foreach ($goalMilestones as $ms)
@php
$msDue = trim((string) ($ms['editTo'] ?? ''));
$msDue = ($msDue === '' || str_starts_with($msDue, '0000-00-00')) ? null : $msDue;
@endphp
{{-- Management row only status signals (dots/bars) live on
the Progress tab (review 2026-08-04). --}}
<div class="gv-ms-item">
<a class="gv-ms-name" href="#/tickets/editMilestone/{{ (int) $ms['id'] }}" title="{{ __('links.edit_milestone') }}: {{ $ms['headline'] }}">{{ $ms['headline'] }}</a>
@if ($msDue !== null)
<span class="gv-ms-due">{{ __('label.due') }} {{ format($msDue)->date() }}</span>
@endif
@if ($login::userIsAtLeast($roles::$editor))
<button type="button"
hx-post="{{ BASE_URL }}/goalcanvas/editCanvasItem/{{ $id }}"
hx-vals='{"removeMilestone": {{ (int) $ms['id'] }}}'
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#goalMsSection"
hx-swap="outerHTML"
class="delete gv-ms-remove"
aria-label="{{ __("links.remove") }}: {{ $ms['headline'] }}" title="{{ __("links.remove") }}"><i class="fa fa-close" aria-hidden="true"></i></button>
@endif
</div>
@endforeach
</div>
@endif
</div>

View File

@@ -0,0 +1,103 @@
{{--
Goal metric readout RA planbar language: filled progress + striped
remaining, quarter score marks, a position marker at the current value,
start/goal anchored under the bar's ends, and an explicit "N to go".
The number IS the input (RA inline pattern): blur-when-changed hx-posts
to goalProgress/updateValue, which re-renders this partial into #gvReadout.
Expects: $canvasItem (id, metricType, startValue, currentValue, endValue,
setting, description). Shared by the dialog and the HxController.
--}}
@php
$roType = $canvasItem['metricType'] ?: 'number';
$roStart = (float) ($canvasItem['startValue'] ?? 0);
$roCur = (float) ($canvasItem['currentValue'] ?? 0);
$roGoal = (float) ($canvasItem['endValue'] ?? 0);
$roHasRange = ($roGoal - $roStart) != 0;
$roPct = $roHasRange ? max(0, min(100, (($roCur - $roStart) / ($roGoal - $roStart)) * 100)) : 0;
$roFmt = function ($v) use ($roType) {
$v = (float) $v;
if ($roType === 'percent') {
return rtrim(rtrim(number_format($v, 2), '0'), '.').'%';
}
if ($roType === 'currency') {
return '$'.number_format($v, $v == floor($v) ? 0 : 2);
}
return rtrim(rtrim(number_format($v, 2), '0'), '.');
};
$roReached = $roHasRange && $roPct >= 100;
// Works for decreasing goals too (goal < start): distance left to travel.
$roRemaining = abs($roGoal - $roCur);
// linkAndReport current values are computed from children; viewers can't
// write — both render the number as static text instead of an input.
$roEditable = $login::userIsAtLeast($roles::$editor) && ($canvasItem['setting'] ?? '') !== 'linkAndReport';
// Inline blur-save needs a PERSISTED goal: on the create form (id empty)
// an hx-post with itemId=0 would swap the readout back to zeros and wipe
// the value the user just typed — new goals get a plain input that
// submits with the create form instead.
$roLive = (int) ($canvasItem['id'] ?? 0) > 0;
@endphp
<div class="gv-metric-bar" id="gvReadout">
{{-- WHAT is being measured without it the tab is a bare number. --}}
@if (trim((string) ($canvasItem['description'] ?? '')) !== '')
<div class="gv-mb-metric">{{ $canvasItem['description'] }}</div>
@endif
<div class="gv-mb-top">
@if ($roEditable && $roLive)
<input class="gv-mb-input" type="number" step="0.01" name="currentValue"
value="{{ $roCur == floor($roCur) ? (int) $roCur : $roCur }}"
aria-label="{{ __('goalcanvas.update_current') }}"
data-tippy-content="{{ __('goalcanvas.update_current') }}"
hx-post="{{ BASE_URL }}/hx/goalcanvas/goalProgress/updateValue"
hx-vals='{"itemId": {{ (int) $canvasItem['id'] }}}'
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-trigger="blur changed"
hx-target="#gvReadout"
hx-swap="outerHTML">
@elseif ($roEditable)
{{-- New goal: same editable number, no HTMX the value rides the
create form's own submit. --}}
<input class="gv-mb-input" type="number" step="0.01" name="currentValue"
value="{{ $roCur == floor($roCur) ? (int) $roCur : $roCur }}"
aria-label="{{ __('goalcanvas.update_current') }}"
data-tippy-content="{{ __('goalcanvas.update_current') }}">
@else
<span class="gv-mb-now" @if (($canvasItem['setting'] ?? '') === 'linkAndReport') data-tippy-content="{{ __('text.current_value_calculated_from_children') }}" @endif>{{ $roFmt($roCur) }}</span>
{{-- Static readout (linkAndReport / viewer) submits no currentValue,
and EditCanvasItem defaults an absent one to '' — which would
blank the stored metric when the user saves other fields. Round
the stored value back through the form to preserve it. --}}
<input type="hidden" name="currentValue" value="{{ $roCur == floor($roCur) ? (int) $roCur : $roCur }}">
@endif
{{-- How far away it ISN'T the missing half of every progress bar. --}}
@if ($roHasRange)
<span class="gv-mb-togo">
@if ($roReached)
{{ __('goalcanvas.goal_reached') }}
@else
{{ sprintf(__('goalcanvas.to_go'), $roFmt($roRemaining)) }}
@endif
</span>
@endif
</div>
{{-- Scored track (RA planbar language) + resolved % the % column
width/gap matches the milestone rows so both right edges align. --}}
<div class="gv-mb-barrow">
<div class="gv-mb-scalewrap">
<div class="gv-track gv-track--scored" aria-hidden="true">
<div class="gv-fill" style="width:{{ $roPct }}%"></div>
<span class="gv-tick" style="left:25%"></span>
<span class="gv-tick" style="left:50%"></span>
<span class="gv-tick" style="left:75%"></span>
@if ($roPct > 0 && $roPct < 100)
<span class="gv-marker" style="left:{{ $roPct }}%"></span>
@endif
</div>
{{-- The totals live where they ARE on the scale: start under the
left end, the goal under the right end. --}}
<div class="gv-scale"><span>{{ $roFmt($roStart) }}</span><span>{{ $roFmt($roGoal) }}</span></div>
</div>
<span class="gv-mb-pct">{{ (int) round($roPct) }}%</span>
</div>
</div>

View File

@@ -0,0 +1,66 @@
@if (count($allCanvas) > 0)
@else
<br /><br />
<div class='center'>
<div class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{{ __("headlines.goal.analysis") }}</h3>
<br />{{ __("text.goal.helper_content") }}
@if ($login::userIsAtLeast($roles::$editor))
<br /><br />
<a href='javascript:void(0)' class='addCanvasLink btn btn-primary'>
{{ __('links.icon.create_new_board') }}
</a>
@endif
</div>
@endif
@if (!empty($disclaimer) && count($allCanvas) > 0)
<small class="align-center">{{ $disclaimer }}</small>
@endif
{!! $tpl->viewFactory->make($tpl->getTemplatePath('canvas', 'modals'), $__data)->render() !!}
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function() {
if (jQuery('#searchCanvas').length > 0) {
new SlimSelect({
select: '#searchCanvas'
});
}
leantime.goalCanvasController.setRowHeights();
leantime.canvasController.setCanvasName('goal');
leantime.canvasController.initFilterBar();
@if ($login::userIsAtLeast($roles::$editor))
leantime.canvasController.initCanvasLinks();
leantime.canvasController.initUserDropdown();
leantime.canvasController.initStatusDropdown();
leantime.canvasController.initRelatesDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
@if (isset($_GET['showModal']))
@php
if ($_GET['showModal'] == "") {
$modalUrl = "&type=" . array_key_first($canvasTypes);
} else {
$modalUrl = "/" . (int)$_GET['showModal'];
}
@endphp
leantime.canvasController.openModalManually(
"{{ BASE_URL }}/goalcanvas/editCanvasItem{{ $modalUrl }}");
window.history.pushState({}, document.title, '{{ BASE_URL }}/goalcanvas/showCanvas/');
@endif
});
</script>

View File

@@ -0,0 +1,485 @@
@extends($layout)
@section('content')
@php
$elementName = 'goal';
@endphp
@php
$canvasTitle = '';
//get canvas title
foreach ($allCanvas as $canvasRow) {
if ($canvasRow['id'] == $currentCanvas) {
$canvasTitle = $canvasRow['title'];
break;
}
}
@endphp
<style>
.canvas-row {
margin-left: 0px;
margin-right: 0px;
}
.canvas-title-only {
border-radius: var(--box-radius-small);
}
h4.canvas-element-title-empty {
background: white !important;
border-color: white !important;
}
div.canvas-element-center-middle {
text-align: center;
}
</style>
<div class="pageheader">
<div class="pageicon"><span class="fas {{ $canvasIcon }}"></span></div>
<div class="pagetitle">
@if (count($allCanvas) > 0)
<x-global::subjectSwitcher
:parent="__('headline.goal.board')"
:current="$canvasTitle">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/goalcanvas/bigRock">{!! __('links.icon.create_new_bigrock') !!}</a></li>
@endif
<li class="border"></li>
@foreach ($allCanvas as $canvasRow)
<li><a
href='{{ BASE_URL }}/goalcanvas/showCanvas/{{ $canvasRow['id'] }}'>{{ $canvasRow['title'] }}</a>
</li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{{ __('headline.goal.board') }}</h1>
@endif
</div>
@if (count($allCanvas) > 0)
<div class="pageheader-right">
<span class="dropdown dropdownWrapper headerEditDropdown">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown"><i
class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/goalcanvas/bigRock/{{ $currentCanvas }}">{!! __('links.icon.edit') !!}</a></li>
<li><a href="javascript:void(0)" class="cloneCanvasLink ">{!! __('links.icon.clone') !!}</a></li>
<li><a href="javascript:void(0)" class="mergeCanvasLink ">{!! __('links.icon.merge') !!}</a></li>
<li><a href="javascript:void(0)" class="importCanvasLink ">{!! __('links.icon.import') !!}</a>
</li>
@endif
<li><a
href="{{ BASE_URL }}/goalcanvas/export/{{ $currentCanvas }}">{!! __('links.icon.export') !!}</a>
</li>
<li><a href="javascript:window.print();">{!! __('links.icon.print') !!}</a></li>
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/goalcanvas/delCanvas/{{ $currentCanvas }}"
class="delete">{!!__('links.icon.delete') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
</div>
<!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
<?php echo $tpl->displayNotification(); ?>
<div class="row">
<div class="col-md-3">
@if ($login::userIsAtLeast($roles::$editor) && count($canvasTypes) == 1 && count($allCanvas) > 0)
<x-global::forms.button tag="a" link="#/goalcanvas/editCanvasItem?type={{ $elementName }}" contentRole="primary"
id="{{ $elementName }}">{!! __('links.add_new_canvas_itemgoal') !!}</x-global::forms.button>
@endif
</div>
<div class="col-md-6 center">
</div>
<div class="col-md-3">
<div class="pull-right">
<div class="btn-group viewDropDown">
@if (count($allCanvas) > 0 && !empty($statusLabels))
@php
$filterStatus = $filter['status'] ?? 'all';
$filterRelates = $filter['relates'] ?? 'all';
@endphp
@if (($filterStatus ?? '') == 'all')
<button class="btn dropdown-toggle" data-toggle="dropdown"><i class="fas fa-filter"></i>
{!! __('status.all') !!} {!! __('links.view') !!}</button>
@else
<button class="btn dropdown-toggle" data-toggle="dropdown"><i
class="fas fa-fw {{ __($statusLabels[$filterStatus]['icon']) }}"></i>
{{ $statusLabels[$filterStatus]['title'] }} {{ __('links.view') }}</button>
@endif
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/goalcanvas/showCanvas?filter_status=all" @if ($filterStatus == 'all')
class="active"
@endif><i class="fas fa-globe"></i> {!! __('status.all') !!}</a></li>
@foreach ($statusLabels as $key => $data)
<li><a href="{{ BASE_URL }}/goalcanvas/showCanvas?filter_status={{ $key }}"
@if ($filterStatus == $key)
class="active"
@endif><i class="fas fa-fw {{ $data['icon'] }}"></i>
{!! $data['title'] !!}</a></li>
@endforeach
</ul>
@endif
</div>
<div class="btn-group viewDropDown">
@if (count($allCanvas) > 0 && !empty($relatesLabels))
@php
$filterStatus = $filter['status'] ?? 'all';
$filterRelates = $filter['relates'] ?? 'all';
@endphp
@if ($filterRelates == 'all')
<button class="btn dropdown-toggle" data-toggle="dropdown"><i
class="fas fa-fw fa-globe"></i> {{ __('relates.all') }}
{{ __('links.view') }}</button>
@else
<button class="btn dropdown-toggle" data-toggle="dropdown"><i
class="fas fa-fw {{ __($relatesLabels[$filterRelates]['icon']) }}"></i>
{{ $relatesLabels[$filterRelates]['title'] }} {{ __('links.view') }}</button>
@endif
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/goalcanvas/showCanvas?filter_relates=all" @if ($filterRelates == 'all')
class="active"
@endif><i class="fas fa-globe"></i> {{ __('relates.all') }}</a></li>
@foreach ($relatesLabels as $key => $data)
<li><a href="{{ BASE_URL }}/goalcanvas/showCanvas?filter_relates={{ $key }}"
@if ($filterRelates == $key)
class="active"
@endif><i class="fas fa-fw {{ $data['icon'] }}"></i>
{{ $data['title'] }}</a></li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
</div>
<div class="clearfix"></div>
@if (count($allCanvas) > 0)
<div id="sortableCanvasKanban" class="sortableTicketList disabled" style="padding-top:15px;">
<div class="row">
<div class="col-md-12">
<div class="row">
@foreach ($canvasItems as $row)
@php
$filterStatus = $filter['status'] ?? 'all';
$filterRelates = $filter['relates'] ?? 'all';
@endphp
@if (
$row['box'] === $elementName &&
($filterStatus == 'all' || $filterStatus == $row['status']) &&
($filterRelates == 'all' || $filterRelates == $row['relates']))
@php
$comments = app()->make(\Leantime\Domain\Comments\Repositories\Comments::class);
$nbcomments = $comments->countComments(moduleId: $row['id']);
@endphp
<div class="col-md-4">
<div class="ticketBox" id="item_{{ $row['id'] }}">
<div class="row">
<div class="col-md-12">
<div class="inlineDropDownContainer" style="float:right;">
@if ($login::userIsAtLeast($roles::$editor))
<a href="javascript:void(0)"
class="dropdown-toggle ticketDropDown"
data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{{ __('subtitles.edit') }}</li>
<li><a href="#/goalcanvas/editCanvasItem/{{ $row['id'] }}"
data="item_{{ $row['id'] }}">
{!! __('links.edit_canvas_item') !!}</a></li>
<li><a href="#/goalcanvas/delCanvasItem/{{ $row['id'] }}"
data="item_{{ $row['id'] }}">
{!! __('links.delete_canvas_item') !!}</a></li>
</ul>
@endif
</div>
<h4><strong>Goal:</strong> <a
href="#/goalcanvas/editCanvasItem/{{ $row['id'] }}"
data="item_{{ $row['id'] }}">{{ $row['title'] }}</a>
</h4>
<br />
<strong>Metric:</strong> {{ $row['description'] }}
<br /><br />
@php
$percentDone = $row['goalProgress'];
$metricTypeFront = '';
$metricTypeBack = '';
if ($row['metricType'] == 'percent') {
$metricTypeBack = '%';
} elseif ($row['metricType'] == 'currency') {
$metricTypeFront = __('language.currency');
}
@endphp
<div class="row">
<div class="col-md-4"></div>
<div class="col-md4 center">
<small>{{ sprintf(__('text.percent_complete'), $percentDone) }}</small>
</div>
<div class="col-md-4"></div>
</div>
<div class="progress" style="margin-bottom:0px;">
<div class="progress-bar progress-bar-success"
role="progressbar" aria-valuenow="{{ $percentDone }}"
aria-valuemin="0" aria-valuemax="100"
style="width: {{ $percentDone }}%">
<span
class="sr-only">{{ sprintf(__('text.percent_complete'), $percentDone) }}</span>
</div>
</div>
<div class="row" style="padding-bottom:0px;">
<div class="col-md-4">
<small>Start:<br />{{ $metricTypeFront . $row['startValue'] . $metricTypeBack }}</small>
</div>
<div class="col-md-4 center">
<small>{{ __('label.current') }}:<br />{{ $metricTypeFront . $row['currentValue'] . $metricTypeBack }}</small>
</div>
<div class="col-md-4" style="text-align:right">
<small>{{ __('label.goal') }}:<br />{{ $metricTypeFront . $row['endValue'] . $metricTypeBack }}</small>
</div>
</div>
<div class="clearfix" style="padding-bottom: 8px;"></div>
@if (!empty($statusLabels))
<div
class="dropdown ticketDropdown statusDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left status label-{{ $row['status'] != '' ? $statusLabels[$row['status']]['dropdown'] : '' }}"
href="javascript:void(0);" role="button"
id="statusDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span
class="text">{{ $row['status'] != '' ? $statusLabels[$row['status']]['title'] : '' }}</span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu"
aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">
{{ __('dropdown.choose_status') }}</li>
@foreach ($statusLabels as $key => $data)
@if ($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);"
class="label-{{ $data['dropdown'] }}"
data-label='{{ $data['title'] }}'
data-value="{{ $row['id'] . '/' . $key }}"
id="ticketStatusChange{{ $row['id'] . $key }}">{{ $data['title'] }}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if (!empty($relatesLabels))
<div
class="dropdown ticketDropdown relatesDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left relates label-{{ $relatesLabels[$row['relates']]['dropdown'] }}"
href="javascript:void(0);" role="button"
id="relatesDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span
class="text">{{ $relatesLabels[$row['relates']]['title'] }}</span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu"
aria-labelledby="relatesDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">
{{ __('dropdown.choose_relates') }}</li>
@foreach ($relatesLabels as $key => $data)
@if ($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);"
class="label-{{ $data['dropdown'] }}"
data-label='{{ $data['title'] }}'
data-value="{{ $row['id'] . '/' . $key }}"
id="ticketRelatesChange{{ $row['id'] . $key }}">{{ $data['title'] }}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
<div
class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
<a class="dropdown-toggle f-left" href="javascript:void(0);"
role="button"
id="userDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span class="text">
@if ($row['authorFirstname'] != '')
<span id='userImage{{ $row['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage={{ $row['author'] }}'
width='25'
style='vertical-align: middle;' />
</span>
<span id='user{{ $row['id'] }}'></span>
@else
<span id='userImage{{ $row['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage=false'
width='25'
style='vertical-align: middle;' />
</span>
<span id='user{{ $row['id'] }}'></span>
@endif
</span>
</a>
<ul class="dropdown-menu"
aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">
{{ __('dropdown.choose_user') }}</li>
@foreach ($users as $user)
<li class='dropdown-item'>
<a href='javascript:void(0);'
data-label='{{ sprintf(__('text.full_name'), $user['firstname'], $user['lastname']) }}'
data-value='{{ $row['id'] . '_' . $user['id'] . '_' . $user['profileId'] }}'
id='userStatusChange{{ $row['id'] . $user['id'] }}'>
<img src='{{ BASE_URL }}/api/users?profileImage={{ $user['id'] }}'
width='25'
style='vertical-align: middle; margin-right:5px;' />
{{ sprintf(__('text.full_name'), $user['firstname'], $user['lastname']) }}
</a>
</li>
@endforeach
</ul>
</div>
<div class="right" style="margin-right:10px;">
<a href="#/goalcanvas/editCanvasComment/{{ $row['id'] }}"
class="commentCountLink"
data="item_{{ $row['id'] }}"><span
class="fas fa-comments"></span></a>
<small>{{ $nbcomments }}</small>
</div>
</div>
</div>
@include('goalcanvas::partials.milestoneChips', ['milestones' => $row['milestones'] ?? []])
</div>
</div>
@endif
@endforeach
</div>
<br />
</div>
</div>
</div>
@if (count($canvasItems) == 0)
<br /><br />
<div class='center'>
<div class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{{ __('headlines.goal.analysis') }}</h3>
<br />{!! __('text.goal.helper_content') !!}
</div>
@endif
<div class="clearfix"></div>
@endif
<!-- ShowBottomCanvs -->
@if (count($allCanvas) > 0)
@else
<br /><br />
<div class='center'>
<div class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{{ __('headlines.goal.analysis') }}</h3>
<br />{{ __('text.goal.helper_content') }}
@if ($login::userIsAtLeast($roles::$editor))
<br /><br />
<x-global::forms.button tag="a" link="javascript:void(0)" class="addCanvasLink" contentRole="primary">
{{ __('links.icon.create_new_board') }}
</x-global::forms.button>
@endif
</div>
@endif
@if (!empty($disclaimer) && count($allCanvas) > 0)
<small class="align-center">{{ $disclaimer }}</small>
@endif
{!! $tpl->viewFactory->make($tpl->getTemplatePath('canvas', 'modals'), $__data)->render() !!}
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function() {
if (jQuery('#searchCanvas').length > 0) {
new SlimSelect({
select: '#searchCanvas'
});
}
leantime.goalCanvasController.setRowHeights();
leantime.canvasController.setCanvasName('goal');
leantime.canvasController.initFilterBar();
@if ($login::userIsAtLeast($roles::$editor))
leantime.canvasController.initCanvasLinks();
leantime.canvasController.initUserDropdown();
leantime.canvasController.initStatusDropdown();
leantime.canvasController.initRelatesDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
@if (isset($_GET['showModal']))
@php
if ($_GET['showModal'] == '') {
$modalUrl = '&type=' . array_key_first($canvasTypes);
} else {
$modalUrl = '/' . (int) $_GET['showModal'];
}
@endphp
leantime.canvasController.openModalManually(
"{{ BASE_URL }}/goalcanvas/editCanvasItem{{ $modalUrl }}");
window.history.pushState({}, document.title,
'{{ BASE_URL }}/goalcanvas/showCanvas/');
@endif
});
</script>
@endsection

View File

@@ -0,0 +1,92 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Create a new goal.
*/
class CreateGoalTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('title')->description('The goal as an outcome statement, e.g. "Increase early-detection screenings". NOT the metric — must differ from description.')
->required()
->string('description')->description('The metric being tracked, e.g. "Screenings at clinic walk-ins" (shown as "What are you tracking?"). Must differ from the title.')
->required()
->number('startValue')->description('Starting value for the goal metric.')
->required()
->number('currentValue')->description('Current value of the goal metric.')
->required()
->number('endValue')->description('Target value for the goal metric.')
->required()
->integer('canvasId')->description('Canvas ID this goal belongs to.')
->required()
->string('startDate')->description('Start date in ISO8601 format.')
->string('endDate')->description('End date in ISO8601 format.')
->integer('milestoneId')->description('ID of a milestone to attach to this goal.')
->string('metricType')->description('Type of metric (e.g., "percent", "currency", "number").')
->string('status')->description('Status of the goal (e.g., "status_ontrack", "status_atrisk", "status_miss").');
}
public function name(): string
{
return 'createGoal';
}
public function description(): string
{
return 'Creates a new goal with the specified details.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$values = [
'title' => $arguments['title'],
'description' => $arguments['description'],
'box' => 'goal',
'author' => session('userdata.id'),
'canvasId' => (int) ($arguments['canvasId'] ?? 0),
'startValue' => ($arguments['startValue'] ?? null),
'currentValue' => ($arguments['currentValue'] ?? null),
'endValue' => ($arguments['endValue'] ?? null),
'metricType' => ($arguments['metricType'] ?? 'number'),
'status' => ($arguments['status'] ?? 'status_ontrack'),
];
$startDate = ($arguments['startDate'] ?? null);
if ($startDate !== null) {
$values['startDate'] = $startDate;
}
$endDate = ($arguments['endDate'] ?? null);
if ($endDate !== null) {
$values['endDate'] = $endDate;
}
$milestoneId = ($arguments['milestoneId'] ?? null);
if ($milestoneId !== null) {
$values['milestoneId'] = $milestoneId;
}
$goalId = $this->goalcanvasService->createGoal($values);
if ($goalId) {
return ToolResult::text("Goal created successfully with ID: {$goalId}");
}
return ToolResult::error('Failed to create goal. Please check the provided information.');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Create a new goal board.
*/
class CreateGoalboardTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('title')->description('Title of the goal board.')
->required()
->integer('projectId')->description('Project ID this goal board belongs to.')
->required()
->string('description')->description('Description of the goal board.');
}
public function name(): string
{
return 'createGoalboard';
}
public function description(): string
{
return 'Creates a new goal board for organizing goals within a project.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$values = [
'title' => $arguments['title'],
'description' => ($arguments['description'] ?? ''),
'projectId' => (int) ($arguments['projectId'] ?? 0),
'author' => session('userdata.id'),
];
$boardId = $this->goalcanvasService->createGoalboard($values);
if ($boardId) {
return ToolResult::text("Goal board created successfully with ID: {$boardId}");
}
return ToolResult::error('Failed to create goal board. Please check the provided information.');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Update an existing goal.
*/
class EditGoalTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the goal to update.')
->required()
->string('title')->description('Updated title of the goal.')
->string('description')->description('Updated description of what the goal is measuring.')
->number('startValue')->description('Updated starting value for the goal metric.')
->number('currentValue')->description('Updated current value of the goal metric.')
->number('endValue')->description('Updated target value for the goal metric.')
->string('startDate')->description('Updated start date in ISO8601 format.')
->string('endDate')->description('Updated end date in ISO8601 format.')
->integer('milestoneId')->description('Updated ID of a milestone to attach to this goal.')
->string('metricType')->description('Updated type of metric (e.g., "percent", "currency", "number").')
->string('status')->description('Updated status of the goal (e.g., "status_ontrack", "status_atrisk", "status_miss").');
}
public function name(): string
{
return 'editGoal';
}
public function description(): string
{
return 'Updates an existing goal with the specified details.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
if ($id <= 0) {
return ToolResult::error('A valid goal id is required.');
}
$optionalFields = [
'title', 'description', 'startValue', 'currentValue', 'endValue',
'startDate', 'endDate', 'milestoneId', 'metricType', 'status',
];
$params = [];
foreach ($optionalFields as $field) {
$value = $arguments[$field] ?? null;
if ($value !== null) {
$params[$field] = $value;
}
}
if ($params === []) {
return ToolResult::error('Provide at least one field to update.');
}
try {
$updated = $this->goalcanvasService->patchGoalItem($id, $params);
} catch (AuthorizationException) {
// Unknown, foreign, or unauthorized goal id — one message for all three, so the
// response does not leak whether a goal id exists in another project.
return ToolResult::error("Goal with ID {$id} not found.");
}
if ($updated) {
return ToolResult::text('Goal updated successfully.');
}
return ToolResult::error('Failed to update goal.');
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Get all goals for a project.
*/
#[IsReadOnly]
class GetAllGoalsTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to get goals for.')
->required()
->integer('boardId')->description('Specific goal board ID to filter by.');
}
public function name(): string
{
return 'getAllGoals';
}
public function description(): string
{
return 'Gets all goals for a specific project.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$boardId = ($arguments['boardId'] ?? null);
$goals = $this->goalcanvasService->pollGoals($projectId, $boardId);
if (empty($goals)) {
return ToolResult::text("No goals found for project ID: {$projectId}");
}
// Milestone links come from the tracked_by edge model (many-to-many),
// hydrated in ONE batched call — the legacy milestoneId column goes
// stale after edits and misses every link beyond the first.
$milestonesByGoal = $this->goalcanvasService->getMilestonesForGoals(
array_map(static fn ($g) => (int) $g['id'], $goals)
);
$response = "## Goals\n";
foreach ($goals as $goal) {
$milestones = array_map(
static fn (array $m) => ($m['headline'] ?? '').' ('.((int) ($m['percentDone'] ?? 0)).'%)',
$milestonesByGoal[(int) $goal['id']] ?? []
);
$result = [
'id' => $goal['id'],
'title' => Str::sanitizeForLLM($goal['title']),
'description' => Str::sanitizeForLLM($goal['description']),
'startValue' => $goal['startValue'],
'currentValue' => $goal['currentValue'],
'endValue' => $goal['endValue'],
'metricType' => $goal['metricType'],
'canvasId' => $goal['canvasId'],
'milestones' => $milestones !== [] ? Str::sanitizeForLLM(implode('; ', $milestones)) : 'None',
'startDate' => $goal['startDate'],
'endDate' => $goal['endDate'],
'status' => $goal['status'],
'setting' => $goal['setting'],
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Get all child goals associated with a parent goal (KPI).
*/
#[IsReadOnly]
class GetChildGoalsTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('parentId')->description('ID of the parent goal to get children for.')
->required();
}
public function name(): string
{
return 'getChildGoals';
}
public function description(): string
{
return 'Gets all child goals associated with a parent goal.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$parentId = (int) ($arguments['parentId'] ?? 0);
$childGoals = $this->goalcanvasService->getChildrenbyKPI($parentId);
if (empty($childGoals)) {
return ToolResult::text("No child goals found for parent goal ID: {$parentId}");
}
$response = "## Child Goals for Parent ID: {$parentId}\n";
foreach ($childGoals as $goal) {
$result = [
'id' => $goal['id'],
'title' => Str::sanitizeForLLM($goal['title']),
'startValue' => $goal['startValue'],
'currentValue' => $goal['currentValue'],
'endValue' => $goal['endValue'],
'metricType' => $goal['metricType'],
'boardTitle' => Str::sanitizeForLLM($goal['boardTitle']),
'canvasId' => $goal['canvasId'],
'projectName' => Str::sanitizeForLLM($goal['projectName']),
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Get detailed information about a specific goal.
*/
#[IsReadOnly]
class GetGoalTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('goalId')->description('ID of the goal to retrieve.')
->required();
}
public function name(): string
{
return 'getGoal';
}
public function description(): string
{
return 'Gets detailed information about a specific goal by its ID.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$goalId = (int) ($arguments['goalId'] ?? 0);
$goal = $this->goalcanvasService->getGoalItem($goalId);
if (! $goal) {
return ToolResult::error("Goal with ID {$goalId} not found.");
}
// Milestones come from the tracked_by edge model (many-to-many), not
// the frozen legacy milestoneId column — a goal can have several, and
// the column goes stale after edits.
$milestones = array_map(
static fn (array $m) => ($m['headline'] ?? '').' ('.((int) ($m['percentDone'] ?? 0)).'%, '.($m['statusType'] ?? 'NEW').')',
$this->goalcanvasService->getGoalMilestones($goalId)['milestones']
);
$response = "## Goal Details\n";
$result = [
'id' => $goal['id'],
'title' => Str::sanitizeForLLM($goal['title']),
'description' => Str::sanitizeForLLM($goal['description']),
'board' => Str::sanitizeForLLM($goal['boardTitle'] ?? ''),
'startValue' => $goal['startValue'],
'currentValue' => $goal['currentValue'],
'endValue' => $goal['endValue'],
'metricType' => $goal['metricType'],
'status' => $goal['status'],
'startDate' => $goal['startDate'],
'endDate' => $goal['endDate'],
'milestones' => $milestones !== [] ? Str::sanitizeForLLM(implode('; ', $milestones)) : 'None',
'author' => $goal['authorFirstname'].' '.$goal['authorLastname'],
'created' => $goal['created'],
];
$response .= Str::toMarkdown($result)."\n";
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Get all goals associated with a specific milestone.
*/
#[IsReadOnly]
class GetGoalsByMilestoneTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('milestoneId')->description('ID of the milestone to get goals for.')
->required();
}
public function name(): string
{
return 'getGoalsByMilestone';
}
public function description(): string
{
return 'Gets all goals associated with a specific milestone.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$milestoneId = (int) ($arguments['milestoneId'] ?? 0);
$goals = $this->goalcanvasService->getGoalsByMilestone($milestoneId);
if (empty($goals)) {
return ToolResult::text("No goals found for milestone ID: {$milestoneId}");
}
$response = "## Goals for Milestone ID: {$milestoneId}\n";
foreach ($goals as $goal) {
$result = [
'id' => $goal['id'],
'title' => Str::sanitizeForLLM($goal['title']),
'description' => Str::sanitizeForLLM($goal['description']),
'startValue' => $goal['startValue'],
'currentValue' => $goal['currentValue'],
'endValue' => $goal['endValue'],
'metricType' => $goal['metricType'],
'canvasId' => $goal['canvasId'],
'startDate' => $goal['startDate'],
'endDate' => $goal['endDate'],
'status' => $goal['status'],
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Leantime\Domain\Goalcanvas\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
/**
* Get all available parent KPIs (goals) that can be linked to other goals.
*/
#[IsReadOnly]
class GetParentKPIsTool extends Tool
{
public function __construct(
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to get parent KPIs for.')
->required();
}
public function name(): string
{
return 'getParentKPIs';
}
public function description(): string
{
return 'Gets all available parent KPIs that can be linked to other goals.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$parentKPIs = $this->goalcanvasService->getParentKPIs($projectId);
if (empty($parentKPIs)) {
return ToolResult::text("No parent KPIs found for project ID: {$projectId}");
}
$response = "## Available Parent KPIs for Project ID: {$projectId}\n";
foreach ($parentKPIs as $kpi) {
$result = [
'id' => $kpi['id'],
'description' => Str::sanitizeForLLM($kpi['description']),
'project' => Str::sanitizeForLLM($kpi['project']),
'board' => Str::sanitizeForLLM($kpi['board']),
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}