OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
90
app/Domain/Goalcanvas/Controllers/BigRock.php
Normal file
90
app/Domain/Goalcanvas/Controllers/BigRock.php
Normal 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.'');
|
||||
}
|
||||
}
|
||||
}
|
||||
469
app/Domain/Goalcanvas/Controllers/Dashboard.php
Normal file
469
app/Domain/Goalcanvas/Controllers/Dashboard.php
Normal 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')));
|
||||
}
|
||||
}
|
||||
82
app/Domain/Goalcanvas/Controllers/DelCanvas.php
Normal file
82
app/Domain/Goalcanvas/Controllers/DelCanvas.php
Normal 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');
|
||||
}
|
||||
}
|
||||
70
app/Domain/Goalcanvas/Controllers/DelCanvasItem.php
Normal file
70
app/Domain/Goalcanvas/Controllers/DelCanvasItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
278
app/Domain/Goalcanvas/Controllers/EditCanvasComment.php
Normal file
278
app/Domain/Goalcanvas/Controllers/EditCanvasComment.php
Normal 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) {}
|
||||
}
|
||||
419
app/Domain/Goalcanvas/Controllers/EditCanvasItem.php
Normal file
419
app/Domain/Goalcanvas/Controllers/EditCanvasItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
119
app/Domain/Goalcanvas/Controllers/Export.php
Normal file
119
app/Domain/Goalcanvas/Controllers/Export.php
Normal 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;
|
||||
}
|
||||
}
|
||||
397
app/Domain/Goalcanvas/Controllers/ShowCanvas.php
Normal file
397
app/Domain/Goalcanvas/Controllers/ShowCanvas.php
Normal 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')));
|
||||
}
|
||||
}
|
||||
58
app/Domain/Goalcanvas/Hxcontrollers/GoalProgress.php
Normal file
58
app/Domain/Goalcanvas/Hxcontrollers/GoalProgress.php
Normal 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);
|
||||
}
|
||||
}
|
||||
274
app/Domain/Goalcanvas/Js/goalCanvasController.js
Normal file
274
app/Domain/Goalcanvas/Js/goalCanvasController.js
Normal 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
|
||||
};
|
||||
|
||||
})();
|
||||
46
app/Domain/Goalcanvas/Permissions/GoalcanvasPermissions.php
Normal file
46
app/Domain/Goalcanvas/Permissions/GoalcanvasPermissions.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
1554
app/Domain/Goalcanvas/Repositories/Goalcanvas.php
Normal file
1554
app/Domain/Goalcanvas/Repositories/Goalcanvas.php
Normal file
File diff suppressed because it is too large
Load Diff
907
app/Domain/Goalcanvas/Services/Goalcanvas.php
Normal file
907
app/Domain/Goalcanvas/Services/Goalcanvas.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
app/Domain/Goalcanvas/Templates/bigRockDialog.blade.php
Normal file
47
app/Domain/Goalcanvas/Templates/bigRockDialog.blade.php
Normal 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>
|
||||
65
app/Domain/Goalcanvas/Templates/canvasComment.blade.php
Normal file
65
app/Domain/Goalcanvas/Templates/canvasComment.blade.php
Normal 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
|
||||
471
app/Domain/Goalcanvas/Templates/canvasDialog.blade.php
Normal file
471
app/Domain/Goalcanvas/Templates/canvasDialog.blade.php
Normal 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> {{ $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> {{ $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
|
||||
355
app/Domain/Goalcanvas/Templates/dashboard.blade.php
Normal file
355
app/Domain/Goalcanvas/Templates/dashboard.blade.php
Normal 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))
|
||||
|
||||
<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
|
||||
15
app/Domain/Goalcanvas/Templates/delCanvas.blade.php
Normal file
15
app/Domain/Goalcanvas/Templates/delCanvas.blade.php
Normal 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
|
||||
12
app/Domain/Goalcanvas/Templates/delCanvasItem.blade.php
Normal file
12
app/Domain/Goalcanvas/Templates/delCanvasItem.blade.php
Normal 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
|
||||
@@ -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
|
||||
@@ -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)· {{ $milestoneSummary['inProgress'] }} {{ __("goalcanvas.summary_in_progress") }} @endif
|
||||
@if ($milestoneSummary['notStarted'] > 0)· {{ $milestoneSummary['notStarted'] }} {{ __("goalcanvas.summary_not_started") }} @endif
|
||||
@if ($milestoneSummary['done'] > 0)· {{ $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>
|
||||
@@ -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>
|
||||
66
app/Domain/Goalcanvas/Templates/showBottomCanvas.blade.php
Normal file
66
app/Domain/Goalcanvas/Templates/showBottomCanvas.blade.php
Normal 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>
|
||||
485
app/Domain/Goalcanvas/Templates/showCanvas.blade.php
Normal file
485
app/Domain/Goalcanvas/Templates/showCanvas.blade.php
Normal 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
|
||||
92
app/Domain/Goalcanvas/Tools/CreateGoalTool.php
Normal file
92
app/Domain/Goalcanvas/Tools/CreateGoalTool.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
59
app/Domain/Goalcanvas/Tools/CreateGoalboardTool.php
Normal file
59
app/Domain/Goalcanvas/Tools/CreateGoalboardTool.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
88
app/Domain/Goalcanvas/Tools/EditGoalTool.php
Normal file
88
app/Domain/Goalcanvas/Tools/EditGoalTool.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
87
app/Domain/Goalcanvas/Tools/GetAllGoalsTool.php
Normal file
87
app/Domain/Goalcanvas/Tools/GetAllGoalsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/Domain/Goalcanvas/Tools/GetChildGoalsTool.php
Normal file
69
app/Domain/Goalcanvas/Tools/GetChildGoalsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
80
app/Domain/Goalcanvas/Tools/GetGoalTool.php
Normal file
80
app/Domain/Goalcanvas/Tools/GetGoalTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
71
app/Domain/Goalcanvas/Tools/GetGoalsByMilestoneTool.php
Normal file
71
app/Domain/Goalcanvas/Tools/GetGoalsByMilestoneTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
app/Domain/Goalcanvas/Tools/GetParentKPIsTool.php
Normal file
64
app/Domain/Goalcanvas/Tools/GetParentKPIsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user