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

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

View File

@@ -0,0 +1,60 @@
<?php
namespace Leantime\Domain\Tickets\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\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class DelMilestone extends Controller
{
private TicketService $ticketService;
public function init(TicketService $ticketService): void
{
$this->ticketService = $ticketService;
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::DELETE)]
public function get(): Response
{
if (! isset($_GET['id'])) {
return $this->tpl->displayPartial('errors.error404', responseCode: 404);
}
$id = (int) $_GET['id'];
$this->tpl->assign('ticket', $this->ticketService->getTicket($id));
return $this->tpl->displayPartial('tickets.delMilestone');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::DELETE)]
public function post($params): Response
{
if (! isset($_GET['id'], $params['del'])) {
return $this->tpl->displayPartial('errors.error404', responseCode: 404);
}
if (($result = $this->ticketService->deleteMilestone($id = (int) ($_GET['id']))) === true) {
$this->tpl->setNotification($this->language->__('notification.milestone_deleted'), 'success');
return Frontcontroller::redirect(BASE_URL.'/tickets/roadmap');
}
$this->tpl->setNotification($this->language->__($result['msg']), 'error');
$this->tpl->assign('ticket', $this->ticketService->getTicket($id));
return $this->tpl->displayPartial('tickets.delMilestone');
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class DelTicket extends Controller
{
private TicketService $ticketService;
public function init(TicketService $ticketService): void
{
$this->ticketService = $ticketService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(TicketsPermissions::DELETE)]
public function get(): Response
{
if (! isset($_GET['id'])) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$id = (int) $_GET['id'];
try {
$this->ticketService->canDelete($id);
} catch (\Exception $e) {
$this->tpl->assign('error', $e->getMessage());
return $this->tpl->displayPartial('tickets.delTicket');
}
$this->tpl->assign('error', '');
$this->tpl->assign('ticket', $this->ticketService->getTicket($id));
return $this->tpl->displayPartial('tickets.delTicket');
}
/**
* @throws \Exception
*/
#[RequiresPermission(TicketsPermissions::DELETE)]
public function post($params): Response
{
if (! isset($params['del'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
if (! isset($_GET['id'])) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$id = (int) $_GET['id'];
$result = $this->ticketService->delete($id);
if ($result === true) {
$this->tpl->setNotification($this->language->__('notification.todo_deleted'), 'success');
$redirect = session('lastPage') ?? BASE_URL.'/';
return Frontcontroller::redirect($redirect);
}
$this->tpl->setNotification($this->language->__($result['msg']), 'error');
$this->tpl->assign('ticket', $this->ticketService->getTicket($id));
return $this->tpl->displayPartial('tickets.delTicket');
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
class EditMilestone extends Controller
{
private TicketService $ticketService;
private CommentService $commentsService;
private ProjectService $projectService;
/**
* init - initialize private variables
*/
public function init(
TicketService $ticketService,
CommentService $commentsService,
ProjectService $projectService
): void {
$this->ticketService = $ticketService;
$this->commentsService = $commentsService;
$this->projectService = $projectService;
}
/**
* get - handle get requests
*/
public function get($params)
{
if (isset($params['id'])) {
// Delete comment
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
$this->commentsService->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
}
$milestone = $this->ticketService->getMilestone((int) $params['id']);
if ($milestone === false || ! isset($milestone->id)) {
$this->tpl->setNotification($this->language->__('notifications.could_not_find_milestone'), 'error');
return Frontcontroller::redirect(BASE_URL.'/tickets/roadmap/');
}
// Ensure this ticket belongs to the current project
if (session('currentProject') != $milestone->projectId) {
$this->projectService->changeCurrentSessionProject($milestone->projectId);
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$milestone->id);
}
$comments = $this->commentsService->getComments('ticket', $params['id']);
} else {
$milestone = $this->ticketService->getNewMilestone();
$comments = [];
}
$allAssignedprojects = $this->projectService->getProjectsAssignedToUser(session('userdata.id'), 'open');
$this->tpl->assign('allAssignedprojects', $allAssignedprojects);
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('comments', $comments);
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('users', $this->projectService->getUsersWithAccessToProject((int) session('currentProject')));
$this->tpl->assign('milestone', $milestone);
return $this->tpl->displayPartial('tickets.milestoneDialog');
}
/**
* post - handle post requests
*/
public function post($params)
{
// If ID is set its an update
if (isset($_GET['id']) && (int) $_GET['id'] > 0) {
$params['id'] = (int) $_GET['id'];
if (isset($params['comment']) === true) {
$milestone = $this->ticketService->getMilestone($params['id']);
if ($this->ticketService->addMilestoneComment($params, $milestone)) {
$this->tpl->setNotification($this->language->__('notifications.comment_added_successfully'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.problem_saving_your_comment'), 'error');
}
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$params['id']);
}
if (isset($params['headline']) === true) {
if ($this->ticketService->updateMilestoneFromDialog($params)) {
$this->tpl->setNotification($this->language->__('notification.milestone_edited_successfully'), 'success');
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$params['id'].'?closeModal=1');
}
$this->tpl->setNotification($this->language->__('notification.saving_milestone_error'), 'error');
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$params['id']);
}
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$params['id']);
}
$result = $this->ticketService->createMilestoneFromDialog($params);
if (is_numeric($result)) {
$this->tpl->setNotification($this->language->__('notification.milestone_created_successfully'), 'success');
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/'.$result.'?closeModal=1');
}
$this->tpl->setNotification($this->language->__('notification.saving_milestone_error'), 'error');
return Frontcontroller::redirect(BASE_URL.'/tickets/editMilestone/');
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class MoveTicket extends Controller
{
private TicketService $ticketService;
private ProjectService $projectService;
public function init(
TicketService $ticketService,
ProjectService $projectService
): void {
$this->ticketService = $ticketService;
$this->projectService = $projectService;
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::EDIT)]
public function get($params): Response
{
$ticketId = $params['id'] ?? '';
$ticket = $this->ticketService->getTicket($ticketId);
if (! $ticket) {
return $this->tpl->displayPartial('errors.error404', responseCode: 404);
}
$projects = $this->projectService->getProjectsAssignedToUser(session('userdata.id'));
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('projects', $projects);
return $this->tpl->displayPartial('tickets.moveTicket');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::EDIT)]
public function post($params): Response
{
if (! empty($ticketId = (int) $_GET['id']) && ! empty($projectId = (int) $params['projectId'])) {
if ($this->ticketService->moveTicket($ticketId, $projectId)) {
$this->tpl->setNotification($this->language->__('text.ticket_moved'), 'success');
} else {
$this->tpl->setNotification($this->language->__('text.move_problem'), 'error');
}
}
return FrontcontrollerCore::redirect(BASE_URL.'/tickets/moveTicket/'.$ticketId.'?closeModal=true');
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Leantime\Domain\Tickets\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\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Models\Tickets as TicketModel;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class NewTicket extends Controller
{
private ProjectService $projectService;
private TicketService $ticketService;
private SprintService $sprintService;
private TimesheetService $timesheetService;
private UserService $userService;
public function init(
ProjectService $projectService,
TicketService $ticketService,
SprintService $sprintService,
TimesheetService $timesheetService,
UserService $userService
): void {
$this->projectService = $projectService;
$this->ticketService = $ticketService;
$this->sprintService = $sprintService;
$this->timesheetService = $timesheetService;
$this->userService = $userService;
if (! session()->exists('lastPage')) {
session(['lastPage' => BASE_URL.'/tickets/showKanban/']);
}
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::CREATE)]
public function get(): Response
{
$ticket = app()->make(TicketModel::class, [
'values' => [
'userLastname' => session('userdata.name'),
'status' => 3,
'projectId' => session('currentProject'),
'sprint' => session('currentSprint') ?? '',
'editorId' => session('userdata.id'),
],
]);
$ticket->date = dtHelper()->userNow();
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketParents', $this->ticketService->getAllPossibleParents($ticket));
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('ticketTypes', $this->ticketService->getTicketTypes());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('sprints', $this->sprintService->getAllSprints(session('currentProject')));
$this->tpl->assign('kind', $this->timesheetService->getLoggableHourTypes());
$this->tpl->assign('ticketHours', 0);
$this->tpl->assign('userHours', 0);
$this->tpl->assign('timesheetsAllHours', 0);
$this->tpl->assign('remainingHours', 0);
$this->tpl->assign('userInfo', $this->userService->getUser(session('userdata.id')));
$this->tpl->assign('users', $this->projectService->getUsersWithAccessToProject((int) session('currentProject')));
$allAssignedprojects = $this->projectService->getProjectsUserHasAccessTo(session('userdata.id'));
$this->tpl->assign('allAssignedprojects', $allAssignedprojects);
return $this->tpl->displayPartial('tickets.newTicketModal');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(TicketsPermissions::CREATE)]
public function post($params): Response
{
if (isset($params['saveTicket']) || isset($params['saveAndCloseTicket'])) {
$params['timeToFinish'] = format(value: $params['timeToFinish'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$params['timeFrom'] = format(value: $params['timeFrom'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$params['timeTo'] = format(value: $params['timeTo'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$result = $this->ticketService->addTicket($params);
if (is_array($result) === false) {
$this->tpl->setNotification($this->language->__('notifications.ticket_saved'), 'success');
if (isset($params['saveAndCloseTicket']) === true && $params['saveAndCloseTicket'] == 1) {
return Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$result.'?closeModal=1');
} else {
return Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$result);
}
} else {
$this->tpl->setNotification($this->language->__($result['msg']), 'error');
$ticket = app()->makeWith(TicketModel::class, ['values' => $params]);
$ticket->userLastname = session('userdata.name');
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketParents', $this->ticketService->getAllPossibleParents($ticket));
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('ticketTypes', $this->ticketService->getTicketTypes());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$this->tpl->assign('milestones', $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]));
$this->tpl->assign('sprints', $this->sprintService->getAllSprints(session('currentProject')));
$this->tpl->assign('kind', $this->timesheetService->getLoggableHourTypes());
$this->tpl->assign('ticketHours', 0);
$this->tpl->assign('userHours', 0);
$this->tpl->assign('timesheetsAllHours', 0);
$this->tpl->assign('remainingHours', 0);
$this->tpl->assign('userInfo', $this->userService->getUser(session('userdata.id')));
$this->tpl->assign('users', $this->projectService->getUsersWithAccessToProject((int) session('currentProject')));
$allAssignedprojects = $this->projectService->getProjectsUserHasAccessTo(session('userdata.id'));
$this->tpl->assign('allAssignedprojects', $allAssignedprojects);
return $this->tpl->displayPartial('tickets.newTicketModal');
}
}
return Frontcontroller::redirect(BASE_URL.'/tickets/newTicket');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
class Roadmap extends Controller
{
private TicketService $ticketService;
/**
* init - initialize private variables
*/
public function init(
TicketService $ticketService
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastMilestoneView' => 'timeline']);
session(['lastFilterdMilestonesView' => CURRENT_URL]);
}
/**
* get - handle get requests
*/
public function get($params)
{
$params = $this->ticketService->normalizeRoadmapParams($params);
// Sets the filter module to show a quick toggle for task types
$this->tpl->assign('enableTaskTypeToggle', true);
$this->tpl->assign('showTasks', $params['showTasks'] ?? 'false');
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
$allProjectMilestones = $this->ticketService->getAllMilestones($template_assignments['searchCriteria'], 'standard');
$allProjectMilestones = $this->ticketService->getBulkMilestoneProgress($allProjectMilestones);
$this->tpl->assign('timelineTasks', $allProjectMilestones);
return $this->tpl->display('tickets.roadmap');
}
/**
* post - handle post requests
*/
public function post($params)
{
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
$allProjectMilestones = $this->ticketService->getAllMilestones($template_assignments['searchCriteria']);
$this->tpl->assign('timelineTasks', $allProjectMilestones);
return $this->tpl->display('tickets.roadmap');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
class RoadmapAll extends Controller
{
private TicketService $ticketService;
private ClientService $clientService;
/**
* init - initialize private variables
*/
public function init(
ClientService $clientService,
TicketService $ticketService
): void {
$this->clientService = $clientService;
$this->ticketService = $ticketService;
}
/**
* get - handle get requests
*/
public function get($params)
{
$clientId = 0;
$currentClientName = '';
if (isset($_GET['client']) === true && $_GET['client'] != '') {
$clientId = (int) $_GET['client'];
$currentClientName = $this->ticketService->getClientNameById($clientId);
}
$allProjectMilestones = $this->ticketService->getAllMilestonesOverview(false, 'date', false, $clientId);
$allClients = $this->clientService->getUserClients(session('userdata.id'));
$this->tpl->assign('currentClientName', $currentClientName);
$this->tpl->assign('currentClient', $clientId);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('clients', $allClients);
return $this->tpl->display('tickets.roadmapAll');
}
/**
* post - handle post requests
*/
public function post($params)
{
$allProjectMilestones = $this->ticketService->getAllMilestonesOverview();
$this->tpl->assign('milestones', $allProjectMilestones);
return $this->tpl->display('tickets.roadmapAll');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class ShowAll extends Controller
{
private TicketService $ticketService;
public function init(
TicketService $ticketService,
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastTicketView' => 'table']);
session(['lastFilterdTicketTableView' => CURRENT_URL]);
if (! session()->exists('currentProjectName')) {
Frontcontroller::redirect(BASE_URL.'/');
}
}
/**
* @throws \Exception
*/
public function get($params): Response
{
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
return $this->tpl->display('tickets.showAll');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class ShowAllMilestones extends Controller
{
private TicketService $ticketService;
public function init(
TicketService $ticketService
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastMilestoneView' => 'milestonetable']);
session(['lastFilterdMilestoneView' => CURRENT_URL]);
}
/**
* @throws \Exception
*/
public function get($params): Response
{
$params = $this->ticketService->normalizeRoadmapParams($params);
// Sets the filter module to show a quick toggle for task types
$this->tpl->assign('enableTaskTypeToggle', true);
$this->tpl->assign('showTasks', $params['showTasks'] ?? 'false');
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
return $this->tpl->display('tickets.showAllMilestones');
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class ShowAllMilestonesOverview extends Controller
{
private TicketService $ticketService;
private UserService $userService;
private ClientService $clientService;
public function init(
TicketService $ticketService,
UserService $userService,
ClientService $clientService
): void {
$this->ticketService = $ticketService;
$this->userService = $userService;
$this->clientService = $clientService;
session(['lastPage' => CURRENT_URL]);
}
/**
* @throws \Exception
*/
public function get($params): Response
{
$clientId = 0;
$currentClientName = '';
if (isset($_GET['client']) === true && $_GET['client'] != '') {
$clientId = (int) $_GET['client'];
$currentClientName = $this->ticketService->getClientNameById($clientId);
}
$searchCriteria = $this->ticketService->getMilestonesOverviewSearchCriteria($params);
$this->tpl->assign('allTickets', $this->ticketService->getAllMilestonesOverview(false, 'duedate', false, $clientId, $searchCriteria));
$this->tpl->assign('allTicketStates', $this->ticketService->getStatusLabels());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$this->tpl->assign('ticketTypeIcons', $this->ticketService->getTypeIcons());
$this->tpl->assign('searchCriteria', $searchCriteria);
$this->tpl->assign('numOfFilters', $this->ticketService->countSetFilters($searchCriteria));
$allClients = $this->clientService->getUserClients(session('userdata.id'));
$this->tpl->assign('clients', $allClients);
$this->tpl->assign('currentClientName', $currentClientName);
$this->tpl->assign('currentClient', $clientId);
$this->tpl->assign('users', $this->userService->getAll());
$this->tpl->assign('milestones', $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]));
$this->tpl->assign('types', $this->ticketService->getTicketTypes());
return $this->tpl->display('tickets.showAllMilestonesOverview');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class ShowKanban extends Controller
{
private TicketService $ticketService;
public function init(
TicketService $ticketService
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastTicketView' => 'kanban']);
session(['lastFilterdTicketKanbanView' => CURRENT_URL]);
}
/**
* @throws \Exception
*/
public function get(array $params): Response
{
// Status groupBy is redundant on Kanban (status already shown as columns)
// Auto-reset to "all" (no grouping) for cleaner default view
if (isset($params['groupBy']) && $params['groupBy'] === 'status') {
$params['groupBy'] = 'all';
}
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
$allKanbanColumns = $this->ticketService->getKanbanColumns();
// NEW: Calculate status breakdown for swimlane visualizations
$statusBreakdown = $this->ticketService->getStatusBreakdownBySwimlane(
$template_assignments['allTickets'],
$allKanbanColumns
);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
$this->tpl->assign('allKanbanColumns', $allKanbanColumns);
$this->tpl->assign('statusBreakdown', $statusBreakdown);
return $this->tpl->display('tickets.showKanban');
}
/**
* @throws BindingResolutionException
*/
public function post(array $params): Response
{
// QuickAdd
if (isset($_POST['quickadd']) && $_POST['quickadd'] == 1) {
$formParams = [
'headline' => $_POST['headline'] ?? '',
'status' => $_POST['status'] ?? '',
'milestone' => $_POST['milestone'] ?? '',
'sprint' => $_POST['sprint'] ?? '',
'projectId' => session('currentProject'),
'editorId' => session('userdata.id'),
];
$swimlaneValue = $_POST['swimlane'] ?? null;
$groupBy = $_POST['groupBy'] ?? null;
$stayOpen = isset($_POST['stay_open']) && $_POST['stay_open'] === '1';
$result = $this->ticketService->quickAddTicketFromKanban($formParams, $swimlaneValue, $groupBy, $stayOpen);
if ($result['success'] === false) {
$this->tpl->setNotification($result['message'], 'error');
} else {
$this->tpl->setNotification('Task created: '.htmlspecialchars($result['headline']), 'success');
}
return Frontcontroller::redirect(CURRENT_URL.'#status-'.$result['status']);
}
return Frontcontroller::redirect(CURRENT_URL);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
class ShowList extends Controller
{
private TicketService $ticketService;
public function init(
TicketService $ticketService
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastTicketView' => 'list']);
session(['lastFilterdTicketListView' => CURRENT_URL]);
}
/**
* @throws \Exception
*/
public function get($params): Response
{
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
return $this->tpl->display('tickets.showList');
}
/**
* @throws BindingResolutionException
*/
public function post(array $params): Response
{
// QuickAdd
if (isset($_POST['quickadd'])) {
$formParams = [
'headline' => $_POST['headline'] ?? '',
'milestone' => $_POST['milestone'] ?? '',
'sprint' => $_POST['sprint'] ?? '',
'projectId' => session('currentProject'),
'editorId' => session('userdata.id'),
];
$result = $this->ticketService->quickAddTicket($formParams);
if (is_array($result)) {
$this->tpl->setNotification($result['message'], $result['status']);
}
}
return Frontcontroller::redirect(CURRENT_URL);
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* showAll Class - show My Calender
*/
namespace Leantime\Domain\Tickets\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
class ShowProjectCalendar extends Controller
{
private TicketService $ticketService;
/**
* init - initialize private variables
*/
public function init(
TicketService $ticketService
): void {
$this->ticketService = $ticketService;
session(['lastPage' => CURRENT_URL]);
session(['lastMilestoneView' => 'calendar']);
}
/**
* get - handle get requests
*/
public function get($params)
{
$template_assignments = $this->ticketService->getTicketTemplateAssignments($params);
array_map([$this->tpl, 'assign'], array_keys($template_assignments), array_values($template_assignments));
$allProjectMilestones = $this->ticketService->getAllMilestones($template_assignments['searchCriteria']);
$this->tpl->assign('milestones', $allProjectMilestones);
return $this->tpl->display('tickets.calendar');
}
/**
* post - handle post requests
*/
public function post($params)
{
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]);
$this->tpl->assign('milestones', $allProjectMilestones);
return $this->tpl->display('tickets.roadmap');
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Carbon\Carbon;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Support\FromFormat;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Files\Services\Files as FileService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Tickets\Services\TicketResource as TicketResourceService;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class ShowTicket extends Controller
{
private ProjectService $projectService;
private TicketService $ticketService;
private SprintService $sprintService;
private FileService $fileService;
private CommentService $commentService;
private TimesheetService $timesheetService;
private UserService $userService;
private TicketResourceService $ticketResourceService;
public function init(
ProjectService $projectService,
TicketService $ticketService,
SprintService $sprintService,
FileService $fileService,
CommentService $commentService,
TimesheetService $timesheetService,
UserService $userService,
TicketResourceService $ticketResourceService
): void {
$this->projectService = $projectService;
$this->ticketService = $ticketService;
$this->sprintService = $sprintService;
$this->fileService = $fileService;
$this->commentService = $commentService;
$this->timesheetService = $timesheetService;
$this->userService = $userService;
$this->ticketResourceService = $ticketResourceService;
if (session()->exists('lastPage') === false) {
session(['lastPage' => BASE_URL.'/tickets/showKanban']);
}
}
/**
* @throws BindingResolutionException
*/
public function get($params): Response
{
if (! isset($params['id'])) {
return $this->tpl->displayPartial('errors.error400', responseCode: 400);
}
$id = (int) ($params['id']);
$ticket = $this->ticketService->getTicket($id);
if ($ticket === false) {
return $this->tpl->display('errors.error500', responseCode: 500);
}
// Ensure this ticket belongs to the current project
if (session('currentProject') != $ticket->projectId) {
$this->projectService->changeCurrentSessionProject($ticket->projectId);
return Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$id);
}
// Delete file
if (isset($params['delFile']) === true) {
if ($result = $this->fileService->deleteFile($params['delFile'])) {
$this->tpl->setNotification($this->language->__('notifications.file_deleted'), 'success');
return Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$id.'#files');
}
$this->tpl->setNotification($this->language->__('notifications.file_deleted_error'), 'error');
}
// Delete comment
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
if ($this->commentService->deleteComment($commentId)) {
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
$response = Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$id);
$response->headers->set('HX-Trigger', 'ticketUpdate');
return $response;
}
$this->tpl->setNotification($this->language->__('notifications.comment_deleted_error'), 'error');
}
// Delete Subtask
if (isset($params['delSubtask']) === true) {
}
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketParents', $this->ticketService->getAllPossibleParents($ticket));
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('ticketTypes', $this->ticketService->getTicketTypes());
$this->tpl->assign('ticketTypeIcons', $this->ticketService->getTypeIcons());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('sprints', $this->sprintService->getAllSprints(session('currentProject')));
$this->tpl->assign('kind', $this->timesheetService->getLoggableHourTypes());
$this->tpl->assign('ticketHours', $this->timesheetService->getLoggedHoursForTicketByDate($id));
$this->tpl->assign('userHours', $this->timesheetService->getUsersTicketHours($id, session('userdata.id')));
$this->tpl->assign('timesheetsAllHours', $this->timesheetService->getSumLoggedHoursForTicket($id));
$this->tpl->assign('remainingHours', $this->timesheetService->getRemainingHours($ticket));
$this->tpl->assign('userInfo', $this->userService->getUser(session('userdata.id')));
$this->tpl->assign('users', $this->projectService->getUsersWithAccessToProject((int) $ticket->projectId));
$projectData = $this->projectService->getProject($ticket->projectId);
$this->tpl->assign('projectData', $projectData);
$comments = $this->commentService->getComments('ticket', $id);
$this->tpl->assign('numComments', count($comments));
$this->tpl->assign('comments', $comments);
$files = $this->fileService->getFilesByModule('ticket', $id);
$this->tpl->assign('numFiles', count($files));
$this->tpl->assign('files', $files);
$this->tpl->assign('onTheClock', $this->timesheetService->isClocked(session('userdata.id')));
$this->tpl->assign('timesheetValues', [
'kind' => '',
'date' => Carbon::now(session('usersettings.timezone'))->setTimezone('UTC'),
'hours' => '',
'description' => '',
]);
// TODO: Refactor thumbnail generation in file manager
$this->tpl->assign('imgExtensions', ['jpg', 'jpeg', 'png', 'gif', 'psd', 'bmp', 'tif', 'thm', 'yuv']);
$allAssignedprojects = $this->projectService->getProjectsUserHasAccessTo(session('userdata.id'));
$this->tpl->assign('allAssignedprojects', $allAssignedprojects);
// 关联资源BOM/工艺文件/工具清单/文件/wiki
$this->tpl->assign('linkedResources', $this->ticketResourceService->getLinkedResources($id));
$this->tpl->assign('resourceCandidates', $this->ticketResourceService->listCandidates((int) $ticket->projectId));
$response = $this->tpl->displayPartial('tickets.showTicketModal');
$response->headers->set('HX-Trigger', 'ticketUpdate');
return $response;
}
/**
* @throws BindingResolutionException
*/
public function post($params): Response
{
if (! isset($_GET['id'])) {
return $this->tpl->display('errors.error400', responseCode: 400);
}
$tab = '';
$id = (int) ($_GET['id']);
$ticket = $this->ticketService->getTicket($id);
if ($ticket === false) {
return $this->tpl->display('errors.error500', responseCode: 500);
}
// Upload File
if (isset($params['upload'])) {
if ($this->fileService->upload($_FILES, 'ticket', $id, $ticket)) {
$this->tpl->setNotification($this->language->__('notifications.file_upload_success'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.file_upload_error'), 'error');
}
$tab = '#files';
}
// Add or edit a comment
if (isset($params['comment']) === true && isset($params['text']) && $params['text'] != '' && isset($params['edit-comment-helper']) && $params['edit-comment-helper'] !== '') {
if ($this->commentService->editComment($_POST, (int) $params['edit-comment-helper'])) {
$this->tpl->setNotification($this->language->__('notifications.comment_edited_success'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_edit_error'), 'error');
}
$tab = '#comment';
} elseif (isset($params['comment']) === true && isset($params['text']) && $params['text'] != '') {
if ($this->commentService->addComment($_POST, 'ticket', $id, $ticket)) {
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_create_error'), 'error');
}
$tab = '#comment';
}
// Log time
if (isset($params['saveTimes']) === true) {
try {
$result = $this->timesheetService->logTime($id, $params);
$this->tpl->setNotification($this->language->__('notifications.time_logged_success'), 'success');
} catch (\Exception $e) {
$this->tpl->setNotification($e->getMessage(), 'error');
}
}
// Save Ticket
if (isset($params['saveTicket']) === true || isset($params['saveAndCloseTicket']) === true) {
// $params['projectId'] = $ticket->projectId;
$params['id'] = $id;
// Prepare values, time comes in as 24hours from time input. Service expects time to be in local user format
$params['timeToFinish'] = format(value: $params['timeToFinish'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$params['timeFrom'] = format(value: $params['timeFrom'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$params['timeTo'] = format(value: $params['timeTo'] ?? '', fromFormat: FromFormat::User24hTime)->userTime24toUserTime();
$result = $this->ticketService->updateTicket($params);
if ($result === true) {
$this->tpl->setNotification($this->language->__('notifications.ticket_saved'), 'success');
} else {
$this->tpl->setNotification($this->language->__($result['msg']), 'error');
}
if (isset($params['saveAndCloseTicket']) === true && $params['saveAndCloseTicket'] == 1) {
$response = Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$id.'?closeModal=1');
$response->headers->set('HX-Trigger', 'ticketUpdate');
return $response;
}
}
$response = Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$id.''.$tab);
$response->headers->set('HX-Trigger', 'ticketUpdate');
return $response;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Leantime\Domain\Tickets\Controllers;
use Illuminate\Http\Request;
use Leantime\Domain\Tickets\Services\TicketResource as TicketResourceService;
use Symfony\Component\HttpFoundation\Response;
/**
* 待办事项 ↔ 资源关联BOM/工艺文件/工具清单/文件/wikiJSON API。
* 每个动作都在 Service 层按 ticket 所属项目自鉴权。
*/
class TicketResourceApi
{
public function __construct(
private TicketResourceService $service,
) {}
/**
* 关联一个资源POST /tickets/resource-api/{ticketId}/link
*/
public function link(int $ticketId, Request $request): Response
{
$type = (string) $request->input('type', '');
$resourceId = (int) $request->input('resourceId', 0);
$result = $this->service->link($ticketId, $type, $resourceId);
return $result['success']
? response()->json(['status' => 'success'])
: response()->json(['status' => 'error', 'message' => $result['message'] ?? '关联失败'], 400);
}
/**
* 解除一个资源关联DELETE /tickets/resource-api/{ticketId}/unlink/{type}/{resourceId}
*/
public function unlink(int $ticketId, string $type, int $resourceId): Response
{
$result = $this->service->unlink($ticketId, $type, $resourceId);
return $result['success']
? response()->json(['status' => 'success'])
: response()->json(['status' => 'error', 'message' => $result['message'] ?? '解除失败'], 400);
}
/**
* 候选资源列表GET /tickets/resource-api/{projectId}/candidates
*/
public function candidates(int $projectId): Response
{
return response()->json(['status' => 'success', 'data' => $this->service->listCandidates($projectId)]);
}
/**
* 已关联资源GET /tickets/resource-api/{ticketId}/links
*/
public function links(int $ticketId): Response
{
return response()->json(['status' => 'success', 'data' => $this->service->getLinkedResources($ticketId)]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired when a milestone is created.
*/
final class MilestoneCreated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int|null $milestoneId The created milestone id; null when the emit site doesn't capture it.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly ?int $milestoneId = null,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.milestone_created'];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a milestone was deleted.
*/
final class MilestoneDeleted implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int $milestoneId The deleted milestone id.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly int $milestoneId,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.milestone_deleted'];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a milestone was updated.
*/
final class MilestoneUpdated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int $milestoneId The updated milestone id.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly int $milestoneId,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.milestone_updated'];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a project's ticket status labels were saved.
*/
final class StatusLabelsUpdated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int|null $projectId The project whose status labels changed.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly ?int $projectId = null,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.statusLabels_updated'];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a ticket (including subtasks) was created.
*/
final class TicketCreated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int|null $ticketId The created ticket id; null when the emit site doesn't capture it.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly ?int $ticketId = null,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.ticket_created'];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a ticket was deleted.
*/
final class TicketDeleted implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int $ticketId The deleted ticket id.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly int $ticketId,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.ticket_deleted'];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithFilters;
use Leantime\Core\Events\Contracts\LeantimeFilter;
/**
* Filters the grouped ticket list before it is handed to the list/table templates.
* Listeners receive the grouped tickets array and must return it (possibly modified).
*/
final class TicketListFilter implements LeantimeFilter
{
use InteractsWithFilters;
/**
* @param array $tickets The grouped tickets to filter.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site ran under for plugin listeners.
*/
public function __construct(
public array $tickets,
private readonly ?string $legacyHook = null,
) {}
/**
* The grouped tickets array threaded through the pipeline.
*/
public function payload(): mixed
{
return $this->tickets;
}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.filterTickets'];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired from the repository when a ticket's status column changes (kanban moves,
* inline patches). Carries the legacy payload keys (ticketId/status/action/handler)
* that existing plugin listeners read.
*
* The legacy bridge emits a SUPERSET of each historical payload: the patchTicket site
* historically passed no `handler` key, so its bridged payload now also carries
* `handler => null`. This is additive and safe — consumers read keys by name
* (`$payload['handler'] ?? …`), never by exact key-set/count — and the kanban
* (updateTicketStatus) site, the only live consumer, always carried `handler`.
*/
final class TicketStatusUpdated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* Legacy payload discriminator kept for plugin listeners that switch on it.
*/
public string $action = 'ticketStatusUpdate';
/**
* @param int $ticketId The ticket whose status changed.
* @param mixed $status The new status value.
* @param mixed $handler Optional handler context passed by kanban updates.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly int $ticketId,
public readonly mixed $status,
public readonly mixed $handler = null,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.repositories.tickets.'.$this->legacyHook.'.ticketStatusUpdate'];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a ticket (including subtasks) was updated.
*/
final class TicketUpdated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int|null $ticketId The updated ticket id; null for bulk updates (sorting,
* kanban status+sorting) where no single id applies.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site fired under for plugin listeners.
*/
public function __construct(
public readonly ?int $ticketId = null,
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.ticket_updated'];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Domain\Tickets\Events;
use Leantime\Core\Events\Concerns\InteractsWithFilters;
use Leantime\Core\Events\Contracts\LeantimeFilter;
/**
* Filters the current user's tasks before the My-ToDos widget renders them.
* Listeners receive the grouped tickets array and must return it (possibly modified).
*/
final class TodoWidgetTasksFilter implements LeantimeFilter
{
use InteractsWithFilters;
/**
* @param array $tickets The grouped widget tickets to filter.
* @param bool $hierarchical True when tickets are grouped hierarchically
* (milestone/parent nesting) instead of flat groups.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the exact historical
* string name this site ran under for plugin listeners.
*/
public function __construct(
public array $tickets,
public readonly bool $hierarchical = false,
private readonly ?string $legacyHook = null,
) {}
/**
* The grouped tickets array threaded through the pipeline.
*/
public function payload(): mixed
{
return $this->tickets;
}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.tickets.services.tickets.'.$this->legacyHook.'.myTodoWidgetTasks'];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Leantime\Domain\Tickets\Htmx;
use Leantime\Core\Events\Htmx\HtmxEvent;
use Leantime\Core\Events\Htmx\InteractsWithHtmxEvents;
/**
* Client (HTMX) data events for the Tickets domain.
*
* Naming follows the lt:{domain}:{entity}.{verb} convention. Legacy values ('ticket_update',
* 'subtasks_update', 'subtasksUpdated') are dual-emitted via {@see \Leantime\Core\Events\Htmx\HtmxEvents}
* during the migration window so existing listeners keep working.
*/
enum HtmxTicketEvents: string implements HtmxEvent
{
use InteractsWithHtmxEvents;
/** One or more tickets have been updated. */
case UPDATE = 'lt:tickets:ticket.updated';
/** A ticket's subtasks have changed. */
case SUBTASK_UPDATE = 'lt:tickets:subtask.updated';
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Leantime\Domain\Tickets\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Tickets\Services\Tickets;
class Milestones extends HtmxController
{
protected static string $view = 'tickets::partials.milestoneCard';
private Tickets $ticketService;
/**
* Controller constructor
*/
public function init(Tickets $ticketService): void
{
$this->ticketService = $ticketService;
}
public function progress()
{
$getParams = $_GET;
$milestone = $this->ticketService->getTicket($getParams['milestoneId']);
$percentDone = $this->ticketService->getMilestoneProgress($getParams['milestoneId']);
$this->tpl->assign('progressColor', $getParams['progressColor'] ?? 'default');
$this->tpl->assign('noText', $getParams['noText'] ?? false);
$this->tpl->assign('milestone', $milestone);
$this->tpl->assign('percentDone', $percentDone);
return 'progress';
}
public function showCard()
{
$getParams = $_GET;
$milestone = $this->ticketService->getTicket($getParams['milestoneId']);
$percentDone = $this->ticketService->getMilestoneProgress($getParams['milestoneId']);
$this->tpl->assign('percentDone', $percentDone);
$this->tpl->assign('milestone', $milestone);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Leantime\Domain\Tickets\Hxcontrollers;
use Leantime\Core\Controller\HxComponent;
use Leantime\Core\Events\Htmx\HtmxEvent;
use Leantime\Domain\Tickets\Htmx\HtmxTicketEvents;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Subtasks list component for a ticket.
*
* Mounted with <x-global::hx :for="self::class" :id="$ticketId" />. It both emits and listens for
* {@see HtmxTicketEvents::SUBTASK_UPDATE} so that a subtask change made here also refreshes other
* components showing the same data (e.g. the dashboard to-do widget) and vice-versa — the emit and
* listen sides reference the same enum case, so they cannot drift apart.
*/
class Subtasks extends HxComponent
{
protected static string $view = 'tickets::partials.subtasks';
/** Refresh swaps the inner content so the mount wrapper keeps its id + event triggers. */
public static string $swap = 'innerHTML';
private Tickets $ticketService;
public function init(Tickets $ticketService): void
{
$this->ticketService = $ticketService;
}
public static function route(): string
{
return 'tickets/subtasks';
}
/**
* @return array<int, HtmxEvent>
*/
public static function listensTo(): array
{
return [HtmxTicketEvents::SUBTASK_UPDATE];
}
/**
* @return array<int, HtmxEvent>
*/
public static function emits(): array
{
return [HtmxTicketEvents::SUBTASK_UPDATE];
}
public function save(): void
{
$getParams = $_GET;
$params = $_POST;
$ticket = $this->ticketService->getTicket($getParams['ticketId']);
if ($this->ticketService->upsertSubtask($params, $ticket)) {
$this->tpl->setNotification($this->language->__('notifications.subtask_saved'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.subtask_save_error'), 'error');
}
// Announce the change. Emit the broad event (for widgets listening to all tickets, e.g. the
// dashboard to-do widget) AND the entity-scoped event (for the ticket modal's mount, which
// listens via <x-global::hx :id> for "<event>#<ticketId>").
$this->tpl->emit(HtmxTicketEvents::SUBTASK_UPDATE, HtmxTicketEvents::SUBTASK_UPDATE->scoped($ticket->id));
$ticketSubtasks = $this->ticketService->getAllSubtasks($ticket->id);
$statusLabels = $this->ticketService->getStatusLabels(session('currentProject'));
$efforts = $this->ticketService->getEffortLabels();
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketSubtasks', $ticketSubtasks);
$this->tpl->assign('statusLabels', $statusLabels);
$this->tpl->assign('efforts', $efforts);
}
public function get(): void
{
if (! $this->incomingRequest->getMethod() == 'GET') {
throw new \Exception('This endpoint only supports GET requests');
}
$getVars = $_GET;
// Accept the ticket id from the path (contract-driven mount → query['id']) or the legacy
// ?ticketId= query used by the in-partial forms.
$id = $getVars['ticketId'] ?? $getVars['id'] ?? null;
$ticket = $this->ticketService->getTicket($id);
$ticketSubtasks = $this->ticketService->getAllSubtasks((int) $id);
$statusLabels = $this->ticketService->getStatusLabels(session('currentProject'));
$efforts = $this->ticketService->getEffortLabels();
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketSubtasks', $ticketSubtasks);
$this->tpl->assign('statusLabels', $statusLabels);
$this->tpl->assign('efforts', $efforts);
}
public function delete()
{
$getVars = $_GET;
$id = $getVars['ticketId'];
$parentId = $getVars['parentTicket'];
if ($this->ticketService->delete($id)) {
$this->tpl->setNotification($this->language->__('notifications.subtask_deleted'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.subtask_delete_error'), 'error');
}
// Announce the change — broad event for all-ticket listeners + the parent-scoped event for
// the ticket modal's mount (which listens for "<event>#<parentTicketId>").
$this->tpl->emit(HtmxTicketEvents::SUBTASK_UPDATE, HtmxTicketEvents::SUBTASK_UPDATE->scoped($parentId));
$ticket = $this->ticketService->getTicket($parentId);
$ticketSubtasks = $this->ticketService->getAllSubtasks($parentId);
$statusLabels = $this->ticketService->getStatusLabels(session('currentProject'));
$efforts = $this->ticketService->getEffortLabels();
$this->tpl->assign('ticket', $ticket);
$this->tpl->assign('ticketSubtasks', $ticketSubtasks);
$this->tpl->assign('statusLabels', $statusLabels);
$this->tpl->assign('efforts', $efforts);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Leantime\Domain\Tickets\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Tickets\Services\Tickets;
use Leantime\Domain\Timesheets\Services\Timesheets;
class TicketCard extends HtmxController
{
protected static string $view = 'tickets::partials.ticketCard';
private Tickets $ticketService;
private Timesheets $timesheetService;
/**
* Controller constructor
*/
public function init(Tickets $ticketService, Timesheets $timesheetService): void
{
$this->ticketService = $ticketService;
$this->timesheetService = $timesheetService;
}
public function save(): void
{
$postParams = $_POST;
$id = $postParams['id'];
$ticket = $this->ticketService->getTicket($id);
$values = $ticket;
// Until we have everything as objects we'll need to use arrays
$this->tpl->assign('row', (array) $ticket);
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$this->tpl->assign('milestones', $allProjectMilestones);
}
public function get($params): void
{
$id = $params['id'] ?? $params['ticketId'] ?? null;
$cardType = $params['type'] ?? 'full';
$ticket = $this->ticketService->getTicket($id);
$onTheClock = $this->timesheetService->isClocked(session('userdata.id'));
$this->tpl->assign('cardType', $cardType);
$this->tpl->assign('onTheClock', $onTheClock);
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => $ticket->projectId,
]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('row', (array) $ticket);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Leantime\Domain\Tickets\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Timesheets\Services\Timesheets;
class TimerButton extends HtmxController
{
protected static string $view = 'tickets::partials.timerButton';
private Timesheets $timesheetService;
/**
* Controller constructor
*/
public function init(Timesheets $timesheetService): void
{
$this->timesheetService = $timesheetService;
}
public function getStatus(): void
{
$params = $this->incomingRequest->query->all();
$onTheClock = session()->exists('userdata') ? $this->timesheetService->isClocked(session('userdata.id')) : false;
$this->tpl->assign('onTheClock', $onTheClock);
$this->tpl->assign('parentTicketId', $params['request_parts'] ?? false);
}
public function getStatusButton(): void
{
$this->getStatus();
$this->tpl->displayPartial('tickets::partials.timerButton');
}
}

View File

@@ -0,0 +1,420 @@
leantime.kanbanController = (function () {
/**
* Toggle quick-add form visibility
* @param {HTMLElement} triggerElement - The link element that triggered the toggle
*/
var toggleQuickAdd = function(triggerElement) {
var container = triggerElement.closest('.quickaddContainer');
var form = container.querySelector('[data-quickadd-form]');
var input = form.querySelector('[data-quickadd-input]');
var link = container.querySelector('.quickAddLink');
var isVisible = form.classList.contains('active');
if (isVisible) {
// Hide form, show link
form.classList.remove('active');
form.style.display = 'none';
form.dataset.submitting = 'false';
link.style.display = '';
link.setAttribute('aria-expanded', 'false');
} else {
// Show form, hide link
form.classList.add('active');
form.style.display = 'block';
form.dataset.submitting = 'false';
link.style.display = 'none';
link.setAttribute('aria-expanded', 'true');
input.focus();
}
};
/**
* Initialize keyboard interactions for quick-add forms
* - Enter: Save and stay open
* - Shift+Enter: Save and close
* - Escape: Cancel
*/
var initQuickAddKeyboard = function() {
document.addEventListener('keydown', function(e) {
var input = e.target;
if (!input.matches('[data-quickadd-input]')) return;
var form = input.closest('[data-quickadd-form]');
// Prevent multiple submissions
if (form.dataset.submitting === 'true') {
e.preventDefault();
return;
}
var stayOpenInput = form.querySelector('[data-stay-open-input]');
// Enter: Save and close
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.dataset.submitting = 'true';
stayOpenInput.value = '0';
form.submit();
}
// Shift+Enter: Save and stay open
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
form.dataset.submitting = 'true';
stayOpenInput.value = '1';
form.submit();
}
// Escape: Cancel
if (e.key === 'Escape') {
e.preventDefault();
var container = form.closest('.quickaddContainer');
var link = container.querySelector('.quickAddLink');
toggleQuickAdd(link);
input.value = '';
}
});
};
/**
* Initialize click outside behavior
* - If form has text: save and close
* - If form is empty: just close
*/
var initClickOutsideSave = function() {
document.addEventListener('click', function(e) {
var forms = document.querySelectorAll('[data-quickadd-form].active');
forms.forEach(function(form) {
var container = form.closest('.quickaddContainer');
var link = container.querySelector('.quickAddLink');
var input = form.querySelector('[data-quickadd-input]');
var isClickInside = form.contains(e.target) || link.contains(e.target);
// Prevent action if already submitting
if (form.dataset.submitting === 'true') {
return;
}
if (!isClickInside && input.value.trim() !== '') {
// Save if there's text
var stayOpenInput = form.querySelector('[data-stay-open-input]');
stayOpenInput.value = '0';
form.dataset.submitting = 'true';
form.submit();
} else if (!isClickInside) {
// Just close if empty
toggleQuickAdd(link);
}
});
});
};
/**
* Equalize column heights within a swimlane content area
* Sets all columns to the max height for consistent appearance
* @param {HTMLElement} contentElement - The swimlane content container
*/
var equalizeColumnHeights = function(contentElement) {
var columns = contentElement.querySelectorAll('.column .contentInner');
if (columns.length === 0) return;
// Reset ALL height styles first to get natural heights
// This includes height set by setUpKanbanColumns() in ticketsController
columns.forEach(function(col) {
col.style.minHeight = '';
col.style.height = '';
});
// Find max height from natural content
var maxHeight = 0;
columns.forEach(function(col) {
var height = col.offsetHeight;
if (height > maxHeight) {
maxHeight = height;
}
});
// Set all columns to max height using minHeight (allows growth if needed)
if (maxHeight > 0) {
columns.forEach(function(col) {
col.style.minHeight = maxHeight + 'px';
});
}
};
/**
* Reset column heights to natural height
* @param {HTMLElement} contentElement - The swimlane content container
*/
var resetColumnHeights = function(contentElement) {
var columns = contentElement.querySelectorAll('.column .contentInner');
columns.forEach(function(col) {
col.style.minHeight = '';
col.style.height = ''; // Clear height set by setUpKanbanColumns()
});
};
/**
* Initialize column heights for all expanded swimlanes on page load
* Uses requestAnimationFrame to ensure DOM is fully rendered before measuring
*/
var initExpandedColumnHeights = function() {
// Wait for next frame to ensure DOM is painted
requestAnimationFrame(function() {
// Double RAF for extra safety (after layout + paint)
requestAnimationFrame(function() {
var expandedContents = document.querySelectorAll('.kanban-swimlane-content:not(.collapsed)');
expandedContents.forEach(function(content) {
equalizeColumnHeights(content);
});
});
});
};
/**
* Toggle swimlane collapse/expand
* Two states only:
* - Expanded: full ticket cards
* - Collapsed: compact ticket cards
* @param {string} swimlaneId - Swimlane identifier
*/
var toggleSwimlane = function(swimlaneId) {
var row = document.getElementById('swimlane-row-' + swimlaneId);
var sidebar = document.querySelector('.kanban-swimlane-sidebar[data-swimlane-id="' + swimlaneId + '"]');
var content = document.getElementById('swimlane-content-' + swimlaneId);
if (!row || !sidebar || !content) {
console.error('Swimlane elements not found for ID:', swimlaneId);
return;
}
var isExpanded = sidebar.getAttribute('aria-expanded') === 'true';
var newExpanded = !isExpanded;
// Update aria-expanded on sidebar
sidebar.setAttribute('aria-expanded', newExpanded.toString());
// Update chevron icon
var chevronIcon = sidebar.querySelector('.kanban-lane-chevron i');
if (chevronIcon) {
chevronIcon.className = newExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right';
}
// Update data-expanded attribute on row (CSS uses this for styling)
row.setAttribute('data-expanded', newExpanded.toString());
// Toggle state on content area
if (newExpanded) {
content.classList.remove('collapsed');
// Equalize column heights so empty columns match tallest
equalizeColumnHeights(content);
} else {
content.classList.add('collapsed');
// Equalize column heights when collapsed - maintain visual alignment
equalizeColumnHeights(content);
}
// Persist state to session via JSON-RPC
leantime.rpc('Api.Api.setSubmenuState', {
submenu: 'swimlane_' + swimlaneId,
state: newExpanded ? 'open' : 'closed'
}).catch(function (error) {
console.error('Could not persist swimlane state', error);
});
};
/**
* Initialize keyboard support for swimlane headers
* - Enter/Space: Toggle swimlane
* - Arrow keys: Navigate between swimlanes
*/
var initSwimlaneKeyboard = function() {
document.addEventListener('keydown', function(e) {
var header = e.target.closest('[data-swimlane-id]');
if (!header) return;
var swimlaneId = header.getAttribute('data-swimlane-id');
// Enter or Space to toggle
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSwimlane(swimlaneId);
}
// Arrow Up/Down to navigate between swimlanes
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var allHeaders = Array.from(document.querySelectorAll('[data-swimlane-id]'));
var currentIndex = allHeaders.indexOf(header);
if (e.key === 'ArrowDown' && currentIndex < allHeaders.length - 1) {
allHeaders[currentIndex + 1].focus();
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
allHeaders[currentIndex - 1].focus();
}
}
});
};
/**
* Initialize Tippy.js tooltips for swimlane header elements
*/
var initProgressBarTooltips = function() {
if (typeof tippy === 'undefined') {
return;
}
var tippyConfig = {
allowHTML: true,
placement: 'top',
arrow: true,
theme: 'light-border',
trigger: 'mouseenter focus',
delay: [500, 0] // 500ms show delay, 0ms hide delay
};
// All swimlane header elements with tooltips
var tooltipSelectors = [
'.status-segment[data-tippy-content]', // Progress bar segments
'.time-indicator[data-tippy-content]', // Time indicators (overdue, due soon, stale)
'.kanban-lane-count[data-tippy-content]', // Count badges
'.tshirt-icon[data-tippy-content]', // Effort icons
'.thermometer-icon[data-tippy-content]', // Priority icons
'.user-avatar[data-tippy-content]', // User avatars
'.type-icon[data-tippy-content]', // Type icons
'.sprint-icon[data-tippy-content]', // Sprint icons
'.milestone-icon[data-tippy-content]', // Milestone icons
'.swimlane-header-label[data-tippy-content]' // Swimlane labels
];
tooltipSelectors.forEach(function(selector) {
var elements = document.querySelectorAll(selector);
if (elements.length > 0) {
tippy(elements, tippyConfig);
}
});
};
/**
* Initialize tap-to-reveal column counts on mobile/touch devices
* Toggles .count-visible class on .widgettitle elements
*/
var initMobileColumnCountToggle = function() {
// Only on touch devices
if (!('ontouchstart' in window)) return;
document.querySelectorAll('.widgettitle').forEach(function(header) {
header.addEventListener('click', function(e) {
// Don't interfere with dropdown clicks
if (e.target.closest('.dropdown-toggle, .dropdown-menu')) return;
// Toggle visibility
this.classList.toggle('count-visible');
});
});
};
/**
* Initialize column heights for all collapsed swimlanes on page load
* Equalize heights so columns visually align even when collapsed
*/
var initCollapsedColumnHeights = function() {
var collapsedContents = document.querySelectorAll('.kanban-swimlane-content.collapsed');
collapsedContents.forEach(function(content) {
equalizeColumnHeights(content);
});
};
/**
* Initialize sticky swimlane sidebars using scroll listener and transform
* Uses transform instead of position:fixed to avoid layout shifts
*/
var initStickySwimlaneSidebars = function() {
var rows = document.querySelectorAll('.kanban-swimlane-row');
if (rows.length === 0) return;
// Skip on mobile (vertical layout doesn't need sticky)
if (window.innerWidth <= 768) return;
var STICKY_TOP = 120; // Distance from viewport top when sticky
var updateStickyPositions = function() {
rows.forEach(function(row) {
var sidebar = row.querySelector('.kanban-swimlane-sidebar');
var sidebarInner = row.querySelector('.kanban-swimlane-sidebar-inner');
var sentinel = row.querySelector('.kanban-swimlane-sentinel');
if (!sidebar || !sidebarInner || !sentinel) return;
var sentinelRect = sentinel.getBoundingClientRect();
var sidebarRect = sidebar.getBoundingClientRect();
// Calculate if sidebar content should be sticky
var shouldBeSticky = sentinelRect.top < STICKY_TOP && sidebarRect.bottom > (STICKY_TOP + 100);
if (shouldBeSticky) {
// Calculate how much to translate the inner content
var translateY = STICKY_TOP - sidebarRect.top;
// Don't translate beyond the sidebar bottom
var maxTranslate = sidebarRect.height - sidebarInner.offsetHeight - 12;
translateY = Math.min(translateY, Math.max(0, maxTranslate));
sidebar.classList.add('is-sticky');
sidebarInner.style.transform = 'translateY(' + translateY + 'px)';
} else {
sidebar.classList.remove('is-sticky');
sidebarInner.style.transform = '';
}
});
};
// Update on scroll
window.addEventListener('scroll', updateStickyPositions, { passive: true });
// Initial check
updateStickyPositions();
};
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initQuickAddKeyboard();
initClickOutsideSave();
initSwimlaneKeyboard();
initProgressBarTooltips();
initStickySwimlaneSidebars();
initMobileColumnCountToggle();
initCollapsedColumnHeights();
initExpandedColumnHeights();
});
} else {
initQuickAddKeyboard();
initClickOutsideSave();
initSwimlaneKeyboard();
initProgressBarTooltips();
initStickySwimlaneSidebars();
initMobileColumnCountToggle();
initCollapsedColumnHeights();
initExpandedColumnHeights();
}
// Make public what you want to have public, everything else is private
return {
toggleQuickAdd: toggleQuickAdd,
initQuickAddKeyboard: initQuickAddKeyboard,
initClickOutsideSave: initClickOutsideSave,
toggleSwimlane: toggleSwimlane,
initSwimlaneKeyboard: initSwimlaneKeyboard,
initProgressBarTooltips: initProgressBarTooltips,
equalizeColumnHeights: equalizeColumnHeights,
resetColumnHeights: resetColumnHeights,
initCollapsedColumnHeights: initCollapsedColumnHeights,
initExpandedColumnHeights: initExpandedColumnHeights,
initStickySwimlaneSidebars: initStickySwimlaneSidebars,
initMobileColumnCountToggle: initMobileColumnCountToggle
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
var leantime = leantime || {};
leantime.ticketsRepository = (function () {
//Functions
/**
* Patch a single ticket via JSON-RPC. Returns the underlying promise so
* callers can chain success/error handling.
*/
function patchTicket(id, values) {
return leantime.rpc('Tickets.Tickets.patchTicket', { id: id, values: values });
}
/**
* Shared failure handler for inline ticket updates (auth denied, validation, server error).
* The JSON-RPC layer now rejects with the server's error message on denial/not-found, so
* surface it to the user instead of failing silently.
*/
function handlePatchError(id) {
return function (error) {
jQuery.growl({
message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"),
style: "error"
});
console.error('Could not update ticket ' + id, error);
};
}
var updateMilestoneDates = function (id, start, end, sortIndex) {
let userDateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "luxon");
let userTimeFormat = leantime.dateHelper.getFormatFromSettings("timeformat", "luxon");
let editFrom = luxon.DateTime.fromSQL(start).toFormat(userDateFormat);
let timeFrom = luxon.DateTime.fromSQL(start).toFormat(userTimeFormat);
let editTo = luxon.DateTime.fromSQL(end).toFormat(userDateFormat);
let timeTo = luxon.DateTime.fromSQL(end).toFormat(userTimeFormat);
//This is easier for now and MVP. Later this needs to be refactored to reload the list of tickets async
patchTicket(id, {
editFrom: editFrom,
editTo: editTo,
timeFrom: timeFrom,
timeTo: timeTo,
sortIndex: sortIndex
}).catch(handlePatchError(id));
};
var updateRemainingHours = function (id, remaining, callbackSuccess) {
patchTicket(id, { hourRemaining: remaining })
.then(function () { callbackSuccess(); })
.catch(handlePatchError(id));
};
var updatePlannedHours = function (id, planhours, callbackSuccess) {
patchTicket(id, { planHours: planhours })
.then(function () { callbackSuccess(); })
.catch(handlePatchError(id));
};
var updateDueDates = function (id, date, callbackSuccess) {
patchTicket(id, { dateToFinish: date })
.then(function () { callbackSuccess(); })
.catch(handlePatchError(id));
};
var updateEditFromDates = function (id, date, callbackSuccess) {
patchTicket(id, { editFrom: date })
.then(function () { callbackSuccess(); })
.catch(handlePatchError(id));
};
var updateEditToDates = function (id, date, callbackSuccess) {
patchTicket(id, { editTo: date })
.then(function () { callbackSuccess(); })
.catch(handlePatchError(id));
};
// Make public what you want to have public, everything else is private
return {
updateMilestoneDates: updateMilestoneDates,
updateRemainingHours:updateRemainingHours,
updatePlannedHours:updatePlannedHours,
updateDueDates:updateDueDates,
updateEditFromDates:updateEditFromDates,
updateEditToDates:updateEditToDates
};
})();

View File

@@ -0,0 +1,25 @@
<?php
namespace Leantime\Domain\Tickets\Models;
use Carbon\CarbonImmutable;
/**
* At-a-glance metrics for a task board, computed from the tickets currently on
* screen (so the numbers always reflect the active filters). Rendered in the
* page-header sub-line.
*/
class BoardSummary
{
/** Total tasks on the board. */
public int $total = 0;
/** Tasks with no assignee (editorId). */
public int $unassigned = 0;
/** Tasks whose due date falls between today and the end of the current week. */
public int $dueThisWeek = 0;
/** Most recent change on the board (max ticket modified date), null if none. */
public ?CarbonImmutable $lastUpdated = null;
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Leantime\Domain\Tickets\Models;
/**
* Design tokens for ticket visualization
* Centralizes priority, effort, type, and status mappings
*/
class TicketDesignTokens
{
/**
* Priority levels with labels and color mappings
*/
public const PRIORITIES = [
1 => [
'label' => 'Critical',
'cssVar' => '--priority-critical',
'color' => '#C73E5C', // Design spec
'icon' => 'thermometer-full',
'fill' => 1.0, // Thermometer fill level (0.0-1.0)
],
2 => [
'label' => 'High',
'cssVar' => '--priority-high',
'color' => '#E85A5A', // Design spec
'icon' => 'thermometer-three-quarters',
'fill' => 0.8,
],
3 => [
'label' => 'Medium',
'cssVar' => '--priority-medium',
'color' => '#F5A623', // Design spec
'icon' => 'thermometer-half',
'fill' => 0.6,
],
4 => [
'label' => 'Low',
'cssVar' => '--priority-low',
'color' => '#2ECC71', // Design spec
'icon' => 'thermometer-quarter',
'fill' => 0.4,
],
5 => [
'label' => 'Lowest',
'cssVar' => '--priority-lowest',
'color' => '#6B7280', // Design spec
'icon' => 'thermometer-empty',
'fill' => 0.2,
],
];
/**
* Effort/Story points with labels and size mappings
*/
public const EFFORTS = [
0.5 => ['label' => '< 2min', 'size' => 'xxs', 'tshirtLabel' => 'XXS'],
1 => ['label' => 'XS', 'size' => 'xs', 'tshirtLabel' => 'XS'],
2 => ['label' => 'S', 'size' => 'sm', 'tshirtLabel' => 'S'],
3 => ['label' => 'M', 'size' => 'md', 'tshirtLabel' => 'M'],
5 => ['label' => 'L', 'size' => 'lg', 'tshirtLabel' => 'L'],
8 => ['label' => 'XL', 'size' => 'xl', 'tshirtLabel' => 'XL'],
13 => ['label' => 'XXL', 'size' => 'xxl', 'tshirtLabel' => 'XXL'],
];
/**
* Ticket types with emoji icons
*/
public const TYPES = [
'story' => ['label' => 'Story', 'icon' => '👤', 'faIcon' => 'fa-book'],
'task' => ['label' => 'Task', 'icon' => '📋', 'faIcon' => 'fa-check-square'],
'subtask' => ['label' => 'Subtask', 'icon' => '📋', 'faIcon' => 'fa-diagram-successor'],
'bug' => ['label' => 'Bug', 'icon' => '🐛', 'faIcon' => 'fa-bug'],
'feature' => ['label' => 'Feature', 'icon' => '✨', 'faIcon' => 'fa-star'],
'epic' => ['label' => 'Epic', 'icon' => '🏔️', 'faIcon' => 'fa-mountain'],
'documentation' => ['label' => 'Documentation', 'icon' => '📄', 'faIcon' => 'fa-file'],
'improvement' => ['label' => 'Improvement', 'icon' => '🔧', 'faIcon' => 'fa-wrench'],
'research' => ['label' => 'Research', 'icon' => '🔬', 'faIcon' => 'fa-flask'],
];
/**
* Get priority token by ID
*
* @param int $id Priority ID (1-5)
* @return array|null Priority configuration array or null if not found
*/
public static function getPriority(int $id): ?array
{
return self::PRIORITIES[$id] ?? null;
}
/**
* Get effort token by points
*
* @param float $points Story points value
* @return array|null Effort configuration array or null if not found
*/
public static function getEffort(float $points): ?array
{
return self::EFFORTS[$points] ?? null;
}
/**
* Get type token by name
*
* @param string $type Ticket type name
* @return array|null Type configuration array or null if not found
*/
public static function getType(string $type): ?array
{
return self::TYPES[$type] ?? null;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace Leantime\Domain\Tickets\Models;
class Tickets
{
public mixed $id = null;
public ?string $headline = '';
public mixed $type = null;
public ?string $description = '';
public mixed $projectId = null;
public mixed $projectDescription = null;
public mixed $editorId = null;
public mixed $userId = null;
public mixed $priority = null;
public mixed $sortIndex = null;
public mixed $date = null;
public mixed $timelineDate = null;
public mixed $timelineDateToFinish = null;
public mixed $dateToFinish = null;
public mixed $timeToFinish = null;
public mixed $status = 3;
public mixed $storypoints = null;
public mixed $hourRemaining = null;
public mixed $planHours = null;
public mixed $sprint = null;
public ?string $acceptanceCriteria = '';
public ?string $outcomeImpact = '';
public mixed $tags = null;
public mixed $url = null;
public mixed $editFrom = null;
public mixed $timeFrom = null;
public mixed $editTo = null;
public mixed $timeTo = null;
public mixed $dependingTicketId = null;
public ?string $parentHeadline = '';
public mixed $milestoneid = null;
public ?string $projectName = '';
public ?string $clientName = '';
public ?string $userFirstname = '';
public ?string $userLastname = '';
public ?string $editorFirstname = '';
public ?string $editorLastname = '';
public mixed $doneTickets = null;
public mixed $allTickets = null;
public mixed $percentDone = null;
public mixed $milestoneHeadline = null;
public mixed $milestoneColor = null;
public mixed $editorProfileId = null;
public mixed $bookedHours = null;
public ?array $children = null;
public ?array $collaborators = null;
public mixed $modified = null;
/**
* @param false $values
*/
public function __construct(array|bool $values = false)
{
if ($values !== false) {
$this->id = $values['id'] ?? '';
$this->headline = $values['headline'] ?? '';
$this->type = $values['type'] ?? '';
$this->description = $values['description'] ?? '';
$this->projectId = $values['projectId'] ?? '';
$this->editorId = $values['editorId'] ?? '';
$this->userId = $values['userId'] ?? '';
$this->priority = $values['priority'] ?? '';
$this->date = $values['date'] ?? date('Y-m-d H:i:s');
$this->dateToFinish = $values['dateToFinish'] ?? '';
$this->status = $values['status'] ?? '3';
$this->storypoints = $values['storypoints'] ?? '';
$this->hourRemaining = $values['hourRemaining'] ?? '';
$this->planHours = $values['planHours'] ?? '';
$this->sprint = $values['sprint'] ?? '';
$this->acceptanceCriteria = $values['acceptanceCriteria'] ?? '';
$this->outcomeImpact = $values['outcomeImpact'] ?? '';
$this->tags = $values['tags'] ?? '';
$this->editFrom = $values['editFrom'] ?? '';
$this->editTo = $values['editTo'] ?? '';
$this->dependingTicketId = $values['dependingTicketId'] ?? '';
$this->milestoneid = $values['milestoneid'] ?? '';
$this->projectName = $values['projectName'] ?? '';
$this->clientName = $values['clientName'] ?? '';
$this->userFirstname = $values['userFirstname'] ?? '';
$this->userLastname = $values['userLastname'] ?? '';
$this->editorFirstname = $values['editorFirstname'] ?? '';
$this->editorLastname = $values['editorLastname'] ?? '';
$this->modified = $values['modified'] ?? '';
}
}
/**
* Convert the ticket to a string representation that's useful for AI consumption
*/
public function __toString(): string
{
$output = "Ticket #{$this->id}: {$this->headline}\n";
$output .= "Type: {$this->type}\n";
$output .= "Status: {$this->status}\n";
$output .= "Priority: {$this->priority}\n";
if (! empty($this->description)) {
$output .= 'Description: '.strip_tags($this->description)."\n";
}
if (! empty($this->dateToFinish)) {
$output .= "Due Date: {$this->dateToFinish}\n";
}
if (! empty($this->editorFirstname) || ! empty($this->editorLastname)) {
$output .= "Assigned To: {$this->editorFirstname} {$this->editorLastname}\n";
}
if (! empty($this->storypoints)) {
$output .= "Story Points: {$this->storypoints}\n";
}
$output .= "Project ID: {$this->projectId}\n";
return $output;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Leantime\Domain\Tickets\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Tickets (to-do) permission vocabulary — the verbs only.
*
* Declares *what* can be done with tickets; it says nothing about *which roles* may do it.
* Role assignment is centrally owned (defaults in {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions},
* runtime assignments in `zp_role_permissions` / the admin UI). The typed constants are
* used at call sites (controllers, `@api` methods, `@can`, menu) so nobody types a raw string.
*
* All ticket capabilities are project-scoped — a ticket belongs to a project, so authority
* is the user's role *in that project* (see {@see \Leantime\Core\Auth\RoleResolver}).
*/
final class TicketsPermissions implements ProvidesPermissions
{
public const VIEW = 'tickets.view';
public const COMMENT = 'tickets.comment';
public const UPLOAD = 'tickets.upload';
public const CREATE = 'tickets.create';
public const EDIT = 'tickets.edit';
public const DELETE = 'tickets.delete';
public function domain(): string
{
return 'tickets';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View to-dos'),
new Permission(self::COMMENT, 'Comment on to-dos'),
new Permission(self::UPLOAD, 'Upload files to to-dos'),
new Permission(self::CREATE, 'Create to-dos'),
new Permission(self::EDIT, 'Edit to-dos'),
new Permission(self::DELETE, 'Delete to-dos'),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Leantime\Domain\Tickets\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
class TicketHistory
{
private ConnectionInterface $db;
/**
* __construct - get database connection
*/
public function __construct(DbCore $db)
{
$this->db = $db->getConnection();
}
public function getRecentTicketHistory(\DateTime $startingFrom, int $ticketId): array
{
$query = $this->db->table('zp_tickethistory')
->where('dateModified', '>=', $startingFrom->format('Y-m-d'));
if ($ticketId !== null) {
$query->where('ticketId', $ticketId);
}
$results = $query->orderBy('dateModified', 'desc')->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Leantime\Domain\Tickets\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Support\EntityRelationshipEnum;
/**
* 待办事项(ticket/milestone) ↔ 资源关联层。
*
* 复用多态表 zp_entity_relationship零建表
* entityA = ticket idtask 或 milestone共用 zp_tickets.id 序列)
* entityAType = 'Ticket'
* entityB = 资源 id
* entityBType = 资源类型bom | process | tooling | file | wiki
* relationship = EntityRelationshipEnum::LinkedResource->value
*
* 幂等在服务层事务 + lockForUpdate 实现(仿 Goalcanvas::addGoalMilestoneLink
*/
class TicketResource
{
private ConnectionInterface $db;
public function __construct(DbCore $db)
{
$this->db = $db->getConnection();
}
/**
* 关联一个资源(幂等:已存在则返回 true
*/
public function addLink(int $ticketId, string $resourceType, int $resourceId, int $userId): bool
{
if ($ticketId <= 0 || $resourceId <= 0 || $resourceType === '') {
return false;
}
return $this->db->transaction(function () use ($ticketId, $resourceType, $resourceId, $userId): bool {
$exists = $this->db->table('zp_entity_relationship')
->where('entityA', $ticketId)
->where('entityAType', 'Ticket')
->where('entityB', $resourceId)
->where('entityBType', $resourceType)
->where('relationship', EntityRelationshipEnum::LinkedResource->value)
->lockForUpdate()
->exists();
if ($exists) {
return true;
}
$this->db->table('zp_entity_relationship')->insert([
'entityA' => $ticketId,
'entityAType' => 'Ticket',
'entityB' => $resourceId,
'entityBType' => $resourceType,
'relationship' => EntityRelationshipEnum::LinkedResource->value,
'createdOn' => now(),
'createdBy' => $userId > 0 ? $userId : null,
]);
return true;
});
}
/**
* 解除单个资源关联。
*/
public function removeLink(int $ticketId, string $resourceType, int $resourceId): bool
{
return $this->db->table('zp_entity_relationship')
->where('entityA', $ticketId)
->where('entityAType', 'Ticket')
->where('entityB', $resourceId)
->where('entityBType', $resourceType)
->where('relationship', EntityRelationshipEnum::LinkedResource->value)
->delete() > 0;
}
/**
* 解除 ticket 的全部资源关联(删除 ticket 时级联清理)。
*/
public function removeAllLinks(int $ticketId): bool
{
return $this->db->table('zp_entity_relationship')
->where('entityA', $ticketId)
->where('entityAType', 'Ticket')
->where('relationship', EntityRelationshipEnum::LinkedResource->value)
->delete() > 0;
}
/**
* 某 ticket 已关联的资源(去重后的 [resourceType, resourceId] 列表)。
*
* @return array<int, array{type:string,id:int}>
*/
public function getLinks(int $ticketId): array
{
return $this->db->table('zp_entity_relationship')
->where('entityA', $ticketId)
->where('entityAType', 'Ticket')
->where('relationship', EntityRelationshipEnum::LinkedResource->value)
->orderBy('id')
->get(['entityBType', 'entityB'])
->map(fn ($row) => ['type' => (string) $row->entityBType, 'id' => (int) $row->entityB])
->unique(fn ($item) => $item['type'].':'.$item['id'])
->values()
->all();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
<?php
namespace Leantime\Domain\Tickets\Services;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Bom\Permissions\BomPermissions;
use Leantime\Domain\Bom\Services\Bom as BomService;
use Leantime\Domain\Files\Permissions\FilesPermissions;
use Leantime\Domain\Files\Repositories\Files as FileRepository;
use Leantime\Domain\Files\Services\Files as FilesService;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Repositories\TicketResource as TicketResourceRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepository;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
/**
* 待办事项(ticket/milestone) ↔ 资源关联服务。
*
* 5 类资源BOM / 工艺文件 / 工具清单(共用 zp_bomtype 区分、文件zp_file
* wikizp_canvas/zp_canvas_items。关联边落在 zp_entity_relationshipLinkedResource
* entityBType 用资源类型标识bom | process | tooling | file | wiki。
*/
class TicketResource extends BaseService
{
/** 资源类型 → 显示名 */
public const TYPE_NAMES = [
'bom' => 'BOM',
'process' => '工艺文件',
'tooling' => '工具清单',
'file' => '文件',
'wiki' => 'Wiki',
];
/** 主数据类型(对应 zp_bom.type */
public const MASTER_TYPES = ['bom', 'process', 'tooling'];
public function __construct(
protected TicketResourceRepository $repo,
protected TicketsRepository $ticketRepo,
protected BomService $bomService,
protected FilesService $filesService,
protected WikiService $wikiService,
protected FileRepository $fileRepository,
) {}
/**
* 关联一个资源到 ticket幂等
*
* @return array{success:bool,message?:string}
*/
public function link(int $ticketId, string $resourceType, int $resourceId): array
{
if ($ticketId <= 0 || $resourceId <= 0 || ! array_key_exists($resourceType, self::TYPE_NAMES)) {
return ['success' => false, 'message' => '无效的关联参数'];
}
$ticket = $this->ticketRepo->getTicket($ticketId);
if ($ticket === false) {
return ['success' => false, 'message' => '待办事项不存在'];
}
$projectId = (int) $ticket->projectId;
// 写关联需 tickets.edit读资源详情需对应资源 view 权限
$this->authorize(TicketsPermissions::EDIT, $projectId);
if (! $this->resourceExists($resourceType, $resourceId, $projectId)) {
return ['success' => false, 'message' => '资源不存在或不属于当前项目'];
}
$userId = (int) (session('userdata.id') ?? 0);
$ok = $this->repo->addLink($ticketId, $resourceType, $resourceId, $userId);
return $ok
? ['success' => true]
: ['success' => false, 'message' => '关联失败'];
}
/**
* 解除一个资源关联。
*/
public function unlink(int $ticketId, string $resourceType, int $resourceId): array
{
if ($ticketId <= 0 || $resourceId <= 0) {
return ['success' => false, 'message' => '无效参数'];
}
$ticket = $this->ticketRepo->getTicket($ticketId);
if ($ticket === false) {
return ['success' => false, 'message' => '待办事项不存在'];
}
$this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId);
return $this->repo->removeLink($ticketId, $resourceType, $resourceId)
? ['success' => true]
: ['success' => false, 'message' => '解除失败'];
}
/**
* 某 ticket 已关联的资源详情列表(供详情页 chips 渲染)。
*
* @return array<int, array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}>
*/
public function getLinkedResources(int $ticketId): array
{
$links = $this->repo->getLinks($ticketId);
$result = [];
foreach ($links as $link) {
$detail = $this->resolveResourceDetail($link['type'], $link['id']);
if ($detail !== null) {
$result[] = $detail + ['type' => $link['type'], 'id' => $link['id']];
}
}
return $result;
}
/**
* 候选资源列表(供「添加关联」下拉)。
*
* @return array<int, array{type:string,typeName:string,items:array<int,array{id:int,title:string,subtitle?:string}>}>
*/
public function listCandidates(int $projectId): array
{
$groups = [];
// 主数据BOM / 工艺文件 / 工具清单全局projectId=0
foreach (self::MASTER_TYPES as $type) {
$masters = $this->bomService->getMasters($type, 0);
$items = [];
foreach ($masters as $m) {
$title = trim(($m['bomNo'] ?? '').' '.($m['productName'] ?? ''));
if ($title === '') {
$title = '#'.($m['id'] ?? '');
}
$items[] = [
'id' => (int) $m['id'],
'title' => $title,
'subtitle' => ($m['version'] ?? '') !== '' ? '版本 '.$m['version'] : null,
];
}
$groups[] = ['type' => $type, 'typeName' => self::TYPE_NAMES[$type], 'items' => $items];
}
// 文件(项目文件)
try {
$files = $this->filesService->getFilesByModule('project', $projectId);
$items = [];
if (is_array($files)) {
foreach ($files as $f) {
$items[] = [
'id' => (int) $f['id'],
'title' => (string) ($f['realName'] ?? ''),
'subtitle' => ($f['firstname'] ?? '').' '.($f['lastname'] ?? ''),
];
}
}
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => $items];
} catch (\Throwable $e) {
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => []];
}
// Wiki项目 wiki 的标题作为可关联单元)
try {
$wikis = $this->wikiService->getAllProjectWikis($projectId);
$items = [];
if (is_array($wikis)) {
foreach ($wikis as $w) {
$items[] = [
'id' => (int) ($w['id'] ?? $w->id ?? 0),
'title' => (string) ($w['title'] ?? 'Wiki'),
];
}
}
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => $items];
} catch (\Throwable $e) {
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => []];
}
return $groups;
}
/**
* 校验资源是否存在(并做项目作用域兜底校验,主数据为全局)。
*/
private function resourceExists(string $resourceType, int $resourceId, int $projectId): bool
{
return match ($resourceType) {
'bom', 'process', 'tooling' => $this->bomService->getBom($resourceId) !== false,
'file' => $this->fileExists($resourceId),
'wiki' => $this->wikiExists($resourceId, $projectId),
default => false,
};
}
private function fileExists(int $fileId): bool
{
try {
$f = $this->filesService->getFileById($fileId);
return $f !== false;
} catch (\Throwable $e) {
return false;
}
}
private function wikiExists(int $wikiId, int $projectId): bool
{
try {
$wikis = $this->wikiService->getAllProjectWikis($projectId);
foreach ($wikis as $w) {
if ((int) ($w['id'] ?? $w->id ?? 0) === $wikiId) {
return true;
}
}
} catch (\Throwable $e) {
// ignore
}
return false;
}
/**
* 解析资源详情(供 chips / 弹窗)。
*
* @return array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}|null
*/
private function resolveResourceDetail(string $resourceType, int $resourceId): ?array
{
$typeName = self::TYPE_NAMES[$resourceType] ?? $resourceType;
if (in_array($resourceType, self::MASTER_TYPES, true)) {
$bom = $this->bomService->getBom($resourceId);
if ($bom === false) {
return null;
}
$title = trim(($bom['bomNo'] ?? '').' '.($bom['productName'] ?? ''));
if ($title === '') {
$title = '#'.$resourceId;
}
return [
'type' => $resourceType,
'typeName' => $typeName,
'id' => $resourceId,
'title' => $title,
'subtitle' => ($bom['version'] ?? '') !== '' ? '版本 '.$bom['version'] : null,
'url' => BASE_URL.'/bom/show/'.$resourceId,
];
}
if ($resourceType === 'file') {
$f = $this->fileRepository->getFile($resourceId);
if ($f === false) {
return null;
}
return [
'type' => 'file',
'typeName' => $typeName,
'id' => $resourceId,
'title' => (string) ($f['realName'] ?? ('文件 #'.$resourceId)),
'subtitle' => trim(($f['firstname'] ?? '').' '.($f['lastname'] ?? '')),
'url' => BASE_URL.'/files/get?fileId='.$resourceId,
];
}
if ($resourceType === 'wiki') {
try {
$article = $this->wikiService->getArticle($resourceId);
$title = (string) ($article->title ?? ('Wiki #'.$resourceId));
return [
'type' => 'wiki',
'typeName' => $typeName,
'id' => $resourceId,
'title' => $title,
'url' => BASE_URL.'/wiki/showArticle/'.$resourceId,
];
} catch (\Throwable $e) {
return null;
}
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
<?php
namespace Leantime\Domain\Tickets\Support;
use Illuminate\Support\Str;
use Leantime\Core\Support\AbstractEntityFormatter;
use Leantime\Domain\Projects\Models\Project;
use Leantime\Domain\Tickets\Models\Tickets;
/**
* Ticket entity formatter for AI consumption.
*
* Formats ticket/task entities into structured markdown suitable for AI prompts,
* embeddings, and other LLM operations.
*/
class TicketFormatter extends AbstractEntityFormatter
{
/**
* Fields to exclude from ticket formatting.
*/
protected array $excludedFields = [
'sortIndex',
'timelineDate',
'timelineDateToFinish',
'timeToFinish',
'timeFrom',
'timeTo',
'url',
'doneTickets',
'allTickets',
'children',
'editorProfileId',
];
/**
* Priority order for displaying ticket fields.
*/
protected array $fieldPriority = [
'id',
'headline',
'description',
'type',
'status',
'priority',
'projectName',
'assignedTo',
'dueDate',
'storypoints',
'planHours',
];
public function __construct(
protected Tickets $ticket,
protected Project|array|null $project = null,
protected array|false|null $subtasks = null
) {}
/**
* Get the entity type.
*/
public function getEntityType(): string
{
return 'ticket';
}
/**
* Get the entity ID.
*/
public function getEntityId(): mixed
{
return $this->ticket->id;
}
/**
* Prepare ticket data for formatting.
*/
protected function prepareEntityData(array $context = []): array
{
$data = [
'id' => $this->ticket->id,
'headline' => $this->sanitizeValue($this->ticket->headline),
'type' => $this->sanitizeValue($this->ticket->type),
'description' => $this->sanitizeValue($this->ticket->description),
'status' => $this->ticket->status,
'priority' => $this->ticket->priority,
'storypoints' => $this->sanitizeValue($this->ticket->storypoints),
'dueDate' => $this->formatDate($this->ticket->dateToFinish),
'planHours' => $this->sanitizeValue($this->ticket->planHours),
'scheduledFrom' => $this->formatDate($this->ticket->editFrom),
'scheduledTo' => $this->formatDate($this->ticket->editTo),
'assignedTo' => $this->formatAssignedUser(),
'createdBy' => $this->formatCreatedUser(),
'projectId' => $this->ticket->projectId,
'projectName' => $this->sanitizeValue($this->ticket->projectName),
'clientName' => $this->sanitizeValue($this->ticket->clientName),
'milestone' => $this->formatMilestone(),
'acceptanceCriteria' => $this->sanitizeValue($this->ticket->acceptanceCriteria),
'tags' => $this->sanitizeValue($this->ticket->tags),
'dependingTicketId' => $this->ticket->dependingTicketId,
'parentHeadline' => $this->sanitizeValue($this->ticket->parentHeadline),
// "Last updated" must reflect the last modification, not creation.
// It was reading ->date (the create timestamp), so every ticket
// reported its creation date as "last updated". Fall back to date
// only when modified is absent (legacy rows / partial selects).
'lastUpdated' => $this->formatDate($this->ticket->modified ?: $this->ticket->date),
'bookedHours' => $this->sanitizeValue($this->ticket->bookedHours),
'hourRemaining' => $this->sanitizeValue($this->ticket->hourRemaining),
'percentDone' => $this->sanitizeValue($this->ticket->percentDone),
];
// Add project information if available
if ($this->project) {
if (is_array($this->project)) {
$this->project = new Project($this->project);
}
$data['projectDetails'] = [
'name' => $this->sanitizeValue($this->project->name),
'type' => $this->sanitizeValue($this->project->type),
'state' => $this->sanitizeValue($this->project->state),
'progress' => $this->sanitizeValue($this->project->progress),
'status' => $this->sanitizeValue($this->project->status),
];
}
if ($this->subtasks) {
$data['subtasks'] = $this->formatSubtasks($this->subtasks);
}
return $data;
}
/**
* Format the header section.
*/
protected function formatHeader(array $data): string
{
$type = ! empty($data['type']) ? strtoupper($data['type']) : 'TASK';
$projectInfo = ! empty($data['projectName']) ? " ({$data['projectName']})" : '';
return "## {$type} #{$data['id']} - {$data['headline']}{$projectInfo}";
}
/**
* Format the body with custom ticket-specific formatting.
*/
protected function formatBody(array $data, array $context = []): string
{
$filteredData = $this->filterFields($data, $context);
// Custom formatting for specific fields
if (isset($filteredData['priority'])) {
$filteredData['priority'] = $this->formatPriority($filteredData['priority']);
}
// Remove the duplicated header fields from body
unset($filteredData['id'], $filteredData['headline'], $filteredData['projectName']);
$sortedData = $this->sortFields($filteredData);
return "\n".Str::toMarkdown($sortedData);
}
/**
* Format a compact summary.
*/
protected function formatSummary(array $data): string
{
$type = ! empty($data['type']) ? strtoupper($data['type']) : 'TASK';
$status = $this->formatSimpleStatus($data['status'] ?? '');
$assignee = ! empty($data['assignedTo']) ? "{$data['assignedTo']}" : '';
return "{$type} #{$data['id']}: {$data['headline']}{$status}{$assignee}";
}
/**
* Format subtasks into a list string.
*/
protected function formatSubtasks(array $subtasks): string
{
$subtaskData = '';
foreach ($subtasks as $subtask) {
$subtaskData .= ' #'.$subtask['id'].' '.$this->sanitizeValue($subtask['headline'])."\n";
}
return $subtaskData;
}
/**
* Format the assigned user information.
*/
protected function formatAssignedUser(): string
{
$firstName = $this->sanitizeValue($this->ticket->editorFirstname);
$lastName = $this->sanitizeValue($this->ticket->editorLastname);
if (empty($firstName) && empty($lastName)) {
return 'Unassigned';
}
$name = trim($firstName.' '.$lastName);
$id = $this->ticket->editorId;
return ! empty($id) ? "{$name} (ID: {$id})" : $name;
}
/**
* Format the user who created the ticket.
*/
protected function formatCreatedUser(): string
{
$firstName = $this->sanitizeValue($this->ticket->userFirstname);
$lastName = $this->sanitizeValue($this->ticket->userLastname);
if (empty($firstName) && empty($lastName)) {
return 'Unknown';
}
$name = trim($firstName.' '.$lastName);
$id = $this->ticket->userId;
return ! empty($id) ? "{$name} (ID: {$id})" : $name;
}
/**
* Format milestone information.
*/
protected function formatMilestone(): string
{
if (empty($this->ticket->milestoneHeadline) && empty($this->ticket->milestoneid)) {
return 'No milestone';
}
$headline = $this->sanitizeValue($this->ticket->milestoneHeadline) ?: 'Untitled Milestone';
$id = $this->ticket->milestoneid;
return ! empty($id) ? "{$headline} (ID: {$id})" : $headline;
}
/**
* Format status for summary (simple version).
*
* @param mixed $status
*/
protected function formatSimpleStatus($status): string
{
if (empty($status)) {
return '';
}
// Simple status indicators for summaries
return match ((string) $status) {
'1' => ' [NEW]',
'2' => ' [IN PROGRESS]',
'3' => ' [DONE]',
default => " [{$status}]"
};
}
}

View File

@@ -0,0 +1,249 @@
@extends($layout)
@section('content')
@php
$milestones = $milestones ?? [];
if (! session()->exists('usersettings.submenuToggle.myProjectCalendarView')) {
session(['usersettings.submenuToggle.myProjectCalendarView' => 'dayGridMonth']);
}
@endphp
{!! $tpl->displayNotification() !!}
@include('tickets::submodules.timelineHeader')
<div class="maincontent">
@include('tickets::submodules.timelineTabs')
<div class="maincontentinner">
<div class="row">
<div class="col-md-4">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
<div class="col-md-4">
<div class="fc-center center" id="calendarTitle" style="padding-top:5px;">
<h2>..</h2>
</div>
</div>
<div class="col-md-4">
<button class="fc-next-button btn btn-default right" type="button" style="margin-right:5px;">
<span class="fc-icon fc-icon-chevron-right"></span>
</button>
<button class="fc-prev-button btn btn-default right" type="button" style="margin-right:5px;">
<span class="fc-icon fc-icon-chevron-left"></span>
</button>
<button class="fc-today-button btn btn-default right" style="margin-right:5px;">today</button>
<select id="my-select" style="margin-right:5px;" class="right">
<option class="fc-timeGridDay-button fc-button fc-state-default fc-corner-right" value="timeGridDay" {{ session('usersettings.submenuToggle.myProjectCalendarView') == 'timeGridDay' ? 'selected' : '' }}>Day</option>
<option class="fc-timeGridWeek-button fc-button fc-state-default fc-corner-right" value="timeGridWeek" {{ session('usersettings.submenuToggle.myProjectCalendarView') == 'timeGridWeek' ? 'selected' : '' }}>Week</option>
<option class="fc-dayGridMonth-button fc-button fc-state-default fc-corner-right" value="dayGridMonth" {{ session('usersettings.submenuToggle.myProjectCalendarView') == 'dayGridMonth' ? 'selected' : '' }}>Month</option>
<option class="fc-multiMonthYear-button fc-button fc-state-default fc-corner-right" value="multiMonthYear" {{ session('usersettings.submenuToggle.myProjectCalendarView') == 'multiMonthYear' ? 'selected' : '' }}>Year</option>
</select>
</div>
</div>
<div class="calendar-wrapper">
<div id="calendar"></div>
</div>
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
@if (isset($_GET['showMilestoneModal']))
@php
$modalUrl = $_GET['showMilestoneModal'] == '' ? '' : '/'.(int) $_GET['showMilestoneModal'];
@endphp
leantime.ticketsController.openMilestoneModalManually("{{ BASE_URL }}/tickets/editMilestone{{ $modalUrl }}");
window.history.pushState({},document.title, '{{ BASE_URL }}/tickets/roadmap');
@endif
});
var events = [
@foreach ($milestones as $mlst)
@php
$headline = __('label.'.strtolower($mlst->type)).': '.$mlst->headline;
if ($mlst->type == 'milestone') {
$headline .= ' ('.$mlst->percentDone.'% Done)';
}
$color = '#8D99A6';
if ($mlst->type == 'milestone') {
$color = $mlst->tags;
}
$sortIndex = 0;
if ($mlst->sortIndex != '' && is_numeric($mlst->sortIndex)) {
$sortIndex = $mlst->sortIndex;
}
$dependencyList = [];
if ($mlst->milestoneid != 0) {
$dependencyList[] = $mlst->milestoneid;
}
if ($mlst->dependingTicketId != 0) {
$dependencyList[] = $mlst->dependingTicketId;
}
@endphp
{
title: @json($headline),
@if (dtHelper()->isValidDateString($mlst->dateToFinish))
start: new Date({{ format($mlst->dateToFinish)->jsTimestamp() }}),
end: new Date({{ format(dtHelper()->parseDbDateTime($mlst->dateToFinish)->addHour(1))->jsTimestamp() }}),
@elseif (dtHelper()->isValidDateString($mlst->editFrom))
start: new Date({{ format($mlst->editFrom)->jsTimestamp() }}),
end: new Date({{ format($mlst->editTo)->jsTimestamp() }}),
@endif
enitityId: {{ $mlst->id }},
@if ($mlst->type == 'milestone')
url: '#/tickets/editMilestone/{{ $mlst->id }}',
color: '{{ $color }}',
enitityType: "milestone",
allDay: true,
@else
url: '#/tickets/showTicket/{{ $mlst->id }}',
color: '{{ $color }}',
enitityType: "ticket",
allDay: false,
@endif
},
@endforeach
];
document.addEventListener('DOMContentLoaded', function() {
const heightWindow = jQuery("body").height() - 190;
const calendarEl = document.getElementById('calendar');
const calendar = new FullCalendar.Calendar(calendarEl, {
timeZone: leantime.i18n.__("usersettings.timezone"),
height:heightWindow,
initialView: '{{ session('usersettings.submenuToggle.myProjectCalendarView') }}',
events: events,
editable: true,
headerToolbar: false,
dayHeaderFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "luxon"),
eventTimeFormat: leantime.dateHelper.getFormatFromSettings("timeformat", "luxon"),
slotLabelFormat: leantime.dateHelper.getFormatFromSettings("timeformat", "luxon"),
views: {
timeGridDay: {
},
timeGridWeek: {
},
dayGridMonth: {
dayHeaderFormat: { weekday: 'short' },
},
multiMonthYear: {
showNonCurrentDates: true,
multiMonthTitleFormat: { month: 'long', year: 'numeric' },
dayHeaderFormat: { weekday: 'short' },
}
},
nowIndicator: true,
bootstrapFontAwesome: {
close: 'fa-times',
prev: 'fa-chevron-left',
next: 'fa-chevron-right',
prevYear: 'fa-angle-double-left',
nextYear: 'fa-angle-double-right'
},
eventDrop: function (event) {
leantime.rpc('Tickets.Tickets.patchTicket', {
id: event.event.extendedProps.enitityId,
values: {
editFrom: event.event.startStr,
editTo: event.event.endStr
}
}).catch(function (error) {
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
event.revert();
console.error('Could not update ticket dates', error);
});
},
eventResize: function (event) {
leantime.rpc('Tickets.Tickets.patchTicket', {
id: event.event.extendedProps.enitityId,
values: {
editFrom: event.event.startStr,
editTo: event.event.endStr
}
}).catch(function (error) {
jQuery.growl({ message: (error && error.message) ? error.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
event.revert();
console.error('Could not update ticket dates', error);
})
},
eventMouseEnter: function() {
}
}
);
calendar.setOption('locale', leantime.i18n.__("language.code"));
calendar.render();
calendar.scrollToTime( 100 );
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
jQuery('.fc-prev-button').click(function() {
calendar.prev();
calendar.getCurrentData()
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
});
jQuery('.fc-next-button').click(function() {
calendar.next();
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
});
jQuery('.fc-today-button').click(function() {
calendar.today();
jQuery("#calendarTitle h2").text(calendar.getCurrentData().viewTitle);
});
jQuery("#my-select").on("change", function(e){
calendar.changeView(jQuery("#my-select option:selected").val());
leantime.rpc('Api.Api.setSubmenuState', {
submenu: "myProjectCalendarView",
state: jQuery("#my-select option:selected").val()
}).catch(function (e) { console.error('Could not update submenu state', e); });
});
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swimlane Component Test</title>
<link rel="stylesheet" href="{{ BASE_URL }}/dist/css/main.3.5.12.min.css">
<style>
body { padding: 20px; background: #f5f5f5; }
.test-section { background: white; padding: 20px; margin-bottom: 20px; border-radius: 8px; }
.test-section h2 { margin-top: 0; color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.component-demo { margin: 15px 0; padding: 15px; background: #f9f9f9; border-radius: 4px; }
.component-label { font-weight: bold; color: #666; margin-bottom: 8px; }
</style>
</head>
<body>
<h1>Swimlane Row Header Components Test</h1>
<p>Testing all new Blade components before integration</p>
<!-- Icon Components Test -->
<div class="test-section">
<h2>1. Icon Components</h2>
<div class="component-demo">
<div class="component-label">ThermometerIcon (Priority)</div>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<small>Critical (1):</small>
<x-global::kanban.thermometer-icon :priority="1" />
</div>
<div>
<small>High (2):</small>
<x-global::kanban.thermometer-icon :priority="2" />
</div>
<div>
<small>Medium (3):</small>
<x-global::kanban.thermometer-icon :priority="3" />
</div>
<div>
<small>Low (4):</small>
<x-global::kanban.thermometer-icon :priority="4" />
</div>
<div>
<small>Lowest (5):</small>
<x-global::kanban.thermometer-icon :priority="5" />
</div>
</div>
<div style="margin-top: 10px;">
<small>With labels:</small><br>
<x-global::kanban.thermometer-icon :priority="1" :showLabel="true" />
</div>
</div>
<div class="component-demo">
<div class="component-label">TShirtIcon (Effort)</div>
<div style="display: flex; gap: 20px; align-items: center;">
<x-global::kanban.tshirt-icon :effort="1" :showLabel="true" />
<x-global::kanban.tshirt-icon :effort="2" :showLabel="true" />
<x-global::kanban.tshirt-icon :effort="3" :showLabel="true" />
<x-global::kanban.tshirt-icon :effort="5" :showLabel="true" />
<x-global::kanban.tshirt-icon :effort="8" :showLabel="true" />
<x-global::kanban.tshirt-icon :effort="13" :showLabel="true" />
</div>
</div>
<div class="component-demo">
<div class="component-label">UserAvatar (with consistent color generation)</div>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<small>Marcus Wells (SM):</small><br>
<x-global::kanban.user-avatar username="Marcus Wells" size="sm" />
</div>
<div>
<small>Sarah Connor (MD):</small><br>
<x-global::kanban.user-avatar username="Sarah Connor" size="md" />
</div>
<div>
<small>Unassigned (MD):</small><br>
<x-global::kanban.user-avatar username="Unassigned" size="md" />
</div>
<div>
<small>Bob Johnson (LG):</small><br>
<x-global::kanban.user-avatar username="Bob Johnson" size="lg" />
</div>
</div>
<div style="margin-top: 10px; font-size: 12px; color: #666;">
Note: Each username gets a consistent color. Same user = same color every time.
</div>
</div>
<div class="component-demo">
<div class="component-label">TimeIndicator</div>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<small>Due Soon:</small>
<x-global::kanban.time-indicator type="dueSoon" />
</div>
<div>
<small>Overdue:</small>
<x-global::kanban.time-indicator type="overdue" />
</div>
<div>
<small>Stale:</small>
<x-global::kanban.time-indicator type="stale" />
</div>
</div>
</div>
<div class="component-demo">
<div class="component-label">Supporting Icons</div>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<small>Milestone 🎯:</small>
<x-global::kanban.milestone-icon label="Sprint 1" />
</div>
<div>
<small>Bug 🐛:</small>
<x-global::kanban.type-icon type="bug" />
</div>
<div>
<small>Feature :</small>
<x-global::kanban.type-icon type="feature" />
</div>
<div>
<small>Sprint 🏃:</small>
<x-global::kanban.sprint-icon label="Sprint 2" />
</div>
</div>
</div>
</div>
<!-- MicroProgressBar Test -->
<div class="test-section">
<h2>2. MicroProgressBar Component</h2>
<div class="component-demo">
<div class="component-label">Status Breakdown (50% New, 30% In Progress, 20% Done)</div>
<x-global::kanban.micro-progress-bar
:statusCounts="['3' => 5, '4' => 3, '5' => 2]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:totalCount="10"
/>
</div>
<div class="component-demo">
<div class="component-label">Different Distribution (70% New, 20% In Progress, 10% Done)</div>
<x-global::kanban.micro-progress-bar
:statusCounts="['3' => 14, '4' => 4, '5' => 2]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:totalCount="20"
/>
</div>
<div class="component-demo">
<div class="component-label">All Statuses</div>
<x-global::kanban.micro-progress-bar
:statusCounts="['1' => 2, '3' => 5, '4' => 3, '2' => 1, '5' => 4]"
:statusColumns="['1' => 'Blocked', '3' => 'New', '4' => 'In Progress', '2' => 'Waiting', '5' => 'Done']"
:totalCount="15"
/>
</div>
</div>
<!-- Count Badge Variations -->
<div class="test-section">
<h2>3. CountBadge Color Variations</h2>
<div class="component-demo">
<div class="component-label">Small Count (1-9) - Light Olive</div>
<x-global::kanban.swimlane-row-header
groupBy="priority"
:groupId="3"
label="Medium"
:totalCount="5"
:statusCounts="['3' => 3, '4' => 1, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
</div>
<div class="component-demo">
<div class="component-label">Medium Count (10-99) - Medium Olive</div>
<x-global::kanban.swimlane-row-header
groupBy="priority"
:groupId="2"
label="High"
:totalCount="12"
:statusCounts="['3' => 5, '4' => 4, '5' => 3]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
</div>
<div class="component-demo">
<div class="component-label">Large Count (100+) - Dark Olive</div>
<x-global::kanban.swimlane-row-header
groupBy="priority"
:groupId="1"
label="Critical"
:totalCount="128"
:statusCounts="['3' => 50, '4' => 48, '5' => 30]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
</div>
</div>
<!-- SwimLaneRowHeader Test -->
<div class="test-section">
<h2>4. Complete SwimLaneRowHeader Components (Matching Design)</h2>
<h3 style="color: #6B7A4D; margin-top: 20px;">EXPANDED vs COLLAPSED States</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 30px;">
<div>
<small style="color: #666;">Expanded (shows progress bar)</small>
<x-global::kanban.swimlane-row-header
groupBy="priority"
:groupId="1"
label="Critical"
:totalCount="4"
:statusCounts="['3' => 2, '4' => 1, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="overdue"
/>
</div>
<div>
<small style="color: #666;">Collapsed (minimal)</small>
<x-global::kanban.swimlane-row-header
groupBy="priority"
:groupId="2"
label="High"
:totalCount="6"
:statusCounts="['3' => 3, '4' => 2, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="false"
timeAlert="dueSoon"
/>
</div>
<div>
<small style="color: #666;">Collapsed (USER groupby)</small>
<x-global::kanban.swimlane-row-header
groupBy="editorId"
groupId="123"
label="Sarah Chen"
:totalCount="5"
:statusCounts="['3' => 2, '4' => 2, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="false"
timeAlert="overdue"
/>
</div>
<div>
<small style="color: #666;">Collapsed (EFFORT groupby)</small>
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="5"
label="L"
:totalCount="3"
:statusCounts="['3' => 1, '4' => 1, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="false"
timeAlert="stale"
/>
</div>
</div>
<h3 style="color: #6B7A4D; margin-top: 20px;">GROUP BY: EFFORT</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="1"
label="XS"
:totalCount="5"
:statusCounts="['3' => 2, '4' => 2, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="2"
label="S"
:totalCount="18"
:statusCounts="['3' => 6, '4' => 6, '5' => 6]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="dueSoon"
/>
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="3"
label="M"
:totalCount="6"
:statusCounts="['3' => 2, '4' => 2, '5' => 2]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="5"
label="L"
:totalCount="3"
:statusCounts="['3' => 0, '4' => 1, '5' => 2]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="overdue"
/>
<x-global::kanban.swimlane-row-header
groupBy="storypoints"
:groupId="8"
label="XL"
:totalCount="2"
:statusCounts="['3' => 1, '4' => 1, '5' => 0]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
</div>
<h3 style="color: #6B7A4D; margin-top: 20px;">GROUP BY: MILESTONE</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<x-global::kanban.swimlane-row-header
groupBy="milestoneid"
groupId="1"
label="Q1 Launch"
:totalCount="12"
:statusCounts="['3' => 4, '4' => 5, '5' => 3]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="dueSoon"
moreInfo="Start: Jan 1, 2024 • End: Mar 31, 2024 • Status: Active"
/>
<x-global::kanban.swimlane-row-header
groupBy="milestoneid"
groupId="2"
label="Beta Release"
:totalCount="8"
:statusCounts="['3' => 2, '4' => 2, '5' => 4]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
moreInfo="Start: Feb 1, 2024 • End: Feb 28, 2024 • Status: Completed"
/>
<x-global::kanban.swimlane-row-header
groupBy="milestoneid"
groupId="3"
label="User Research"
:totalCount="5"
:statusCounts="['3' => 3, '4' => 2, '5' => 0]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
moreInfo="Start: Mar 1, 2024 • End: Apr 15, 2024 • Status: In Progress"
/>
<x-global::kanban.swimlane-row-header
groupBy="milestoneid"
groupId="0"
label="No Milestone"
:totalCount="7"
:statusCounts="['3' => 4, '4' => 2, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="stale"
/>
</div>
<h3 style="color: #6B7A4D; margin-top: 20px;">GROUP BY: SPRINT</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<x-global::kanban.swimlane-row-header
groupBy="sprint"
groupId="23"
label="Sprint 23"
:totalCount="15"
:statusCounts="['3' => 5, '4' => 7, '5' => 3]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
timeAlert="dueSoon"
/>
<x-global::kanban.swimlane-row-header
groupBy="sprint"
groupId="24"
label="Sprint 24"
:totalCount="8"
:statusCounts="['3' => 3, '4' => 4, '5' => 1]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
<x-global::kanban.swimlane-row-header
groupBy="sprint"
groupId="0"
label="Backlog"
:totalCount="24"
:statusCounts="['3' => 18, '4' => 4, '5' => 2]"
:statusColumns="['3' => 'New', '4' => 'In Progress', '5' => 'Done']"
:expanded="true"
/>
</div>
<div style="margin-top: 20px; padding: 15px; background: #FFF9E6; border-left: 4px solid #F5A623; border-radius: 4px;">
<strong>💡 Design Notes:</strong>
<ul style="margin: 8px 0; padding-left: 20px;">
<li><strong>Two states only:</strong> Expanded (shows progress bar) and Collapsed (hides progress bar)</li>
<li><strong>Label text always visible</strong> in both states - only progress bar toggles</li>
<li><strong>Count badge styling differs by state:</strong>
<ul style="margin: 4px 0; padding-left: 20px;">
<li>Expanded: Olive green background badge</li>
<li>Collapsed: Plain number (no background)</li>
</ul>
</li>
<li>Chevron rotates: (expanded) (collapsed)</li>
<li>Better spacing and alignment in both states</li>
<li>Time indicators (⏳⏰💤) inline with label row</li>
<li>Milestone dates show on hover only (not displayed on card)</li>
<li>Icons match design: 🎯 for milestones, 🏃 for sprints, 👕 for effort</li>
<li>Smooth transition animation between states (0.2s ease)</li>
</ul>
</div>
</div>
<div class="test-section">
<h2>5. Design Tokens (CSS Variables)</h2>
<div class="component-demo">
<div class="component-label">Priority Colors</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<div style="width: 120px; padding: 10px; background: var(--priority-critical); color: white; border-radius: 4px;">Critical</div>
<div style="width: 120px; padding: 10px; background: var(--priority-high); color: white; border-radius: 4px;">High</div>
<div style="width: 120px; padding: 10px; background: var(--priority-medium); color: white; border-radius: 4px;">Medium</div>
<div style="width: 120px; padding: 10px; background: var(--priority-low); color: white; border-radius: 4px;">Low</div>
<div style="width: 120px; padding: 10px; background: var(--priority-lowest); color: white; border-radius: 4px;">Lowest</div>
</div>
</div>
</div>
<script>
console.log('Component test page loaded');
// Implement toggle functionality for test page
window.leantime = window.leantime || {};
window.leantime.kanbanController = window.leantime.kanbanController || {};
window.leantime.kanbanController.toggleSwimlane = function(id) {
console.log('Toggle swimlane:', id);
const header = document.querySelector('[data-swimlane-id="' + id + '"]');
if (!header) return;
const isExpanded = header.getAttribute('data-expanded') === 'true';
const newExpanded = !isExpanded;
// Update data attribute
header.setAttribute('data-expanded', newExpanded.toString());
// Find the component and re-render it (in real app, this would be handled by HTMX/server)
// For test page, we'll use a simple approach: reload the page or toggle classes
// Since we can't easily re-render Blade, let's just show a message
alert('Toggle clicked! In the real Kanban view, this will expand/collapse the swimlane.\n\nCurrent state: ' + (isExpanded ? 'Expanded' : 'Collapsed') + '\nNew state: ' + (newExpanded ? 'Expanded' : 'Collapsed'));
// In Phase 7, we'll implement actual server-side toggle with HTMX or page reload
};
</script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
<h4 class="widgettitle title-light">{!! __('subtitles.delete_milestone') !!}</h4>
<form method="post" action="{{ BASE_URL }}/tickets/delMilestone/{{ $ticket->id }}">
<p>{!! __('text.confirm_milestone_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 }}/tickets/roadmap/">{!! __('buttons.back') !!}</x-global::forms.button>
</form>

View File

@@ -0,0 +1,20 @@
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
@if (!empty($error))
{!! $error !!}
@else
@if (is_object($ticket))
<form method="post" action="{{ BASE_URL }}/tickets/delTicket/{{ $ticket->id }}">
<p>{!! __('text.confirm_ticket_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="#/tickets/showTicket/{{ $ticket->id }}">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
@else
<p>Ticket not found</p>
@endif
@endif

View File

@@ -0,0 +1,133 @@
@php
$currentMilestone = $milestone ?? null;
$milestones = $milestones ?? [];
$statusLabels = $statusLabels ?? [];
@endphp
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
//It's not a modal
location.href="{{ BASE_URL }}/tickets/roadmap?showMilestoneModal={{ $currentMilestone->id }}";
}
}
</script>
<div class="modal-icons">
@if (isset($currentMilestone->id) && $currentMilestone->id != '')
<a href="#/tickets/delMilestone/{{ $currentMilestone->id }}" class="danger" data-tippy-content="Delete"><i class='fa fa-trash-can'></i></a>
@endif
</div>
<h4 class="widgettitle title-light">{!! __('headline.milestone') !!} </h4>
{!! $tpl->displayNotification() !!}
<form class="formModal" method="post" action="{{ BASE_URL }}/tickets/editMilestone/{{ $currentMilestone->id }}" style="min-width: 250px;">
<label>{!! __('label.milestone_title') !!}</label>
<x-global::forms.text-input name="headline" value="{{ $currentMilestone->headline }}" placeholder="{{ __('label.milestone_title') }}" /><br />
<label class="control-label">{!! __('label.project') !!}</label>
<select name="projectId" class="tw-w-full">
@foreach ($allAssignedprojects as $project)
@if (empty($project['type']) || $project['type'] == 'project')
<option value="{{ $project['id'] }}"
@if (!empty($currentMilestone->projectId) && $currentMilestone->projectId == $project['id'])
selected
@elseif (session('currentProject') == $project['id'])
selected
@endif
>{{ $project['name'] }}</option>
@endif
@endforeach
</select>
<label>{!! __('label.todo_status') !!}</label>
<select id="status-select" name="status" class="span11"
data-placeholder="{{ isset($statusLabels[$currentMilestone->status]) ? $statusLabels[$currentMilestone->status]['name'] : '' }}">
@foreach ($statusLabels as $key => $label)
<option value="{{ $key }}"
@if ($currentMilestone->status == $key) selected='selected' @endif
>{{ $label['name'] }}</option>
@endforeach
</select>
<label>{!! __('label.dependent_on') !!}</label>
<select name="dependentMilestone" class="span11">
<option value="">{!! __('label.no_dependency') !!}</option>
@foreach ($milestones as $milestoneRow)
@if ($milestoneRow->id !== $currentMilestone->id)
<option value="{{ $milestoneRow->id }}"
@if ($currentMilestone->milestoneid == $milestoneRow->id) selected='selected' @endif
>{{ $milestoneRow->headline }} </option>
@endif
@endforeach
</select>
<label>{!! __('label.owner') !!}</label>
<select data-placeholder="{{ __('input.placeholders.filter_by_user') }}"
name="editorId" class="user-select span11">
<option value="">{!! __('dropdown.not_assigned') !!}</option>
@foreach ($users as $userRow)
<option value="{{ $userRow['id'] }}"
@if ($currentMilestone->editorId == $userRow['id']) selected='selected' @endif
>{{ $userRow['firstname'] }} {{ $userRow['lastname'] }}</option>
@endforeach
</select>
<label>{!! __('label.color') !!}</label>
<input type="text" name="tags" autocomplete="off" value="{{ $currentMilestone->tags }}" placeholder="{{ __('input.placeholders.pick_a_color') }}" class="simpleColorPicker"/><br />
<label>{!! __('label.planned_start_date') !!}</label>
<input type="text" name="editFrom" autocomplete="off" value="{{ format($currentMilestone->editFrom)->date() }}" placeholder="{{ __('language.dateformat') }}" id="milestoneEditFrom" /><br />
<label>{!! __('label.planned_end_date') !!}</label>
<input type="text" name="editTo" autocomplete="off" value="{{ format($currentMilestone->editTo)->date() }}" placeholder="{{ __('language.dateformat') }}" id="milestoneEditTo" /><br />
<label>{!! __('label.outcome_impact') !!}</label>
<textarea name="outcomeImpact" rows="3" class="tw-w-full"
placeholder="{{ __('input.placeholders.outcome_impact') }}">{{ $currentMilestone->outcomeImpact }}</textarea><br />
<div class="row">
<div class="col-md-6">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" />
</div>
<div class="col-md-6 align-right padding-top-sm">
</div>
</div>
</form>
@if (isset($currentMilestone->id) && $currentMilestone->id !== '')
<br />
<input type="hidden" name="comment" value="1" />
@include('comments::submodules.generalComment', ['formUrl' => '/tickets/editMilestone/'.$currentMilestone->id])
@endif
<script type="text/javascript">
jQuery(document).ready(function(){
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
return;
@endif
leantime.ticketsController.initSimpleColorPicker();
leantime.ticketsController.initMilestoneDates();
@if (!$login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if ($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
})
</script>

View File

@@ -0,0 +1,53 @@
@if ($ticket->type == 'milestone')
<h4 class="widgettitle title-light">{!! __('headline.move_milestone') !!} </h4>
@else
<h4 class="widgettitle title-light">{!! __('headline.move_todo') !!} </h4>
@endif
<form method="post" action="{{ BASE_URL }}/tickets/moveTicket/{{ $ticket->id }}" class="formModal">
<h3>#{{ $ticket->id }} - {{ $ticket->headline }}</h3> <br />
<p>
@if ($ticket->type == 'milestone')
{!! __('text.moving_milestones') !!}
@else
{!! __('text.moving') !!}
@endif
<br /><br />
</p>
<select id="projectSelector" name="projectId">
@php
$i = 0;
$lastClient = '';
foreach ($projects as $projectRow) {
if ($lastClient != $projectRow['clientName']) {
$lastClient = $projectRow['clientName'];
if ($i > 1) {
echo '</optgroup>';
}
echo "<optgroup label='".$tpl->escape($projectRow['clientName'])."'> ";
}
echo "<option value='".$projectRow['id']."'>".$tpl->escape($projectRow['name']).'</option>';
$i++;
}
@endphp
</select><br /><br /><br /><br />
<br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.move')" name="move" />
<x-global::forms.button tag="a" class="pull-right" link="javascript:void(0);" onclick="jQuery.nmTop().close();" contentRole="tertiary">{!! __('buttons.back') !!}</x-global::forms.button>
<div class="clearall"></div>
<br />
</form>
<script>
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
jQuery(document).ready(function(){
jQuery("#projectSelector").chosen();
});
</script>

View File

@@ -0,0 +1,57 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pull-right padding-top">
<a href="{{ session('lastPage') }}" class="backBtn"><i class="far fa-arrow-alt-circle-left"></i> {!! __('links.go_back') !!}</a>
</div>
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{{ session('currentProjectClient').' // '.session('currentProjectName') }}</h5>
<h1>{!! __('headlines.new_to_do') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="tabbedwidget tab-primary ticketTabs">
<ul>
<li>
<a href="#ticketdetails">{!! __('tabs.ticketDetails') !!}</a>
</li>
</ul>
<div id="ticketdetails">
<form class="ticketModal" action="{{ BASE_URL }}/tickets/newTicket" method="post">
@include('tickets::submodules.ticketDetails')
</form>
</div>
</div>
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
leantime.ticketsController.initTicketTabs();
leantime.ticketsController.initTagsInput();
});
jQuery(window).load(function () {
jQuery(window).resize();
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,64 @@
@php
$projectData = $projectData ?? [];
$todoTypeIcons = $ticketTypeIcons ?? [];
@endphp
<div style="min-width:90%">
<h1>{!! __('headlines.new_to_do') !!}</h1>
{!! $tpl->displayNotification() !!}
<div class="tabbedwidget tab-primary ticketTabs" style="visibility:hidden;">
<ul>
<li><a href="#ticketdetails">{!! __('tabs.ticketDetails') !!}</a></li>
</ul>
<div id="ticketdetails">
<form class="formModal" action="{{ BASE_URL }}/tickets/newTicket" method="post">
@include('tickets::submodules.ticketDetails')
</form>
</div>
</div>
</div>
<br />
<script type="text/javascript">
jQuery(document).ready(function(){
@if (isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
leantime.ticketsController.initTicketTabs();
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initDueDateTimePickers();
leantime.dateController.initDatePicker(".dates");
leantime.dateController.initDateRangePicker(".editFrom", ".editTo");
leantime.ticketsController.initTagsInput();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initStatusDropdown();
jQuery(".ticketTabs select").chosen();
@else
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if ($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
});
</script>

View File

@@ -0,0 +1,51 @@
@props([
'milestone' => null,
'noText' => false,
'percentDone' => 0,
'progressColor' => 'default'
])
<div class="ticketBox fixed">
<div class="row">
<div class="col-md-8" style="margin-bottom:5px;">
<strong><a href="<?=BASE_URL ?>/tickets/showKanban?milestone={{ $milestone->id }}" >{{ $milestone->headline }}</a></strong>
</div>
<div class="col-md-4 align-right">
</div>
</div>
@fragment('progress')
@if($noText === false || $noText === null)
<div class="row progress-wrapper">
<div class="col-md-7 percent-label">
{{ __("label.due") }}
<?php echo format($milestone->editTo )->date($tpl->__("text.no_date_defined")); ?>
</div>
<div class="col-md-5 percent-label" style="text-align:right">
<?=sprintf($tpl->__("text.percent_complete"), format($percentDone)->decimal())?>
</div>
</div>
@endif
<div class="row">
<div class="col-md-12">
<div class="progress" data-tippy-content="<?=sprintf($tpl->__("text.percent_complete"), format($percentDone)->decimal())?>">
<div class="progress-bar progress-bar-success"
role="progressbar"
aria-valuenow="{{ $percentDone }}"
aria-valuemin="0" aria-valuemax="100"
style="width: {{ $percentDone }}%; {{ $progressColor !== 'default' ? ' background: #'.$progressColor.'; ' : '' }}">
<span class="sr-only">{{ sprintf($tpl->__("text.percent_complete"), format($percentDone)->decimal()) }}</span>
</div>
</div>
</div>
</div>
<script>
// Idempotent init — a bare tippy('[data-tippy-content]') here re-instanced
// every tooltip on the page each time a milestone card rendered (flashing
// + duplicate header tooltips). initTooltips skips already-instanced els.
window.leantime?.initTooltips?.();
</script>
@endfragment
</div>

View File

@@ -0,0 +1,87 @@
@php
/**
* Quick-add form partial for Kanban columns
*
* @var int $statusId - Status column ID
* @var string|null $swimlaneKey - Swimlane identifier
* @var bool $isEmpty - Whether column is empty
* @var array|null $reopenState - Session flash data
*/
$isActive = !empty($reopenState)
&& $reopenState['status'] == $statusId
&& ($reopenState['swimlane'] ?? null) == ($swimlaneKey ?? null);
$savedHeadline = $isActive ? ($reopenState['headline'] ?? '') : '';
$hasError = $isActive && !empty($reopenState['error']);
@endphp
<div class="quickaddContainer tw-mb-s {{ $isEmpty ? 'quickaddContainer--empty' : '' }}" data-status="{{ $statusId }}" data-swimlane="{{ $swimlaneKey ?? '' }}">
<a href="javascript:void(0);"
class="quickAddLink {{ $isEmpty ? 'empty-state' : 'inline-add' }}"
onclick="leantime.kanbanController.toggleQuickAdd(this)"
aria-expanded="{{ $isActive ? 'true' : 'false' }}"
aria-controls="quickadd-form-{{ $statusId }}-{{ $swimlaneKey ?? 'default' }}"
style="{{ $isActive ? 'display:none;' : '' }}">
<i class="fa-solid fa-plus"></i>
<span>Add To-Do</span>
</a>
<form method="post"
class="quickAddForm {{ $isActive ? 'active' : '' }}"
id="quickadd-form-{{ $statusId }}-{{ $swimlaneKey ?? 'default' }}"
data-quickadd-form
style="{{ $isActive ? '' : 'display:none;' }}"
data-submitting="false">
<input type="hidden" name="quickadd" value="1" />
<input type="hidden" name="status" value="{{ $statusId }}" />
<input type="hidden" name="swimlane" value="{{ $swimlaneKey ?? '' }}" />
<input type="hidden" name="groupBy" value="{{ $currentGroupBy ?? '' }}" />
<input type="hidden" name="milestone" value="{{ $searchCriteria['milestone'] ?? '' }}" />
<input type="hidden" name="sprint" value="{{ session('currentSprint') ?? '' }}" />
<input type="hidden" name="stay_open" value="0" data-stay-open-input />
@if (! empty($programBoard) && ! empty($availableProjects))
{{-- Program board: a new task must belong to exactly one child project. --}}
<div class="form-group">
<select name="quickaddProjectId" class="form-control" required aria-label="{{ __('label.project') }}">
<option value="">{{ __('label.project') }}</option>
@foreach ($availableProjects as $quickAddProjectId => $quickAddProjectName)
<option value="{{ $quickAddProjectId }}">{{ $tpl->escape($quickAddProjectName) }}</option>
@endforeach
</select>
</div>
@endif
<div class="form-group">
<label for="headline-{{ $statusId }}-{{ $swimlaneKey ?? 'default' }}" class="sr-only">Task name</label>
<input type="text"
name="headline"
id="headline-{{ $statusId }}-{{ $swimlaneKey ?? 'default' }}"
class="form-control quickAddInput {{ $hasError ? 'error' : '' }}"
placeholder="What are you working on? ↵"
value="{{ htmlspecialchars($savedHeadline) }}"
{{ $isActive ? 'autofocus' : '' }}
data-quickadd-input />
@if ($hasError)
<div class="error-message" role="alert">{{ htmlspecialchars($reopenState['error']) }}</div>
@endif
<div id="quick-add-help-{{ $statusId }}-{{ $swimlaneKey ?? 'default' }}" class="sr-only">
Press Enter to save and close. Press Shift plus Enter to save and add another task. Press Escape to cancel.
</div>
</div>
<div class="formButtonContainer">
<x-global::forms.button inputType="submit" contentRole="primary" onclick="this.closest('form').dataset.submitting = 'true'; this.closest('form').querySelector('[data-stay-open-input]').value = '0';">Save</x-global::forms.button>
<x-global::forms.button inputType="button" contentRole="secondary"
onclick="leantime.kanbanController.toggleQuickAdd(this.closest('.quickaddContainer').querySelector('.quickAddLink'))">
Cancel
</x-global::forms.button>
<i class="fa fa-circle-question"
data-tippy-content="<strong>Keyboard Shortcuts:</strong><br>Enter: Save and close<br>Shift+Enter: Save and add another<br>Esc: Cancel"
tabindex="0"
aria-label="Keyboard shortcuts help"></i>
</div>
</form>
</div>

View File

@@ -0,0 +1,163 @@
<ul class="sortableTicketList" style="margin-bottom:120px;">
<li class="">
<a href="javascript:void(0);" class="quickAddLink" id="subticket_new_link" onclick="jQuery('#subticket_new').toggle('fast', function() {jQuery(this).find('input[name=headline]').focus();}); jQuery(this).toggle('fast');"><i class="fas fa-plus-circle"></i> {{ __("links.add_task") }}</a>
<div class="ticketBox hideOnLoad" id="subticket_new" >
<form method="post" class="form-group"
hx-post="{{ BASE_URL }}/tickets/subtasks/save?ticketId={{ $ticket->id }}"
hx-indicator=".htmx-indicator-small"
hx-target="#ticketSubtasks">
<input type="hidden" value="new" name="subtaskId" />
<input type="hidden" value="1" name="subtaskSave" />
<input name="headline" type="text" title="{{ __("label.headline") }}" style="width:100%" placeholder="{{ __("input.placeholders.what_are_you_working_on") }}" />
<input type="submit" value="{{ __("buttons.save") }}" name="quickadd" />
<div class="htmx-indicator-small">
<x-global::loader id="loadingthis" size="25px" />
</div>
<input type="hidden" name="dateToFinish" id="dateToFinish" value="" />
<input type="hidden" name="status" value="3" />
<input type="hidden" name="sprint" value="{{ session("currentSprint") }}" />
<x-global::forms.button tag="a" link="javascript:void(0);" onclick="jQuery('#subticket_new').toggle('fast'); jQuery('#subticket_new_link').toggle('fast');" contentRole="tertiary">
{{ __("links.cancel") }}
</x-global::forms.button>
</form>
<div class="clearfix"></div>
</div>
</li>
@php
$sumPlanHours = 0;
$sumEstHours = 0;
@endphp
@foreach ($ticketSubtasks as $subticket)
@php
$sumPlanHours = $sumPlanHours + $subticket['planHours'];
$sumEstHours = $sumEstHours + $subticket['hourRemaining'];
if ($subticket['dateToFinish'] == "0000-00-00 00:00:00" || $subticket['dateToFinish'] == "1969-12-31 00:00:00") {
$date = $tpl->__("text.anytime");
} else {
$date = format($subticket['dateToFinish'])->date();
}
@endphp
<li class="ui-state-default" id="ticket_{{ $subticket['id'] }}" >
<div class="ticketBox fixed priority-border-{{ $subticket['priority'] }}" data-val="{{ $subticket['id'] }}" >
<div class="row">
<div class="col-md-12" style="padding:0 15px;">
@if($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer" >
<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><a href="javascript:void(0);" hx-delete="{{ BASE_URL }}/tickets/subtasks/delete?ticketId={{ $subticket["id"] }}&parentTicket={{ $ticket->id }}" hx-target="#ticketSubtasks" class="delete"><i class="fa fa-trash"></i> {{ __("links.delete_todo") }}</a></li>
</ul>
</div>
@endif
<a href="#/tickets/showTicket/{{ $subticket['id'] }}">{{ $subticket['headline'] }}</a>
</div>
</div>
<div class="row">
<div class="col-md-9" style="padding:0 15px;">
<div class="row">
<div class="col-md-4">
{{ __("label.due") }}<input type="text" title="{{ __("label.due") }}" value="{{ $date }}" class="duedates secretInput quickDueDates" data-id="{{ $subticket['id'] }}" name="date" />
</div>
<div class="col-md-4">
{{ __("label.planned_hours") }}<input type="text" value="{{ $subticket['planHours'] }}" name="planHours" data-label="planHours-{{ $subticket['id'] }}" class="small-input secretInput asyncInputUpdate" style="width:40px"/>
</div>
<div class="col-md-4">
{{ __("label.estimated_hours_remaining") }}<input type="text" value="{{ $subticket['hourRemaining'] }}" name="hourRemaining" data-label="hourRemaining-{{ $subticket['id'] }}" class="small-input secretInput asyncInputUpdate" style="width:40px"/>
</div>
</div>
</div>
<div class="col-md-3" style="padding-top:3px;" >
<div class="right">
<div class="dropdown ticketDropdown effortDropdown show">
<a class="dropdown-toggle f-left label-default effort" href="javascript:void(0);" role="button" id="effortDropdownMenuLink{{ $subticket['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@if ($subticket['storypoints'] != '' && $subticket['storypoints'] > 0 && isset($efforts[$subticket['storypoints']]))
{{ $efforts[$subticket['storypoints']] }}
@else
{{ __("label.story_points_unkown") }}
@endif
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $subticket['id'] }}">
<li class="nav-header border">{{ __("dropdown.how_big_todo") }}</li>
@foreach($efforts as $effortKey => $effortValue)
<li class='dropdown-item'>
<a href='javascript:void(0);' data-value='{{ $subticket['id'] }}_{{ $effortKey }}' id='ticketEffortChange{{ $subticket['id'] . $effortKey }}'> {{ $effortValue }}</a>
</li>
@endforeach
</ul>
</div>
@php
if (isset($statusLabels[$subticket['status']])) {
$class = $statusLabels[$subticket['status']]["class"];
$name = $statusLabels[$subticket['status']]["name"];
} else {
$class = 'label-important';
$name = 'new';
}
@endphp
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a class="dropdown-toggle f-left status {{ $class }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $subticket['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{$name }}
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $subticket['id'] }}">
<li class="nav-header border">{{ __('dropdown.choose_status') }}</li>
@foreach ($statusLabels as $key => $label)
<li class='dropdown-item'>
<a href='javascript:void(0);' class='{{ $label["class"] }}' data-label='{{ $label["name"] }}' data-value='{{ $subticket['id'] }}_{{ $key }}_{{ $label["class"] }}' id='ticketStatusChange{{ $subticket['id'] . $key }}' >{{ $label["name"] }}</a>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
</li>
@endforeach
</ul>
<script>
jQuery(document).ready(function(){
<?php if ($login::userIsAtLeast($roles::$editor)) { ?>
leantime.ticketsController.initAsyncInputChange();
leantime.ticketsController.initDueDateTimePickers();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initStatusDropdown();
<?php } else { ?>
leantime.authController.makeInputReadonly(".nyroModalCont");
<?php } ?>
});
</script>

View File

@@ -0,0 +1,139 @@
<div class="ticketBox fixed priority-border-{{ $row['priority'] }}" data-val="{{ $row['id'] }}">
<div class="row">
<div class="col-md-8 titleContainer">
@if($cardType == "full")
<small>{{ $row['projectName'] }}</small><br />
@if($row['dependingTicketId'] > 0)
<a href="#/tickets/showTicket/{{ $row['dependingTicketId'] }}">{{ $row['parentHeadline'] }}</a> //
@endif
@endif
<strong><a href="#/tickets/showTicket/{{ $row['id'] }}" >{{ $row['headline'] }}</a></strong>
</div>
<div class="col-md-4 timerContainer" style="padding:5px 15px;" id="timerContainer-{{ $row['id'] }}">
@include("tickets::partials.ticketsubmenu", ["ticket" => $row, "onTheClock" => $onTheClock])
@if($cardType == "full")
<div class="scheduler pull-right">
@if( $row['editFrom'] != "0000-00-00 00:00:00" && $row['editFrom'] != "1969-12-31 00:00:00")
<i class="fa-solid fa-calendar-check infoIcon tw-mr-xs" style="color:var(--accent2)" data-tippy-content="{{ __('text.schedule_to_start_on') }} {{ format($row['editFrom'])->date() }}"></i>
@else
<i class="fa-regular fa-calendar-xmark infoIcon tw-mr-xs" data-tippy-content="{{ __('text.not_scheduled_drag_ai') }}"></i>
@endif
</div>
@endif
</div>
</div>
<div class="row">
<div class="col-md-4" style="padding:0 15px;">
@if($cardType == "full")
<i class="fa-solid fa-business-time infoIcon" data-tippy-content=" {{ __("label.due") }}"></i>
<input type="text" title="{{ __("label.due") }}" value="{{ format($row['dateToFinish'])->date(__("text.anytime")) }}" class="duedates secretInput" style="margin-left:0px;" data-id="{{ $row['id'] }}" name="date" />
@endif
</div>
<div class="col-md-8 dropdownContainer" style="padding-top:5px;">
<div class="dropdown ticketDropdown statusDropdown colorized show right ">
<a class="dropdown-toggle f-left status {{ $statusLabels[$row['status']]["class"] }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if(isset($statusLabels[$row['status']]))
{{ $statusLabels[$row['status']]["name"] }}
@else
unknown
@endif
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu pull-right" aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.choose_status") }}</li>
@foreach ($statusLabels as $key => $label)
<li class='dropdown-item'>
<a href='javascript:void(0);'
class='{{ $label["class"] }}'
data-label='{{ $label["name"] }}'
data-value='{{ $row['id'] }}_{{ $key }}_{{ $label["class"] }}'
id='ticketStatusChange{{$row['id'] . $key }}'>
{{ $label["name"] }}
</a>
</li>
@endforeach
</ul>
</div>
<?php /*
<div class="dropdown ticketDropdown effortDropdown show right">
<a class="dropdown-toggle f-left label-default effort" href="javascript:void(0);" role="button" id="effortDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if ($row['storypoints'] != '' && $row['storypoints'] > 0)
{{ $efforts["" . $row['storypoints']] ?? $row['storypoints'] }}
@else
{{ __("label.story_points_unkown") }}
@endif
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.how_big_todo") }}</li>
@foreach($efforts as $effortKey => $effortValue)
<li class='dropdown-item'>
<a href='javascript:void(0);'
data-value='{{ $row['id'] . "_" . $effortKey }}'
id='ticketEffortChange{{ $row['id'] . $effortKey }}'>
{{ $effortValue }}
</a>
</li>
@endforeach
</ul>
</div>
*/ ?>
@if($cardType == "full")
<div class="dropdown ticketDropdown milestoneDropdown colorized show right tw-mr-sm">
<a style="background-color:{{ $row['milestoneColor'] }}"
class="dropdown-toggle f-left label-default milestone"
href="javascript:void(0);"
role="button" id="milestoneDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<span class="text">
@if($row['milestoneid'] != "" && $row['milestoneid'] != 0)
{{ $row['milestoneHeadline'] }}
@else
{{ __("label.no_milestone") }}
@endif
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu pull-right" aria-labelledby="milestoneDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __("dropdown.choose_milestone") }}</li>
<li class='dropdown-item'>
<a style='background-color:#b0b0b0'
href='javascript:void(0);'
data-label="{{__("label.no_milestone") }}"
data-value='{{ $row['id'] }}_0_#b0b0b0'>
{{ __("label.no_milestone") }}
</a>
</li>
@if(isset($milestones))
@foreach($milestones as $milestone)
@if(is_object($milestone))
<li class='dropdown-item'>
<a href='javascript:void(0);'
data-label='{{ $milestone->headline }}'
data-value='{{ $row['id'] }}_{{ $milestone->id }}_{{ $milestone->tags }}'
id='ticketMilestoneChange{{ $row['id'] . $milestone->id }}'
style='background-color:{{ $milestone->tags }}'>
{{ $milestone->headline }}
</a>
</li>
@endif
@endforeach
@endif
</ul>
</div>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
@props([
'ticket' => false,
'onTheClock' => false,
'allowSubtaskCreation' => false
])
@if ($login::userIsAtLeast(\Leantime\Domain\Auth\Models\Roles::$editor))
<div class="inlineDropDownContainer" style="float:right;">
<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.todo") }}</li>
@dispatchEvent("beforeShowTicket", ["ticket"=>$ticket])
<li><a href="#/tickets/showTicket/{{ $ticket["id"] }}" class=''><i class="fa fa-edit"></i> {{ __("links.edit_todo") }}</a></li>
@dispatchEvent("beforeMoveTicket", ["ticket"=>$ticket])
<li><a href="#/tickets/moveTicket/{{ $ticket["id"] }}" class=""><i class="fa-solid fa-arrow-right-arrow-left"></i> {{ __("links.move_todo") }}</a></li>
@if($allowSubtaskCreation)
<li><a href="javascript:void(0);" onclick="jQuery('#subtask-form-{{$ticket['id']}}').toggle();"
class="add-subtask-link">
<i class="fa-solid fa-diagram-predecessor"></i> Add Subtask</a></li>
@endif
@dispatchEvent("beforeDeleteTicket", ["ticket"=>$ticket])
<li><a href="#/tickets/delTicket/{{ $ticket["id"] }}" class="delete"><i class="fa fa-trash"></i> {{ __("links.delete_todo") }}</a></li>
@dispatchEvent("submenuSection", ["ticket"=>$ticket])
<li class="nav-header border">{{ __("subtitles.track_time") }}</li>
@dispatchEvent("beforeTimer", ["ticket"=>$ticket])
<li class="timerContainer tw-px-[10px]">
@include('tickets::partials.timerButton', ['parentTicketId' => $ticket['id'], 'onTheClock' => $onTheClock, 'style'=> 'full'])
</li>
@dispatchEvent("end")
</ul>
</div>
@endif

View File

@@ -0,0 +1,78 @@
@props([
'parentTicketId' => false,
'onTheClock' => false,
'style' => 'simple' //simple just the button, full witrh button text
])
<div id="timer-button-container-{{ $parentTicketId }}"
hx-get="{{BASE_URL}}/tickets/timerButton/get-status-button/{{ $parentTicketId }}"
hx-trigger="timerUpdate from:body"
hx-swap="outerHTML"
class="tw-relative timerContainer">
@if ($onTheClock === false)
<a href="javascript:void(0);" data-value="{{ $parentTicketId }}"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/start-timer/"
hx-target="#timerHeadMenu"
hx-swap="outerHTML"
onclick="this.classList.add('starting');"
hx-vals='{"ticketId": "{{ $parentTicketId }}", "action":"start"}'
data-tippy-content="{{ __("links.start_work") }}">
<span class="fa-regular fa-circle-play" style="font-size:18px; padding-top:3px;"></span>
@if($style=="full")
{{ __("links.start_work") }}
@endif
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] == $parentTicketId)
<a href="javascript:void(0);" data-value="{{ $parentTicketId }}"
hx-trigger="click delay:500ms"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/stop-timer/"
hx-target="#timerHeadMenu"
hx-vals='{"ticketId": "{{ $parentTicketId }}", "action":"stop"}'
hx-swap="outerHTML"
onclick="this.classList.add('stopped');"
data-tippy-content="@if (is_array($onTheClock) == true) {!! strip_tags(sprintf(__("links.stop_work_started_at"), dtHelper()::createFromTimestamp($onTheClock["since"], 'UTC')->setToUserTimezone()->format(__("language.timeformat")))) !!} @else {!! strip_tags(sprintf(__("links.stop_work_started_at"), dtHelper()::now()->setToUserTimezone()->format(__("language.timeformat")))) !!} @endif"
>
<span class="fa-regular fa-circle-stop" style="font-size:18px; padding-top:3px;"></span>
@if($style=="full")
@if (is_array($onTheClock) == true)
{!! sprintf(__("links.stop_work_started_at"), dtHelper()::createFromTimestamp($onTheClock["since"], 'UTC')->setToUserTimezone()->format(__("language.timeformat"))) !!}
@else
{!! sprintf(__("links.stop_work_started_at"), dtHelper()::now()->setToUserTimezone()->format(__("language.timeformat"))) !!}
@endif
@endif
<!-- These elements will be added dynamically when the timer is stopped -->
<div class="success-circle"></div>
<div class="particles-container">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<div class="particle particle-4"></div>
<div class="particle particle-5"></div>
<div class="particle particle-6"></div>
<div class="particle particle-7"></div>
<div class="particle particle-8"></div>
</div>
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] != $parentTicketId)
<span class='working'>
@if($style=="full")
{{ __("text.timer_set_other_todo") }}
@else
<span class="fa-solid fa-user-clock" style="font-size:16px; padding-top:3px; color:var(--grey);" data-tippy-content="{{ __("text.timer_set_other_todo") }}"></span>
@endif
</span>
@endif
</div>

View File

@@ -0,0 +1,43 @@
@props([
'parentTicketId' => false,
'onTheClock' => false
])
<li id="timerContainer-{{ $parentTicketId }}"
hx-get="{{BASE_URL}}/tickets/timerButton/get-status/{{ $parentTicketId }}"
hx-trigger="timerUpdate from:body"
hx-swap="outerHTML"
class="timerContainer">
@if ($onTheClock === false)
<a href="javascript:void(0);" data-value="{{ $parentTicketId }}"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/start-timer/"
hx-target="#timerHeadMenu"
hx-swap="outerHTML"
hx-vals='{"ticketId": "{{ $parentTicketId }}", "action":"start"}'>
<span class="fa-regular fa-clock"></span> {{ __("links.start_work") }}
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] == $parentTicketId)
<a href="javascript:void(0);" data-value="{{ $parentTicketId }}"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/stop-timer/"
hx-target="#timerHeadMenu"
hx-vals='{"ticketId": "{{ $parentTicketId }}", "action":"stop"}'
hx-swap="outerHTML">
<span class="fa fa-stop"></span>
@if (is_array($onTheClock) == true)
{!! sprintf(__("links.stop_work_started_at"), date(__("language.timeformat"), $onTheClock["since"])) !!}
@else
{!! sprintf(__("links.stop_work_started_at"), date(__("language.timeformat"), time())) !!}
@endif
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] != $parentTicketId)
<span class='working'>
{{ __("text.timer_set_other_todo") }}
</span>
@endif
</li>

View File

@@ -0,0 +1,165 @@
@extends($layout)
@section('content')
@php
$milestones = $milestones ?? [];
$timelineTasks = $timelineTasks ?? [];
$roadmapView = session('usersettings.views.roadmap', 'Month');
@endphp
@include('tickets::submodules.timelineHeader')
<div class="maincontent">
@include('tickets::submodules.timelineTabs')
<div class="maincontentinner">
<div class="row">
<div class="col-md-4">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
<div class="col-md-4">
</div>
<div class="col-md-4">
<div class="pull-right">
<div class="btn-group dropRight">
@php
$currentView = '';
if ($roadmapView == 'Day') {
$currentView = __('buttons.day');
} elseif ($roadmapView == 'Week') {
$currentView = __('buttons.week');
} elseif ($roadmapView == 'Month') {
$currentView = __('buttons.month');
}
@endphp
<button class="btn dropdown-toggle" data-toggle="dropdown">{!! __('buttons.timeframe') !!}: <span class="viewText">{{ $currentView }}</span><span class="caret"></span></button>
<ul class="dropdown-menu" id="ganttTimeControl">
<li><a href="javascript:void(0);" data-value="Day" class="{{ $roadmapView == 'Day' ? 'active' : '' }}"> {!! __('buttons.day') !!}</a></li>
<li><a href="javascript:void(0);" data-value="Week" class="{{ $roadmapView == 'Week' ? 'active' : '' }}">{!! __('buttons.week') !!}</a></li>
<li><a href="javascript:void(0);" data-value="Month" class="{{ $roadmapView == 'Month' ? 'active' : '' }}">{!! __('buttons.month') !!}</a></li>
</ul>
</div>
</div>
</div>
</div>
@php
if (
(is_array($timelineTasks) && count($timelineTasks) == 0) ||
$timelineTasks == false
) {
echo "<div class='empty' id='emptySprint' style='text-align:center;'>";
echo "<div style='width:30%' class='svgContainer'>";
echo file_get_contents(ROOT.'/dist/images/svg/undraw_adjustments_p22m.svg');
echo '</div>';
echo '<h4>'.__('headlines.no_tickets').'<br /></h4></div>';
}
@endphp
<div class="gantt-wrapper">
<svg id="gantt"></svg>
</div>
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
@if (isset($_GET['showMilestoneModal']))
@php
$modalUrl = $_GET['showMilestoneModal'] == '' ? '' : '/'.(int) $_GET['showMilestoneModal'];
@endphp
leantime.ticketsController.openMilestoneModalManually("{{ BASE_URL }}/tickets/editMilestone{{ $modalUrl }}");
window.history.pushState({},document.title, '{{ BASE_URL }}/tickets/roadmap');
@endif
});
@php
if (count($timelineTasks) > 0) {
@endphp
var tasks = [
@php
$lastMilestoneSortIndex = [];
foreach ($timelineTasks as $mlst) {
if ($mlst->type == 'milestone') {
$lastMilestoneSortIndex[$mlst->id] = ($mlst->sortIndex != '') ? $mlst->sortIndex : 999;
}
}
foreach ($timelineTasks as $mlst) {
$headline = __('label.'.strtolower($mlst->type)).': '.$mlst->headline;
if ($mlst->type == 'milestone') {
$headline .= ' ('.format($mlst->percentDone)->decimal().'% Done)';
}
$color = '#8D99A6';
if ($mlst->type == 'milestone') {
$color = $mlst->tags;
}
$sortIndex = $mlst->sortIndex;
$dependencyList = [];
// Use explicit > 0 checks: new milestones store dependingTicketId as an empty
// string, and under PHP 8 `'' != 0` is true — which pushed an empty dependency and
// skipped the real milestoneid, so a dependency set in table mode never rendered here.
if ((int) $mlst->dependingTicketId > 0) {
$dependencyList[] = $mlst->dependingTicketId;
} elseif ((int) $mlst->milestoneid > 0) {
$dependencyList[] = $mlst->milestoneid;
}
echo "{
id :'".$mlst->id."',
name :".json_encode($headline, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP).",
start :'".(dtHelper()->isValidDateString($mlst->editFrom) ? $mlst->editFrom : dtHelper()->userNow()->addDays(2)->format('Y-m-d'))."',
end :'".(dtHelper()->isValidDateString($mlst->editTo) ? $mlst->editTo : dtHelper()->userNow()->addDays(2)->format('Y-m-d'))."',
progress :'".format($mlst->percentDone)->decimal()."',
dependencies :'".implode(',', $dependencyList)."',
custom_class :'',
type: '".strtolower($mlst->type)."',
bg_color: ".json_encode($color, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP).",
thumbnail: '".BASE_URL.'/api/users?profileImage='.$mlst->editorId."',
sortIndex: ".$sortIndex.'
},';
}
@endphp
];
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initGanttChart(tasks, '{{ $roadmapView }}', false);
@else
leantime.ticketsController.initGanttChart(tasks, '{{ $roadmapView }}', true);
@endif
@php } @endphp
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,171 @@
@extends($layout)
@section('content')
@php
$milestones = $milestones ?? [];
$clients = $clients ?? [];
$clientNameSelected = __('headline.all_clients');
$htmlDropdownClients = '';
foreach ($clients as $client) {
$href = BASE_URL.'/tickets/roadmapAll?clientId='.$client['id'];
$labelActive = '';
if (isset($_GET['clientId']) && $_GET['clientId'] == $client['id']) {
$labelActive = ' class="active"';
$clientNameSelected = $client['name'];
}
$htmlDropdownClients .= "<li><a href='$href' $labelActive> {$client['name']} </a></li>";
}
$roadmapView = session('usersettings.views.roadmap', 'Month');
@endphp
@include('tickets::submodules.portfolioHeader')
<div class="maincontent">
@include('tickets::submodules.portfolioTabs')
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-6">
</div>
<div class="col-md-6">
<div class="pull-right">
<div class="btn-group viewDropDown">
<button class="btn dropdown-toggle" data-toggle="dropdown">{!! __('label.roles.client') !!}: <span class="viewText">{{ $clientNameSelected }}</span><span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href={{ BASE_URL.'/tickets/roadmapAll' }} {{ empty($labelActive) ? "class='active'" : '' }} > {!! __('headline.all_clients') !!} </a></li>
{!! $htmlDropdownClients !!}
</ul>
</div>
<div class="btn-group dropRight">
@php
$currentView = '';
if ($roadmapView == 'Day') {
$currentView = __('buttons.day');
} elseif ($roadmapView == 'Week') {
$currentView = __('buttons.week');
} elseif ($roadmapView == 'Month') {
$currentView = __('buttons.month');
}
@endphp
<button class="btn dropdown-toggle" data-toggle="dropdown">{!! __('buttons.timeframe') !!}: <span class="viewText">{{ $currentView }}</span><span class="caret"></span></button>
<ul class="dropdown-menu" id="ganttTimeControl">
<li><a href="javascript:void(0);" data-value="Day" class="{{ $roadmapView == 'Day' ? 'active' : '' }}"> {!! __('buttons.day') !!}</a></li>
<li><a href="javascript:void(0);" data-value="Week" class="{{ $roadmapView == 'Week' ? 'active' : '' }}">{!! __('buttons.week') !!}</a></li>
<li><a href="javascript:void(0);" data-value="Month" class="{{ $roadmapView == 'Month' ? 'active' : '' }}">{!! __('buttons.month') !!}</a></li>
</ul>
</div>
</div>
</div>
</div>
@php
if (count($milestones) == 0) {
echo "<div class='empty' id='emptySprint' style='text-align:center;'>";
echo "<div style='width:30%' class='svgContainer'>";
echo file_get_contents(ROOT.'/dist/images/svg/undraw_adjustments_p22m.svg');
echo '</div>';
echo '<h4>'.__('headlines.no_milestones').'<br/>
<br />
<a href="'.BASE_URL.'/tickets/editMilestone" class="milestoneModal addCanvasLink btn btn-primary">'.__('links.add_milestone').'</a></h4></div>';
}
@endphp
<div class="gantt-wrapper">
<svg id="gantt"></svg>
</div>
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
@if (isset($_GET['showMilestoneModal']))
@php
$modalUrl = $_GET['showMilestoneModal'] == '' ? '' : '/'.(int) $_GET['showMilestoneModal'];
@endphp
leantime.ticketsController.openMilestoneModalManually("{{ BASE_URL }}/tickets/editMilestone{{ $modalUrl }}");
window.history.pushState({},document.title, '{{ BASE_URL }}/tickets/roadmap');
@endif
});
@if (count($milestones) > 0)
var tasks = [
@php
foreach ($milestones as $mlst) {
$headline = '['.$mlst->projectName.'] ';
$headline .= __('label.'.strtolower($mlst->type)).': '.$mlst->headline;
if ($mlst->type == 'milestone') {
$headline .= ' ('.$mlst->percentDone.'% Done)';
}
$color = '#8D99A6';
if ($mlst->type == 'milestone') {
$color = $mlst->tags;
}
$sortIndex = 0;
if ($mlst->sortIndex != '' && is_numeric($mlst->sortIndex)) {
$sortIndex = $mlst->sortIndex;
}
$dependencyList = [];
if ($mlst->milestoneid != 0) {
$dependencyList[] = $mlst->milestoneid;
}
if ($mlst->dependingTicketId != 0) {
$dependencyList[] = $mlst->dependingTicketId;
}
echo "{
projectName :'".$mlst->projectName."',
id :'".$mlst->id."',
name :".json_encode($headline).",
start :'".(($mlst->editFrom != '0000-00-00 00:00:00' && ! str_starts_with($mlst->editFrom, '1969-12-31')) ? $mlst->editFrom : date('Y-m-d', strtotime('+1 day', time())))."',
end :'".(($mlst->editTo != '0000-00-00 00:00:00' && ! str_starts_with($mlst->editTo, '1969-12-31')) ? $mlst->editTo : date('Y-m-d', strtotime('+1 week', time())))."',
progress :'".$mlst->percentDone."',
dependencies :'".implode(',', $dependencyList)."',
custom_class :'',
type: '".strtolower($mlst->type)."',
bg_color: '".$color."',
thumbnail: '".BASE_URL.'/api/users?profileImage='.$mlst->editorId."',
sortIndex: ".$sortIndex.'
},';
}
@endphp
];
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initGanttChart(tasks, '{{ $roadmapView }}', false);
@else
leantime.ticketsController.initGanttChart(tasks, '{{ $roadmapView }}', true);
@endif
@endif
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,393 @@
@extends($layout)
@section('content')
@php
$allTicketGroups = $allTickets;
$todoTypeIcons = $ticketTypeIcons;
$statusLabels = $allTicketStates;
$newField = $newField ?? [];
$numberofColumns = count($allTicketStates) - 1;
$size = floor(100 / $numberofColumns);
@endphp
{!! $tpl->displayNotification() !!}
@include('tickets::submodules.ticketHeader')
<div class="maincontent">
@include('tickets::submodules.ticketBoardTabs')
<div class="maincontentinner">
{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs). Only the table-specific DataTables buttons remain
here, right-aligned above the table. --}}
<div class="row">
<div class="col-md-12">
<div class="pull-right">
@dispatchEvent('filters.afterRighthandSectionOpen')
<div id="tableButtons" style="display:inline-block"></div>
@dispatchEvent('filters.beforeRighthandSectionClose')
</div>
</div>
</div>
<div class="clearfix" style="margin-bottom: 20px;"></div>
@if (isset($availableProjects))
{{-- Program (cross-project) board: consistent inline quick-add with a required
project picker, matching the kanban/list add affordance. --}}
<form action="" method="post" class="tw-mb-m" style="display:flex; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<input type="text" name="headline" placeholder="{{ __('input.placeholders.create_task') }}" style="flex:1 1 280px; min-width:240px;" />
<select name="quickaddProjectId" class="form-control" required style="width:auto;" aria-label="{{ __('label.project') }}">
<option value="">{{ __('label.project') }}</option>
@foreach ($availableProjects as $quickAddProjectId => $quickAddProjectName)
<option value="{{ $quickAddProjectId }}">{{ $tpl->escape($quickAddProjectName) }}</option>
@endforeach
</select>
<input type="hidden" name="sprint" value="{{ $currentSprint }}" />
<input type="hidden" name="milestone" value="{{ htmlspecialchars((string) ($searchCriteria['milestone'] ?? ''), ENT_QUOTES, 'UTF-8') }}" />
<input type="hidden" name="quickadd" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveTicket" />
</form>
@endif
@if (isset($allTicketGroups['all']))
@php $allTickets = $allTicketGroups['all']['items']; @endphp
@endif
@foreach ($allTicketGroups as $group)
@if ($group['label'] != 'all')
<h5 class="accordionTitle {{ $group['class'] }}" @if (!empty($group['color'])) style="color:{{ htmlspecialchars($group['color']) }}" @endif id="accordion_link_{{ $group['id'] }}">
<a href="javascript:void(0)" class="accordion-toggle" id="accordion_toggle_{{ $group['id'] }}" onclick="leantime.snippets.accordionToggle('{{ $group['id'] }}');">
<i class="fa fa-angle-down"></i>{{ $group['label'] }}({{ count($group['items']) }})
</a><br />
<small style="padding-left:20px; color:var(--primary-font-color); font-size:var(--font-size-s);">{{ $group['more-info'] }}</small>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-{{ $group['id'] }}">
@endif
@php $allTickets = $group['items']; @endphp
@dispatchEvent('allTicketsTable.before', ['tickets' => $allTicketGroups])
<table class="table table-bordered display ticketTable " style="width:100%">
<colgroup>
<col class="con1">
<col class="con0" style="max-width:200px;">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
</colgroup>
@dispatchEvent('allTicketsTable.beforeHead', ['tickets' => $allTickets])
<thead>
@dispatchEvent('allTicketsTable.beforeHeadRow', ['tickets' => $allTickets])
<tr>
<th class="id-col">{!! __('label.id') !!}</th>
<th style="max-width: 350px;">{!! __('label.title') !!}</th>
<th class="status-col">{!! __('label.todo_status') !!}</th>
<th class="milestone-col">{!! __('label.milestone') !!}</th>
<th class="effort-col">{!! __('label.effort') !!}</th>
<th class="priority-col">{!! __('label.priority') !!}</th>
<th class="user-col">{!! __('label.editor') !!}.</th>
<th class="sprint-col">{!! __('label.sprint') !!}</th>
<th class="tags-col">{!! __('label.tags') !!}</th>
<th class="duedate-col">{!! __('label.due_date') !!}</th>
<th class="planned-hours-col">{!! __('label.planned_hours') !!}</th>
<th class="remaining-hours-col">{!! __('label.estimated_hours_remaining') !!}</th>
<th class="booked-hours-col">{!! __('label.booked_hours') !!}</th>
<th class="no-sort"></th>
</tr>
@dispatchEvent('allTicketsTable.afterHeadRow', ['tickets' => $allTickets])
</thead>
@dispatchEvent('allTicketsTable.afterHead', ['tickets' => $allTickets])
<tbody>
@dispatchEvent('allTicketsTable.beforeFirstRow', ['tickets' => $allTickets])
@foreach ($allTickets as $rowNum => $row)
<tr style="height:1px;">
@dispatchEvent('allTicketsTable.afterRowStart', ['rowNum' => $rowNum, 'tickets' => $allTickets])
<td data-order="{{ $row['id'] }}">
#{{ $row['id'] }}
</td>
<td data-order="{{ $row['headline'] }}">
@if ($row['dependingTicketId'] > 0)
<small><a href="#/tickets/showTicket/{{ $row['dependingTicketId'] }}" preload="mouseover">{{ $row['parentHeadline'] }}</a></small> //<br />
@endif
<a class='ticketModal' href="#/tickets/showTicket/{{ $row['id'] }}" preload="mouseover">{{ $row['headline'] }}</a></td>
@php
// On a program (cross-project) board each row must render and edit
// with statuses from ITS OWN project, never a shared set, so a status
// change always writes a key valid in that project.
$rowStatusLabels = (isset($statusLabelsByProject) && isset($statusLabelsByProject[$row['projectId']]))
? $statusLabelsByProject[$row['projectId']]
: $statusLabels;
if (isset($rowStatusLabels[$row['status']])) {
$class = $rowStatusLabels[$row['status']]['class'];
$name = $rowStatusLabels[$row['status']]['name'];
$sortKey = $rowStatusLabels[$row['status']]['sortKey'];
} else {
$class = 'label-important';
$name = 'new';
$sortKey = 0;
}
@endphp
<td data-order="{{ $name }}">
<div class="dropdown ticketDropdown statusDropdown colorized show ">
<a class="dropdown-toggle status {{ $class }} f-left" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $name }}</span>
&nbsp;<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>
@php
foreach ($rowStatusLabels as $key => $label) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='".$label['class']."' data-label='".$tpl->escape($label['name'])."' data-value='".$row['id'].'_'.$key.'_'.$label['class']."' id='ticketStatusChange".$row['id'].$key."' >".$tpl->escape($label['name']).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
@php
if ($row['milestoneid'] != '' && $row['milestoneid'] != 0) {
$milestoneHeadline = $tpl->escape($row['milestoneHeadline']);
} else {
$milestoneHeadline = __('label.no_milestone');
}
@endphp
<td data-order="{{ $milestoneHeadline }}">
<div class="dropdown ticketDropdown milestoneDropdown colorized show">
<a style="background-color:{{ $tpl->escape($row['milestoneColor']) }}" class="dropdown-toggle label-default milestone f-left" href="javascript:void(0);" role="button" id="milestoneDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $milestoneHeadline }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="milestoneDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_milestone') !!}</li>
<li class='dropdown-item'><a style='background-color:#b0b0b0' href='javascript:void(0);' data-label="{!! __('label.no_milestone') !!}" data-value='{{ $row['id'].'_0_#b0b0b0' }}'> {!! __('label.no_milestone') !!} </a></li>
@php
foreach ($milestones as $milestone) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='".$tpl->escape($milestone->headline)."' data-value='".$row['id'].'_'.$milestone->id.'_'.$tpl->escape($milestone->tags)."' id='ticketMilestoneChange".$row['id'].$milestone->id."' style='background-color:".$tpl->escape($milestone->tags)."'>".$tpl->escape($milestone->headline).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="{{ $row['storypoints'] ? $efforts[''.$row['storypoints'].''] ?? '?' : __('label.story_points_unkown') }}">
<div class="dropdown ticketDropdown effortDropdown show">
<a class="dropdown-toggle label-default effort f-left" href="javascript:void(0);" role="button" id="effortDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@if ($row['storypoints'] != '' && $row['storypoints'] > 0){{ $efforts[''.$row['storypoints']] ?? $row['storypoints'] }}@else{!! __('label.story_points_unkown') !!}@endif</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.how_big_todo') !!}</li>
@php
foreach ($efforts as $effortKey => $effortValue) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-value='".$row['id'].'_'.$effortKey."' id='ticketEffortChange".$row['id'].$effortKey."'>".$effortValue.'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="@php if ($row['priority'] != '' && $row['priority'] > 0) { echo $priorities[$row['priority']] ?? __('label.priority_unkown'); } else { echo __('label.priority_unkown'); } @endphp">
<div class="dropdown ticketDropdown priorityDropdown show">
<a class="dropdown-toggle label-default priority priority-bg-{{ $row['priority'] }} f-left" href="javascript:void(0);" role="button" id="priorityDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@php if ($row['priority'] != '' && $row['priority'] > 0) { echo $priorities[$row['priority']] ?? __('label.priority_unkown'); } else { echo __('label.priority_unkown'); } @endphp</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="priorityDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.select_priority') !!}</li>
@php
foreach ($priorities as $priorityKey => $priorityValue) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='priority-bg-".$priorityKey."' data-value='".$row['id'].'_'.$priorityKey."' id='ticketPriorityChange".$row['id'].$priorityKey."'>".$priorityValue.'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="{{ $row['editorFirstname'] != '' ? $tpl->escape($row['editorFirstname']) : __('dropdown.not_assigned') }}">
<div class="dropdown ticketDropdown userDropdown noBg show f-left">
<a class="dropdown-toggle" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text" style="display:inline-flex; align-items:center; gap:6px;">
@php
if ($row['editorFirstname'] != '') {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL.'/api/users?profileImage='.$row['editorId']."' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'>".$tpl->escape($row['editorFirstname']).'</span>';
} else {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL."/api/users?profileImage=false' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'>".__('dropdown.not_assigned').'</span>';
}
if (! empty($row['collaboratorPreview'])) {
echo "<span class='ticket-collaborators' style='display:inline-flex; align-items:center; margin-left:4px;'>";
foreach ($row['collaboratorPreview'] as $index => $collaboratorId) {
$offset = $index > 0 ? 'margin-left:-8px;' : '';
echo "<span class='ticket-collaborator-avatar' title='".__('label.collaborators')."' style='display:inline-flex; width:20px; height:20px; border-radius:999px; border:2px solid var(--main-background-color, #fff); overflow:hidden; ".$offset."'><img src='".BASE_URL.'/api/users?profileImage='.$collaboratorId."' width='20' height='20' style='display:block; width:20px; height:20px;'/></span>";
}
if (($row['collaboratorOverflow'] ?? 0) > 0) {
echo "<span class='ticket-collaborator-more' title='".__('label.collaborators')."' style='display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px; margin-left:4px; border-radius:999px; background:var(--accent-color, #e9ecef); color:var(--secondary-font-color, #333); font-size:11px; line-height:20px;'>+".(int) $row['collaboratorOverflow'].'</span>';
}
echo '</span>';
}
@endphp
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='{!! __('label.not_assigned_to_user') !!}' data-value='{{ $row['id'].'_0_0' }}' id='userStatusChange{{ $row['id'] }}0' >{!! __('label.not_assigned_to_user') !!}</a>
</li>
@php
foreach ($users as $user) {
echo "<li class='dropdown-item'>";
echo "<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>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
@php
if ($row['sprint'] != '' && $row['sprint'] != 0 && $row['sprint'] != -1) {
$sprintHeadline = $tpl->escape($row['sprintName']);
} else {
$sprintHeadline = __('label.not_assigned_to_sprint');
}
@endphp
<td data-order="{{ $sprintHeadline }}">
<div class="dropdown ticketDropdown sprintDropdown show">
<a class="dropdown-toggle label-default sprint f-left" href="javascript:void(0);" role="button" id="sprintDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $sprintHeadline }}</span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="sprintDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_sprint') !!}</li>
<li class='dropdown-item'><a href='javascript:void(0);' data-label="{!! __('label.not_assigned_to_sprint') !!}" data-value='{{ $row['id'].'_0' }}'> {!! __('label.not_assigned_to_sprint') !!} </a></li>
@if ($sprints)
@foreach ($sprints as $sprint)
<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='{{ $sprint->name }}' data-value='{{ $row['id'].'_'.$sprint->id }}' id='ticketSprintChange{{ $row['id'] }}{{ $sprint->id }}' >{{ $sprint->name }}</a>
</li>
@endforeach
@endif
</ul>
</div>
</td>
<td data-order="{{ $row['tags'] }}">
@if ($row['tags'] != '')
@php $tagsArray = explode(',', $row['tags']); @endphp
<div class='tagsinput readonly'>
@foreach ($tagsArray as $tag)
<span class='tag'><span>{{ $tag }}</span></span>
@endforeach
</div>
@endif
</td>
@php
if ($row['dateToFinish'] == '0000-00-00 00:00:00' || $row['dateToFinish'] == '1969-12-31 00:00:00') {
$date = __('text.anytime');
} else {
$date = new DateTime($row['dateToFinish']);
$date = $date->format(__('language.dateformat'));
}
@endphp
<td data-order="{{ $row['dateToFinish'] }}" >
<input type="text" title="{{ __('label.due') }}" value="{{ $date }}" class="quickDueDates secretInput" data-id="{{ $row['id'] }}" name="date" />
</td>
<td data-order="{{ $row['planHours'] }}">
<input type="text" value="{{ $row['planHours'] }}" name="planHours" class="small-input secretInput" onchange="leantime.ticketsController.updatePlannedHours(this, '{{ $row['id'] }}'); jQuery(this).parent().attr('data-order',jQuery(this).val());" />
</td>
<td data-order="{{ $row['hourRemaining'] }}">
<input type="text" value="{{ $row['hourRemaining'] }}" name="remainingHours" class="small-input secretInput" onchange="leantime.ticketsController.updateRemainingHours(this, '{{ $row['id'] }}');" />
</td>
<td data-order="{{ ($row['bookedHours'] === null || $row['bookedHours'] == '') ? '0' : $row['bookedHours'] }}">
{{ ($row['bookedHours'] === null || $row['bookedHours'] == '') ? '0' : $row['bookedHours'] }}
</td>
<td>
@include('tickets::partials.ticketsubmenu', ['ticket' => $row, 'onTheClock' => $onTheClock])
</td>
@dispatchEvent('allTicketsTable.beforeRowEnd', ['tickets' => $allTickets, 'rowNum' => $rowNum])
</tr>
@endforeach
@dispatchEvent('allTicketsTable.afterLastRow', ['tickets' => $allTickets])
</tbody>
@dispatchEvent('allTicketsTable.afterBody', ['tickets' => $allTickets])
<tfoot align="right">
<tr><td colspan="9"></td><td></td><td></td><td></td><td></td><td></td></tr>
</tfoot>
</table>
@dispatchEvent('allTicketsTable.afterClose', ['tickets' => $allTickets])
@if ($group['label'] != 'all')
</div>
@endif
@endforeach
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
@dispatchEvent('scripts.afterOpen')
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initDueDateTimePickers();
leantime.ticketsController.initUserDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initPriorityDropdown();
leantime.ticketsController.initSprintDropdown();
leantime.ticketsController.initStatusDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.ticketsController.initTicketsTable("{{ $searchCriteria['groupBy'] }}");
@dispatchEvent('scripts.beforeClose')
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,286 @@
@extends($layout)
@section('content')
@php
$allTicketGroups = $allTickets;
$todoTypeIcons = $ticketTypeIcons;
$statusLabels = $allTicketStates;
$numberofColumns = count($allTicketStates) - 1;
$size = floor(100 / $numberofColumns);
@endphp
@include('tickets::submodules.timelineHeader')
<div class="maincontent">
@include('tickets::submodules.timelineTabs')
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-6">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
<div class="col-md-6">
<div class="pull-right">
@dispatchEvent('filters.afterRighthandSectionOpen')
<div id="tableButtons" style="display:inline-block"></div>
@dispatchEvent('filters.beforeRighthandSectionClose')
</div>
</div>
</div>
<div class="clearfix" style="margin-bottom: 20px;"></div>
@if (isset($allTicketGroups['all']))
@php $allTickets = $allTicketGroups['all']['items']; @endphp
@endif
@foreach ($allTicketGroups as $group)
@if ($group['label'] != 'all')
<h5 class="accordionTitle {{ $group['class'] }}" @if (!empty($group['color'])) style="color:{{ htmlspecialchars($group['color']) }}" @endif id="accordion_link_{{ $group['id'] }}">
<a href="javascript:void(0)" class="accordion-toggle" id="accordion_toggle_{{ $group['id'] }}" onclick="leantime.snippets.accordionToggle('{{ $group['id'] }}');">
<i class="fa fa-angle-down"></i>{{ $group['label'] }} ({{ count($group['items']) }})
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-{{ $group['id'] }}">
@endif
@php $allTickets = $group['items']; @endphp
@dispatchEvent('allTicketsTable.before', ['tickets' => $allTickets])
<table class="table table-bordered display ticketTable " style="width:100%">
<colgroup>
<col class="con1" >
<col class="con0">
<col class="con1">
<col class="con0" >
<col class="con1">
<col class="con0">
<col class="con1" >
<col class="con0" >
<col class="con1" >
<col class="con0" >
<col class="con1" >
</colgroup>
@dispatchEvent('allTicketsTable.beforeHead', ['tickets' => $allTickets])
<thead>
@dispatchEvent('allTicketsTable.beforeHeadRow', ['tickets' => $allTickets])
<tr>
<th>{!! __('label.title') !!}</th>
<th>{!! __('label.todo_type') !!}</th>
<th>{!! __('label.progress') !!}</th>
<th class="milestone-col">{!! __('label.dependent_on') !!}</th>
<th>{!! __('label.todo_status') !!}</th>
<th class="user-col">{!! __('label.owner') !!}</th>
<th>{!! __('label.planned_start_date') !!}</th>
<th>{!! __('label.planned_end_date') !!}</th>
<th>{!! __('label.planned_hours') !!}</th>
<th>{!! __('label.estimated_hours_remaining') !!}</th>
<th>{!! __('label.booked_hours') !!}</th>
<th class="no-sort"></th>
</tr>
@dispatchEvent('allTicketsTable.afterHeadRow', ['tickets' => $allTickets])
</thead>
@dispatchEvent('allTicketsTable.afterHead', ['tickets' => $allTickets])
<tbody>
@dispatchEvent('allTicketsTable.beforeFirstRow', ['tickets' => $allTickets])
@foreach ($allTickets as $rowNum => $row)
<tr>
@dispatchEvent('allTicketsTable.afterRowStart', ['rowNum' => $rowNum, 'tickets' => $allTickets])
<td data-order="{{ $row['headline'] }}">
@if ($row['type'] == 'milestone')
<a href="#/tickets/editMilestone/{{ $row['id'] }}">{{ $row['headline'] }}</a>
@else
<a href="#/tickets/showTicket/{{ $row['id'] }}">{{ $row['headline'] }}</a>
@endif
</td>
<td>{!! __('label.'.strtolower($row['type'])) !!}</td>
<td>
@if ($row['type'] == 'milestone')
<div hx-trigger="load"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/progress?milestoneId={{ $row['id'] }}&view=Progress">
<div class="htmx-indicator">
{!! __('label.calculating_progress') !!}
</div>
</div>
@endif
</td>
@php
if ($row['milestoneid'] != '' && $row['milestoneid'] != 0) {
$milestoneHeadline = $tpl->escape($row['milestoneHeadline']);
} else {
$milestoneHeadline = __('label.no_milestone');
}
@endphp
<td data-order="{{ $milestoneHeadline }}">
<div class="dropdown ticketDropdown milestoneDropdown colorized show">
<a style="background-color:{{ $tpl->escape($row['milestoneColor']) }}" class="dropdown-toggle label-default milestone" href="javascript:void(0);" role="button" id="milestoneDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $milestoneHeadline }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="milestoneDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_milestone') !!}</li>
<li class='dropdown-item'><a style='background-color:#b0b0b0' href='javascript:void(0);' data-label="{!! __('label.no_milestone') !!}" data-value='{{ $row['id'].'_0_#b0b0b0' }}'> {!! __('label.no_milestone') !!} </a></li>
@php
foreach ($milestones as $milestone) {
if ($milestone->id != $row['id']) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='".$tpl->escape($milestone->headline)."' data-value='".$row['id'].'_'.$milestone->id.'_'.$tpl->escape($milestone->tags)."' id='ticketMilestoneChange".$row['id'].$milestone->id."' style='background-color:".$tpl->escape($milestone->tags)."'>".$tpl->escape($milestone->headline).'</a>';
echo '</li>';
}
}
@endphp
</ul>
</div>
</td>
@php
if (isset($statusLabels[$row['status']])) {
$class = $statusLabels[$row['status']]['class'];
$name = $statusLabels[$row['status']]['name'];
$sortKey = $statusLabels[$row['status']]['sortKey'];
} else {
$class = 'label-important';
$name = 'new';
$sortKey = 0;
}
@endphp
<td data-order="{{ $sortKey }}">
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a class="dropdown-toggle status {{ $class }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $name }}</span>
&nbsp;<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>
@php
foreach ($statusLabels as $key => $label) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='".$label['class']."' data-label='".$tpl->escape($label['name'])."' data-value='".$row['id'].'_'.$key.'_'.$label['class']."' id='ticketStatusChange".$row['id'].$key."' >".$tpl->escape($label['name']).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="{{ $row['editorFirstname'] != '' ? $tpl->escape($row['editorFirstname']) : __('dropdown.not_assigned') }}">
<div class="dropdown ticketDropdown userDropdown noBg show ">
<a class="dropdown-toggle" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@php
if ($row['editorFirstname'] != '') {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL.'/api/users?profileImage='.$row['editorId']."' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'> ".$tpl->escape($row['editorFirstname']).'</span>';
} else {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL."/api/users?profileImage=false' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'>".__('dropdown.not_assigned').'</span>';
}
@endphp
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@php
foreach ($users as $user) {
echo "<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>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="{{ $row['editFrom'] }}" >
{!! __('label.due_icon') !!}<input type="text" title="{{ __('label.planned_start_date') }}" value="{{ format($row['editFrom'])->date() }}" class="editFromDate secretInput milestoneEditFromAsync fromDateTicket-{{ $row['id'] }}" data-id="{{ $row['id'] }}" name="editFrom" class=""/>
</td>
<td data-order="{{ $row['editTo'] }}" >
{!! __('label.due_icon') !!}<input type="text" title="{{ __('label.planned_end_date') }}" value="{{ format($row['editTo'])->date() }}" class="editToDate secretInput milestoneEditToAsync toDateTicket-{{ $row['id'] }}" data-id="{{ $row['id'] }}" name="editTo" class="" />
</td>
<td data-order="{{ $row['planHours'] }}" >
{{ $row['planHours'] }}
</td>
<td data-order="{{ $row['hourRemaining'] }}" >
{{ $row['hourRemaining'] }}
</td>
<td data-order="{{ $row['bookedHours'] }}" >
{{ $row['bookedHours'] }}
</td>
<td>
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer">
<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.todo') !!}</li>
<li><a href="{{ BASE_URL }}/tickets/editMilestone/{{ $row['id'] }}" class='ticketModal'><i class="fa fa-edit"></i> {!! __('links.edit_milestone') !!}</a></li>
<li><a href="{{ BASE_URL }}/tickets/moveTicket/{{ $row['id'] }}" class="moveTicketModal sprintModal"><i class="fa-solid fa-arrow-right-arrow-left"></i> {!! __('links.move_milestone') !!}</a></li>
<li><a href="{{ BASE_URL }}/tickets/delMilestone/{{ $row['id'] }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</a></li>
<li class="nav-header border"></li>
<li><a href="{{ BASE_URL }}/tickets/showAll?search=true&milestone={{ $row['id'] }}">{!! __('links.view_todos') !!}</a></li>
</ul>
</div>
@endif
</td>
@dispatchEvent('allTicketsTable.beforeRowEnd', ['tickets' => $allTickets, 'rowNum' => $rowNum])
</tr>
@endforeach
@dispatchEvent('allTicketsTable.afterLastRow', ['tickets' => $allTickets])
</tbody>
@dispatchEvent('allTicketsTable.afterBody', ['tickets' => $allTickets])
</table>
@dispatchEvent('allTicketsTable.afterClose', ['tickets' => $allTickets])
@if ($group['label'] != 'all')
</div>
@endif
@endforeach
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function(){
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initUserDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initStatusDropdown();
leantime.ticketsController.initSprintDropdown();
leantime.ticketsController.initMilestoneDatesAsyncUpdate();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.ticketsController.initMilestoneTable("{{ $searchCriteria['groupBy'] }}");
@dispatchEvent('scripts.beforeClose')
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,266 @@
@extends($layout)
@section('content')
@php
$todoTypeIcons = $ticketTypeIcons;
$statusLabels = $allTicketStates;
$numberofColumns = count($allTicketStates) - 1;
$size = floor(100 / $numberofColumns);
@endphp
@include('tickets::submodules.portfolioHeader')
<div class="maincontent">
@include('tickets::submodules.portfolioTabs')
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<form action="" method="get" id="ticketSearch">
@dispatchEvent('filters.afterFormOpen')
<input type="hidden" value="1" name="search"/>
<div class="row">
<div class="col-md-5">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
<div class="col-md-2 center">
@dispatchEvent('filters.afterCenterSectionOpen')
@dispatchEvent('filters.beforeCenterSectionClose')
</div>
<div class="col-md-5">
<div class="pull-right">
@dispatchEvent('filters.afterRighthandSectionOpen')
<div id="tableButtons" style="display:inline-block"></div>
@dispatchEvent('filters.beforeRighthandSectionClose')
</div>
</div>
</div>
@dispatchEvent('filters.beforeFormClose')
<div class="clearfix"></div>
</form>
@dispatchEvent('allTicketsTable.before', ['tickets' => $allTickets])
<table id="allTicketsTable" class="table table-bordered display" style="width:100%">
<colgroup>
<col class="con1" >
<col class="con0">
<col class="con1">
<col class="con0" >
<col class="con1">
<col class="con0">
<col class="con1" >
<col class="con0" >
<col class="con1" >
<col class="con0" >
<col class="con1" >
<col class="con0" >
</colgroup>
@dispatchEvent('allTicketsTable.beforeHead', ['tickets' => $allTickets])
<thead>
@dispatchEvent('allTicketsTable.beforeHeadRow', ['tickets' => $allTickets])
<tr>
<th>{!! __('label.project_name') !!}</th>
<th>{!! __('label.title') !!}</th>
<th class="milestone-col">{!! __('label.dependent_on') !!}</th>
<th>{!! __('label.todo_status') !!}</th>
<th class="user-col">{!! __('label.owner') !!}</th>
<th>{!! __('label.planned_start_date') !!}</th>
<th>{!! __('label.planned_end_date') !!}</th>
<th>{!! __('label.planned_hours') !!}</th>
<th>{!! __('label.estimated_hours_remaining') !!}</th>
<th>{!! __('label.booked_hours') !!}</th>
<th>{!! __('label.progress') !!}</th>
<th class="no-sort"></th>
</tr>
@dispatchEvent('allTicketsTable.afterHeadRow', ['tickets' => $allTickets])
</thead>
@dispatchEvent('allTicketsTable.afterHead', ['tickets' => $allTickets])
<tbody>
@dispatchEvent('allTicketsTable.beforeFirstRow', ['tickets' => $allTickets])
@foreach ($allTickets as $rowNum => $row)
<tr>
<td><h4>{{ $row->projectName }} </h4></td>
@dispatchEvent('allTicketsTable.afterRowStart', ['rowNum' => $rowNum, 'tickets' => $allTickets])
<td data-order="{{ $row->headline }}"><a href="#/tickets/editMilestone/{{ $row->id }}">{{ $row->headline }}</a></td>
@php
if ($row->milestoneid != '' && $row->milestoneid != 0) {
$milestoneHeadline = $tpl->escape($row->milestoneHeadline);
} else {
$milestoneHeadline = __('label.no_milestone');
}
@endphp
<td class="dropdown-cell" data-order="{{ $milestoneHeadline }}">
<div class="dropdown ticketDropdown milestoneDropdown colorized show">
<a style="background-color:{{ $tpl->escape($row->milestoneColor) }}" class="dropdown-toggle label-default milestone" href="javascript:void(0);" role="button" id="milestoneDropdownMenuLink{{ $row->id }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $milestoneHeadline }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="milestoneDropdownMenuLink{{ $row->id }}">
<li class="nav-header border">{!! __('dropdown.choose_milestone') !!}</li>
<li class='dropdown-item'><a style='background-color:#b0b0b0' href='javascript:void(0);' data-label="{!! __('label.no_milestone') !!}" data-value='{{ $row->id.'_0_#b0b0b0' }}'> {!! __('label.no_milestone') !!} </a></li>
@php
foreach ($milestones as $milestone) {
if ($milestone->id != $row->id) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='".$tpl->escape($milestone->headline)."' data-value='".$row->id.'_'.$milestone->id.'_'.$tpl->escape($milestone->tags)."' id='ticketMilestoneChange".$row->id.$milestone->id."' style='background-color:".$tpl->escape($milestone->tags)."'>".$tpl->escape($milestone->headline).'</a>';
echo '</li>';
}
}
@endphp
</ul>
</div>
</td>
@php
if (isset($statusLabels[$row->status])) {
$class = $statusLabels[$row->status]['class'];
$name = $statusLabels[$row->status]['name'];
} else {
$class = 'label-important';
$name = 'new';
}
@endphp
<td class="dropdown-cell" data-order="{{ $name }}">
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a class="dropdown-toggle status {{ $class }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row->id }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $name }}</span>
&nbsp;<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>
@php
foreach ($statusLabels as $key => $label) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='".$label['class']."' data-label='".$tpl->escape($label['name'])."' data-value='".$row->id.'_'.$key.'_'.$label['class']."' id='ticketStatusChange".$row->id.$key."' >".$tpl->escape($label['name']).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td class="dropdown-cell" data-order="{{ $row->editorFirstname != '' ? $tpl->escape($row->editorFirstname) : __('dropdown.not_assigned') }}">
<div class="dropdown ticketDropdown userDropdown noBg show ">
<a class="dropdown-toggle" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row->id }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@php
if ($row->editorFirstname != '') {
echo "<span id='userImage".$row->id."'><img src='".BASE_URL.'/api/users?profileImage='.$row->editorId."' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row->id."'> ".$tpl->escape($row->editorFirstname).'</span>';
} else {
echo "<span id='userImage".$row->id."'><img src='".BASE_URL."/api/users?profileImage=false' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row->id."'>".__('dropdown.not_assigned').'</span>';
}
@endphp
</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row->id }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@php
foreach ($users as $user) {
echo "<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>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-order="{{ $row->editFrom }}" >
{!! __('label.due_icon') !!}<input type="text" title="{{ __('label.planned_start_date') }}" value="{{ format($row->editFrom)->date() }}" class="editFromDate secretInput milestoneEditFromAsync fromDateTicket-{{ $row->id }}" data-id="{{ $row->id }}" name="editFrom" class=""/>
</td>
<td data-order="{{ $row->editTo }}" >
{!! __('label.due_icon') !!}<input type="text" title="{{ __('label.planned_end_date') }}" value="{{ format($row->editTo)->date() }}" class="editToDate secretInput milestoneEditToAsync toDateTicket-{{ $row->id }}" data-id="{{ $row->id }}" name="editTo" class="" />
</td>
<td data-order="{{ $row->planHours }}" >
{{ $row->planHours }}
</td>
<td data-order="{{ $row->hourRemaining }}" >
{{ $row->hourRemaining }}
</td>
<td data-order="{{ $row->bookedHours }}" >
{{ $row->bookedHours }}
</td>
<td data-order="{{ $row->percentDone }}">
<div class="progress " style="width: 100%;">
<div class="progress-bar progress-bar-success " role="progressbar" aria-valuenow="{{ $row->percentDone }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ $row->percentDone }}%">
<span class="sr-only">{!! sprintf(__('text.percent_complete'), $row->percentDone) !!}</span>
</div>
</div>
</td>
<td>
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer">
<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.todo') !!}</li>
<li><a href="#/tickets/editMilestone/{{ $row->id }}" class='ticketModal'><i class="fa fa-edit"></i> {!! __('links.edit_milestone') !!}</a></li>
<li><a href="#/tickets/moveTicket/{{ $row->id }}" class="moveTicketModal sprintModal"><i class="fa-solid fa-arrow-right-arrow-left"></i> {!! __('links.move_milestone') !!}</a></li>
<li><a href="#/tickets/delMilestone/{{ $row->id }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</a></li>
<li class="nav-header border"></li>
<li><a href="{{ BASE_URL }}/tickets/showAll?search=true&milestone={{ $row->id }}">{!! __('links.view_todos') !!}</a></li>
</ul>
</div>
@endif
</td>
@dispatchEvent('allTicketsTable.beforeRowEnd', ['tickets' => $allTickets, 'rowNum' => $rowNum])
</tr>
@endforeach
@dispatchEvent('allTicketsTable.afterLastRow', ['tickets' => $allTickets])
</tbody>
@dispatchEvent('allTicketsTable.afterBody', ['tickets' => $allTickets])
</table>
@dispatchEvent('allTicketsTable.afterClose', ['tickets' => $allTickets])
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function(){
});
leantime.ticketsController.initTicketSearchSubmit("{{ BASE_URL }}/tickets/showAll");
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initUserDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initStatusDropdown();
leantime.ticketsController.initSprintDropdown();
leantime.ticketsController.initMilestoneDatesAsyncUpdate();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.ticketsController.initMilestoneTable("{{ $searchCriteria['groupBy'] }}");
@dispatchEvent('scripts.beforeClose')
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,482 @@
@extends($layout)
@section('content')
@php
$tickets = $tickets ?? [];
$todoTypeIcons = $ticketTypeIcons;
$allTicketGroups = $allTickets;
$reopenState = session()->get('quickadd_reopen', null);
$currentGroupBy = $searchCriteria['groupBy'] ?? 'all';
// Program (cross-project) board: columns are semantic status types and tickets are placed
// by their computed statusType (resolved from each project) instead of the raw status key.
$programBoard = $programBoard ?? false;
$placementField = $programBoard ? 'statusType' : 'status';
@endphp
{!! $tpl->displayNotification() !!}
<script>
leantime.kanbanGroupBy = '{{ $tpl->escape($currentGroupBy) }}';
</script>
@include('tickets::submodules.ticketHeader')
<div class="maincontent">
@include('tickets::submodules.ticketBoardTabs')
<div class="maincontentinner kanban-board-wrapper" >
{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs) so there's no separate toolbar row here. --}}
<div class="clearfix"></div>
@if ($programBoard)
<p class="tw-text-[var(--secondary-font-color)]" style="margin-bottom:15px;">
<i class="fa fa-circle-info" aria-hidden="true"></i>
{{ __('text.program_status_rollup') }}
</p>
@endif
@if (isset($allTicketGroups['all']))
@php $allTickets = $allTicketGroups['all']['items']; @endphp
@endif
@php
$isGroupByActive = ! empty($searchCriteria['groupBy']) && $searchCriteria['groupBy'] !== 'all';
$columnHeaderClass = $isGroupByActive ? 'groupby-active' : '';
@endphp
<div class="kanban-column-headers {{ $columnHeaderClass }}" style="
display: flex;
position: sticky;
top: 110px;
justify-content: flex-start;
z-index: 9;
">
@foreach ($allKanbanColumns as $key => $statusRow)
<div class="column">
<h4 class="widgettitle title-primary title-border-{{ $statusRow['class'] }}">
@if ($login::userIsAtLeast($roles::$manager) && ! $programBoard)
<div class="inlineDropDownContainer" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown editHeadline" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li><a href="#/setting/editBoxLabel?module=ticketlabels&label={{ $key }}" class="editLabelModal">{!! __('headlines.edit_label') !!}</a>
</li>
<li><a href="{{ BASE_URL }}/projects/showProject/{{ session('currentProject') }}#todosettings">{!! __('links.add_remove_col') !!}</a></li>
</ul>
</div>
@endif
<strong class="count">0</strong>
{{ $statusRow['name'] }}
</h4>
</div>
@endforeach
</div>
@foreach ($allTicketGroups as $group)
@php $allTickets = $group['items']; @endphp
@if ($group['label'] != 'all')
@php
$swimlaneExpanded = ! in_array($group['id'], session('collapsedSwimlanes', []));
$groupBy = $searchCriteria['groupBy'] ?? 'status';
$groupId = $group['id'];
$groupIdKey = (string) $groupId;
$swimlaneBreakdown = $statusBreakdown[$groupIdKey] ?? $statusBreakdown[$groupId] ?? [];
$statusCounts = $swimlaneBreakdown['statusCounts'] ?? [];
$timeAlert = $swimlaneBreakdown['timeAlert'] ?? null;
@endphp
<div class="kanban-swimlane-row" data-expanded="{{ $swimlaneExpanded ? 'true' : 'false' }}" id="swimlane-row-{{ $group['id'] }}">
<div class="kanban-swimlane-sentinel" data-swimlane-id="{{ $group['id'] }}" aria-hidden="true"></div>
{{-- Render the component directly. It was previously invoked via
app('blade.compiler')::render('<x-...>', $data), but a <x-component> string
passed to Blade::render from inside an already-compiled view gets its bare
variables ($label, $groupId, …) pre-compiled by the outer pass and they are
not in the inner render scope — throwing "Undefined variable" for ANY
kanban group-by. A plain component tag with inline expressions is correct. --}}
<x-global::kanban.swimlane-row-header
:groupBy="$groupBy"
:groupId="$group['id']"
:label="$group['label']"
:totalCount="$swimlaneBreakdown['totalCount'] ?? count($group['items'])"
:statusCounts="$statusCounts"
:statusColumns="$allKanbanColumns"
:expanded="$swimlaneExpanded"
:moreInfo="$group['more-info'] ?? null"
:timeAlert="$group['timeAlert'] ?? null"
/>
<div class="kanban-swimlane-content{{ !$swimlaneExpanded ? ' collapsed' : '' }}" id="swimlane-content-{{ $group['id'] }}">
@endif
<div class="sortableTicketList kanbanBoard" id="kanboard-{{ $group['id'] }}" style="margin-top:-5px;">
<div class="row-fluid">
@php
$emptyColumns = [];
foreach ($allKanbanColumns as $key => $statusRow) {
$hasTickets = false;
if (isset($allTickets)) {
foreach ($allTickets as $ticket) {
if (isset($ticket[$placementField]) && $ticket[$placementField] == $key) {
$hasTickets = true;
break;
}
}
}
if (! $hasTickets) {
$emptyColumns[$key] = true;
}
}
@endphp
@foreach ($allKanbanColumns as $key => $statusRow)
<div class="column">
<div class="contentInner status_{{ $key }} {{ isset($emptyColumns[$key]) ? 'empty-column' : '' }}"
data-empty-text="{{ isset($emptyColumns[$key]) ? 'Empty' : '' }}"
aria-label="{{ isset($emptyColumns[$key]) ? 'Empty column' : htmlspecialchars($statusRow['name']).' column items' }}"
role="list">
@include('tickets::partials.quickadd-form', [
'statusId' => $key,
'swimlaneKey' => $group['value'] ?? $group['id'] ?? null,
'isEmpty' => isset($emptyColumns[$key]),
'currentGroupBy' => $searchCriteria['groupBy'] ?? null,
'programBoard' => $programBoard,
'availableProjects' => $availableProjects ?? null,
])
@foreach ($allTickets as $row)
@if (($row[$placementField] ?? null) == $key)
<div class="ticketBox moveable container priority-border-{{ $row['priority'] }}" id="ticket_{{ $row['id'] }}">
<div class="row" >
<div class="col-md-12">
@include('tickets::partials.ticketsubmenu', ['ticket' => $row, 'onTheClock' => $onTheClock])
@if ($row['dependingTicketId'] > 0)
<small><a href="#/tickets/showTicket/{{ $row['dependingTicketId'] }}" class="form-modal">{{ $row['parentHeadline'] }}</a></small> //
@endif
<small><i class="fa {{ $todoTypeIcons[strtolower($row['type'])] }}"></i> {!! __('label.'.strtolower($row['type'])) !!}</small>
<small>#{{ $row['id'] }}</small>
<div class="kanbanCardContent">
<h4><a href="#/tickets/showTicket/{{ $row['id'] }}" data-hx-get="{{ BASE_URL }}/tickets/showTicket/{{ $row['id'] }}" hx-swap="none" preload="mouseover">{{ $row['headline'] }}</a></h4>
<div class="kanbanContent" style="margin-bottom: 20px">
{!! $tpl->escapeMinimal($row['description']) !!}
</div>
</div>
<div class="tw-flex">
@if ($row['dateToFinish'] != '0000-00-00 00:00:00' && $row['dateToFinish'] != '1969-12-31 00:00:00')
<div>
{!! __('label.due_icon') !!}
<input type="text" title="{{ __('label.due') }}" value="{{ format($row['dateToFinish'])->date() }}" class="duedates secretInput" style="margin-left:0px;" data-id="{{ $row['id'] }}" name="date" />
</div>
<div>
@dispatchEvent('afterDates', ['ticket' => $row])
</div>
@endif
</div>
</div>
</div>
<div class="clearfix" style="padding-bottom: 8px;"></div>
<div class="timerContainer " id="timerContainer-{{ $row['id'] }}" >
<div class="dropdown ticketDropdown milestoneDropdown colorized show firstDropdown" >
<a style="background-color:{{ $tpl->escape($row['milestoneColor']) }}" class="dropdown-toggle f-left label-default milestone" href="javascript:void(0);" role="button" id="milestoneDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@if ($row['milestoneid'] != '' && $row['milestoneid'] != 0){{ $row['milestoneHeadline'] }}@else{!! __('label.no_milestone') !!}@endif</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="milestoneDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_milestone') !!}</li>
<li class='dropdown-item'><a style='background-color:#b0b0b0' href='javascript:void(0);' data-label="{!! __('label.no_milestone') !!}" data-value='{{ $row['id'].'_0_#b0b0b0' }}'> {!! __('label.no_milestone') !!} </a></li>
@php
foreach ($milestones as $milestone) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='".$tpl->escape($milestone->headline)."' data-value='".$row['id'].'_'.$milestone->id.'_'.$tpl->escape($milestone->tags)."' id='ticketMilestoneChange".$row['id'].$milestone->id."' style='background-color:".$tpl->escape($milestone->tags)."'>".$tpl->escape($milestone->headline).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
@if ($row['storypoints'] != '' && $row['storypoints'] > 0)
<div class="dropdown ticketDropdown effortDropdown show">
<a class="dropdown-toggle f-left label-default effort" href="javascript:void(0);" role="button" id="effortDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $efforts[''.$row['storypoints']] ?? $row['storypoints'] }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.how_big_todo') !!}</li>
@php
foreach ($efforts as $effortKey => $effortValue) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' data-value='".$row['id'].'_'.$effortKey."' id='ticketEffortChange".$row['id'].$effortKey."'>".$effortValue.'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
@endif
<div class="dropdown ticketDropdown priorityDropdown show">
<a class="dropdown-toggle f-left label-default priority priority-bg-{{ $row['priority'] }}" href="javascript:void(0);" role="button" id="priorityDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@php if ($row['priority'] != '' && $row['priority'] > 0) { echo $priorities[$row['priority']] ?? __('label.priority_unkown'); } else { echo __('label.priority_unkown'); } @endphp</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="priorityDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.select_priority') !!}</li>
@php
foreach ($priorities as $priorityKey => $priorityValue) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='priority-bg-".$priorityKey."' data-value='".$row['id'].'_'.$priorityKey."' id='ticketPriorityChange".$row['id'].$priorityKey."'>".$priorityValue.'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
<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" style="display:inline-flex; align-items:center;">
@php
if ($row['editorFirstname'] != '') {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL.'/api/users?profileImage='.$row['editorId']."' width='25' style='vertical-align: middle;'/></span>";
} else {
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL."/api/users?profileImage=false' width='25' style='vertical-align: middle;'/></span>";
}
if (! empty($row['collaboratorPreview'])) {
echo "<span class='ticket-collaborators' style='display:inline-flex; align-items:center; margin-left:6px;'>";
foreach ($row['collaboratorPreview'] as $index => $collaboratorId) {
$offset = $index > 0 ? 'margin-left:-8px;' : '';
echo "<span class='ticket-collaborator-avatar' title='".__('label.collaborators')."' style='display:inline-flex; width:18px; height:18px; border-radius:999px; border:2px solid var(--main-background-color, #fff); overflow:hidden; ".$offset."'><img src='".BASE_URL.'/api/users?profileImage='.$collaboratorId."' width='18' height='18' style='display:block; width:18px; height:18px;'/></span>";
}
if (($row['collaboratorOverflow'] ?? 0) > 0) {
echo "<span class='ticket-collaborator-more' title='".__('label.collaborators')."' style='display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 4px; margin-left:4px; border-radius:999px; background:var(--accent-color, #e9ecef); color:var(--secondary-font-color, #333); font-size:10px; line-height:18px;'>+".(int) $row['collaboratorOverflow'].'</span>';
}
echo '</span>';
}
@endphp
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@php
if (is_array($users)) {
foreach ($users as $user) {
echo "<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>';
echo '</li>';
}
}
@endphp
</ul>
</div>
</div>
<div class="clearfix"></div>
@if ($programBoard)
{{-- Cross-project board: columns are semantic stages, so give each card a
dropdown of its OWN project's real statuses (e.g. "Blocked") to set the
detailed status directly. patchTicket writes a key valid in that project,
so it stays orphan-safe. Also show which project the task belongs to. --}}
@php $rowProjectStatuses = $statusLabelsByProject[$row['projectId']] ?? []; @endphp
@php $rowProjectStatus = $rowProjectStatuses[$row['status']] ?? null; @endphp
<div style="margin-top:4px;">
<div class="dropdown ticketDropdown statusDropdown colorized show" style="display:inline-block;">
<a class="dropdown-toggle status {{ $rowProjectStatus['class'] ?? 'label-default' }} f-left" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $tpl->escape($rowProjectStatus['name'] ?? __('label.new')) }}</span>&nbsp;<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>
@php
foreach ($rowProjectStatuses as $statusKey => $statusOption) {
echo "<li class='dropdown-item'><a href='javascript:void(0);' class='".$statusOption['class']."' data-label='".$tpl->escape($statusOption['name'])."' data-value='".$row['id'].'_'.$statusKey.'_'.$statusOption['class']."' id='ticketStatusChange".$row['id'].$statusKey."'>".$tpl->escape($statusOption['name']).'</a></li>';
}
@endphp
</ul>
</div>
<small class="tw-text-[var(--secondary-font-color)]">{{ $tpl->escape($row['projectName'] ?? '') }}</small>
</div>
@endif
@if ($row['commentCount'] > 0 || $row['subtaskCount'] > 0 || $row['tags'] != '')
<div class="row">
<div class="col-md-12 border-top" style="white-space: nowrap;">
@if ($row['commentCount'] > 0)
<a href="#/tickets/showTicket/{{ $row['id'] }}"><span class="fa-regular fa-comments"></span> {{ $row['commentCount'] }}</a>&nbsp;
@endif
@if ($row['subtaskCount'] > 0)
<a id="subtaskLink_{{ $row['id'] }}" href="#/tickets/showTicket/{{ $row['id'] }}" class="subtaskLineLink"> <span class="fa fa-diagram-successor"></span> {{ $row['subtaskCount'] }}</a>&nbsp;
@endif
@if ($row['tags'] != '')
@php $tagsArray = explode(',', $row['tags']); @endphp
<a href="javascript:void(0);" class="dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-tags" aria-hidden="true"></i> {{ count($tagsArray) }}
</a>
<ul class="dropdown-menu ">
<li style="padding:10px"><div class='tagsinput readonly'>
@foreach ($tagsArray as $tag)
<span class='tag'><span>{{ $tag }}</span></span>
@endforeach
</div></li></ul>
@endif
</div>
</div>
@endif
</div>
@endif
@endforeach
</div>
</div>
@endforeach
<div class="clearfix"></div>
</div>
</div>
@if ($group['label'] != 'all')
</div> {{-- .kanban-swimlane-content --}}
</div> {{-- .kanban-swimlane-row --}}
@endif
@endforeach
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
@if (can('tickets.edit'))
leantime.ticketsController.initUserDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initDueDateTimePickers();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initPriorityDropdown();
@if ($programBoard)
{{-- Per-card status dropdown (program board only): set the exact project status. --}}
leantime.ticketsController.initStatusDropdown();
@endif
@if ($programBoard)
{{-- Program board: columns are status types; drag persists per-project via the plugin. --}}
var ticketStatusList = [@foreach ($allKanbanColumns as $key => $statusRow)'{{ $key }}',@endforeach];
if (leantime.pgmProBoard && typeof leantime.pgmProBoard.initProgramKanban === 'function') {
leantime.pgmProBoard.initProgramKanban(ticketStatusList);
} else {
console.warn('PgmPro board JS is not loaded; program kanban drag-and-drop is disabled.');
}
@else
var ticketStatusList = [@foreach ($allTicketStates as $key => $statusRow)'{{ $key }}',@endforeach];
leantime.ticketsController.initTicketKanban(ticketStatusList);
@endif
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.ticketsController.setUpKanbanColumns();
@if (isset($_GET['showTicketModal']))
@php
$modalUrl = $_GET['showTicketModal'] == '' ? '' : '/'.(int) $_GET['showTicketModal'];
@endphp
leantime.ticketsController.openTicketModalManually("{{ BASE_URL }}/tickets/showTicket{{ $modalUrl }}");
window.history.pushState({},document.title, '{{ BASE_URL }}/tickets/showKanban');
@endif
@php
foreach ($allTicketGroups as $group) {
foreach ($group['items'] as $ticket) {
if ($ticket['dependingTicketId'] > 0) {
@endphp
var startElement = document.getElementById('subtaskLink_{{ $ticket['dependingTicketId'] }}');
var endElement = document.getElementById('ticket_{{ $ticket['id'] }}');
if ( startElement != undefined && endElement != undefined) {
var startAnchor = LeaderLine.mouseHoverAnchor({
element: startElement,
showEffectName: 'draw',
style: {background: 'none', backgroundColor: 'none'},
hoverStyle: {background: 'none', backgroundColor: 'none', cursor: 'pointer'}
});
var line{{ $ticket['id'] }} = new LeaderLine(startAnchor, endElement, {
startPlugColor: 'var(--accent1)',
endPlugColor: 'var(--accent2)',
gradient: true,
size: 2,
path: "grid",
startSocket: 'bottom',
endSocket: 'auto'
});
jQuery("#ticket_{{ $ticket['id'] }}").mousedown(function () {
})
.mousemove(function () {
})
.mouseup(function () {
line{{ $ticket['id'] }}.position();
});
jQuery("#ticket_{{ $ticket['dependingTicketId'] }}").mousedown(function () {
})
.mousemove(function () {
})
.mouseup(function () {
line{{ $ticket['id'] }}.position();
});
}
@php
}
}
}
@endphp
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,169 @@
@extends($layout)
@section('content')
@php
$allTicketGroups = $allTickets;
$statusLabels = $allTicketStates;
$groupBy = $groupBy ?? [];
$newField = $newField ?? [];
$numberofColumns = count($allTicketStates) - 1;
$size = floor(100 / $numberofColumns);
@endphp
{!! $tpl->displayNotification() !!}
@include('tickets::submodules.ticketHeader')
<div class="maincontent">
@include('tickets::submodules.ticketBoardTabs')
<div class="maincontentinner">
{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs) so there's no separate toolbar row here. --}}
<div class="clearfix"></div>
@dispatchEvent('allTicketsTable.before', ['tickets' => $allTickets])
<div class="row">
<div class="col-md-3">
<div class="quickAddForm" style="margin-top:15px;">
<form action="" method="post">
<x-global::forms.text-input name="headline" autofocus placeholder="{{ __('input.placeholders.create_task') }}" style="width: 100%;" />
@if (isset($availableProjects))
{{-- Program (cross-project) board: a task must belong to one child project. --}}
<select name="quickaddProjectId" class="form-control tw-mb-s" required aria-label="{{ __('label.project') }}">
<option value="">{{ __('label.project') }}…</option>
@foreach ($availableProjects as $quickAddProjectId => $quickAddProjectName)
<option value="{{ $quickAddProjectId }}">{{ $tpl->escape($quickAddProjectName) }}</option>
@endforeach
</select>
@endif
<input type="hidden" name="sprint" value="{{ $currentSprint }}" />
<input type="hidden" name="milestone" value="{{ htmlspecialchars((string) ($searchCriteria['milestone'] ?? ''), ENT_QUOTES, 'UTF-8') }}" />
<input type="hidden" name="groupBy" value="{{ htmlspecialchars((string) ($searchCriteria['groupBy'] ?? ''), ENT_QUOTES, 'UTF-8') }}" />
<input type="hidden" name="quickadd" value="1"/>
<x-global::forms.button tag="input" inputType="submit" class="tw-mb-m" contentRole="primary" :labelText="__('buttons.save')" name="saveTicket" style="vertical-align: top; " />
</form>
@foreach ($allTicketGroups as $group)
@if ($group['label'] != 'all')
<h5 class="accordionTitle {{ $group['class'] }}" @if (!empty($group['color'])) style="color:{{ htmlspecialchars($group['color']) }}" @endif id="accordion_link_{{ $group['id'] }}">
<a href="javascript:void(0)" class="accordion-toggle" id="accordion_toggle_{{ $group['id'] }}" onclick="leantime.snippets.accordionToggle('{{ $group['id'] }}');">
<i class="fa fa-angle-down"></i>{{ $group['label'] }} ({{ count($group['items']) }})
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-{{ $group['id'] }}">
@endif
@php $allTickets = $group['items']; @endphp
<table class="table display listStyleTable" style="width:100%">
@dispatchEvent('allTicketsTable.beforeHead', ['tickets' => $allTickets])
<thead>
@dispatchEvent('allTicketsTable.beforeHeadRow', ['tickets' => $allTickets])
<tr style="display:none;">
<th style="width:20px" class="status-col">{!! __('label.todo_status') !!}</th>
<th>{!! __('label.title') !!}</th>
</tr>
@dispatchEvent('allTicketsTable.afterHeadRow', ['tickets' => $allTickets])
</thead>
@dispatchEvent('allTicketsTable.afterHead', ['tickets' => $allTickets])
<tbody>
@dispatchEvent('allTicketsTable.beforeFirstRow', ['tickets' => $allTickets])
@foreach ($allTickets as $rowNum => $row)
<tr onclick="leantime.ticketsController.loadTicketToContainer('{{ $row['id'] }}', '#ticketContent')" id="row-{{ $row['id'] }}" class="ticketRows">
@dispatchEvent('allTicketsTable.afterRowStart', ['rowNum' => $rowNum, 'tickets' => $allTickets])
@php
// Program (cross-project) board: render/edit each row with its own
// project's statuses so a status change never orphans the task.
$rowStatusLabels = (isset($statusLabelsByProject) && isset($statusLabelsByProject[$row['projectId']]))
? $statusLabelsByProject[$row['projectId']]
: $statusLabels;
@endphp
<td data-order="{{ isset($rowStatusLabels[$row['status']]) ? $rowStatusLabels[$row['status']]['sortKey'] : '' }}" data-search="{{ isset($rowStatusLabels[$row['status']]) ? $rowStatusLabels[$row['status']]['name'] : '' }}" class="roundStatusBtn" style="width:20px">
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a class="dropdown-toggle status {{ isset($rowStatusLabels[$row['status']]) ? $rowStatusLabels[$row['status']]['class'] : '' }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><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>
@php
foreach ($rowStatusLabels as $key => $label) {
echo "<li class='dropdown-item'>
<a href='javascript:void(0);' class='".$label['class']."' data-label='".$tpl->escape($label['name'])."' data-value='".$row['id'].'_'.$key.'_'.$label['class']."' id='ticketStatusChange".$row['id'].$key."' >".$tpl->escape($label['name']).'</a>';
echo '</li>';
}
@endphp
</ul>
</div>
</td>
<td data-search="{{ isset($rowStatusLabels[$row['status']]) ? $rowStatusLabels[$row['status']]['name'] : '' }}" data-order="{{ $row['headline'] }}" >
<a href="javascript:void(0);"><strong>{{ $row['headline'] }}</strong></a></td>
@dispatchEvent('allTicketsTable.beforeRowEnd', ['tickets' => $allTickets, 'rowNum' => $rowNum])
</tr>
@endforeach
@dispatchEvent('allTicketsTable.afterLastRow', ['tickets' => $allTickets])
</tbody>
@dispatchEvent('allTicketsTable.afterBody', ['tickets' => $allTickets])
</table>
@if ($group['label'] != 'all')
</div>
@endif
@endforeach
</div>
</div>
<div class="col-md-9 hidden-sm" >
<div id="ticketContent">
<div class="center">
<div class='svgContainer'>
{!! file_get_contents(ROOT.'/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{!! __('headlines.pick_a_task') !!}</h3>
{!! __('text.edit_tasks_in_here') !!}
</div>
</div>
</div>
</div>
@dispatchEvent('allTicketsTable.afterClose', ['tickets' => $allTickets])
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
@dispatchEvent('scripts.afterOpen')
@if ($login::userIsAtLeast($roles::$editor))
leantime.ticketsController.initStatusDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.ticketsController.initTicketsList("{{ $searchCriteria['groupBy'] }}");
@dispatchEvent('scripts.beforeClose')
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,90 @@
@extends($layout)
@section('content')
@php
$projectData = $projectData ?? [];
@endphp
<div class="pageheader">
<div class="pull-right padding-top">
<a href="{{ session('lastPage') }}" class="backBtn"><i class="far fa-arrow-alt-circle-left"></i> {!! __('links.go_back') !!}</a>
</div>
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{{ session('currentProjectClient').' // '.session('currentProjectName') }}</h5>
<h1>{!! __('headlines.edit_todo') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="tabbedwidget tab-primary ticketTabs" style="visibility:hidden;">
<ul>
<li><a href="#ticketdetails">{!! __('tabs.ticketDetails') !!}</a></li>
<li><a href="#subtasks">{!! __('tabs.subtasks') !!} ({{ $numSubTasks }})</a></li>
<li><a href="#files">{!! __('tabs.files') !!} ({{ $numFiles }})</a></li>
@if (session('userdata.role') != 'client')
<li><a href="#timesheet" id="timesheetTab">{!! __('tabs.time_tracking') !!}</a></li>
@endif
</ul>
<div id="ticketdetails">
<form class="formModal" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}" method="post">
@include('tickets::submodules.ticketDetails')
</form>
</div>
<div id="subtasks">
@include('tickets::submodules.subTasks')
</div>
<div id="files">
<form action='#files' method='POST' enctype="multipart/form-data" class="formModal">
@include('tickets::submodules.attachments')
</form>
</div>
@if (session('userdata.role') != 'client')
<div id="timesheet">
@include('tickets::submodules.timesheet')
</div>
@endif
</div>
</div>
<div class="maincontentinner">
<form method="post" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}#comments" class="formModal">
<input type="hidden" name="comment" value="1" />
@include('comments::submodules.generalComment', ['formUrl' => BASE_URL.'/tickets/showTicket/'.$ticket->id])
</form>
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(window).load(function () {
leantime.ticketsController.initTicketTabs();
jQuery(window).resize();
});
</script>
@endpush @endonce
@endsection

View File

@@ -0,0 +1,150 @@
<?php
foreach ($__data as $var => $val) {
$$var = $val; // necessary for blade refactor
}
$ticket = $ticket ?? null;
$projectData = $projectData ?? [];
$todoTypeIcons = $ticketTypeIcons ?? [];
?>
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
//It's not a modal
location.href="<?= BASE_URL ?>/tickets/showKanban?showTicketModal=<?php echo $ticket->id; ?>";
}
}
</script>
<div style="min-width:70%">
<?php if ($ticket->dependingTicketId > 0) { ?>
<small><a href="#/tickets/showTicket/<?= $ticket->dependingTicketId ?>"><?= $tpl->escape($ticket->parentHeadline) ?></a></small> //
<?php } ?>
<small class="tw-float-right tw-pr-md" style="padding:5px 30px 0px 0px">Created by <?php $tpl->e($ticket->userFirstname); ?> <?php $tpl->e($ticket->userLastname); ?> | Last Updated: <?= format($ticket->date)->date(); ?> </small>
<h1 class="tw-mb-0" style="margin-bottom:0px;"><i class="fa <?php echo $todoTypeIcons[strtolower($ticket->type)] ?? 'fa-circle'; ?>"></i> #<?= $ticket->id ?> - <?php $tpl->e($ticket->headline); ?></h1>
<br />
<?php if ($login::userIsAtLeast($roles::$editor)) {
$onTheClock = $onTheClock ?? false;
?>
<div class="inlineDropDownContainer" style="float:right; z-index:50; padding-top:10px; padding-right:10px;">
<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"><?php echo $tpl->__('subtitles.todo'); ?></li>
<li><a href="#/tickets/moveTicket/<?php echo $ticket->id; ?>" class="moveTicketModal sprintModal ticketModal"><i class="fa-solid fa-arrow-right-arrow-left"></i> <?php echo $tpl->__('links.move_todo'); ?></a></li>
<li><a href="#/tickets/delTicket/<?php echo $ticket->id; ?>" class="delete"><i class="fa fa-trash"></i> <?php echo $tpl->__('links.delete_todo'); ?></a></li>
<li class="nav-header border"><?php echo $tpl->__('subtitles.track_time'); ?></li>
<li id="timerContainer-ticketDetails-{{ $ticket->id }}"
hx-get="{{BASE_URL}}/tickets/timerButton/get-status/{{ $ticket->id }}"
hx-trigger="timerUpdate from:body"
hx-swap="outerHTML"
class="timerContainer">
@if ($onTheClock === false)
<a href="javascript:void(0);" data-value="{{ $ticket->id }}"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/start-timer/"
hx-target="#timerHeadMenu"
hx-swap="outerHTML"
hx-vals='{"ticketId": "{{ $ticket->id }}", "action":"start"}'>
<span class="fa-regular fa-clock"></span> {{ __("links.start_work") }}
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] == $ticket->id)
<a href="javascript:void(0);" data-value="{{ $ticket->id }}"
hx-patch="{{ BASE_URL }}/hx/timesheets/stopwatch/stop-timer/"
hx-target="#timerHeadMenu"
hx-vals='{"ticketId": "{{ $ticket->id }}", "action":"stop"}'
hx-swap="outerHTML">
<span class="fa fa-stop"></span>
@if (is_array($onTheClock) == true)
{!! sprintf(__("links.stop_work_started_at"), date(__("language.timeformat"), $onTheClock["since"])) !!}
@else
{!! sprintf(__("links.stop_work_started_at"), date(__("language.timeformat"), time())) !!}
@endif
</a>
@endif
@if ($onTheClock !== false && $onTheClock["id"] != $ticket->id)
<span class='working'>
{{ __("text.timer_set_other_todo") }}
</span>
@endif
</li>
</ul>
</div>
<?php } ?>
<div class="tabbedwidget tab-primary ticketTabs" style="visibility:hidden;">
<ul>
<li><a href="#ticketdetails"><span class="fa fa-star"></span> <?php echo $tpl->__('tabs.ticketDetails') ?></a></li>
<li><a href="#files"><span class="fa fa-file"></span> <?php echo $tpl->__('tabs.files') ?> (<?php echo $numFiles; ?>)</a></li>
<?php if ($login::userIsAtLeast($roles::$editor)) { ?>
<li><a href="#timesheet"><span class="fa fa-clock"></span> <?php echo $tpl->__('tabs.time_tracking') ?></a></li>
<?php } ?>
<?php $tpl->dispatchTplEvent('ticketTabs', ['ticket' => $ticket]); ?>
</ul>
<div id="ticketdetails">
<form class="formModal" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}" method="post">
@include('tickets::submodules.ticketDetails')
</form>
</div>
<div id="files">
@include('files::submodules.showAll')
</div>
@if($login::userIsAtLeast($roles::$editor))
<div id="timesheet">
@include('tickets::submodules.timesheet')
</div>
@endif
@dispatchEvent('ticketTabsContent', ['ticket' => $ticket])
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function(){
<?php if (isset($_GET['closeModal'])) { ?>
jQuery.nmTop().close();
<?php } ?>
leantime.ticketsController.initTicketTabs();
<?php if ($login::userIsAtLeast($roles::$editor)) { ?>
leantime.ticketsController.initAsyncInputChange();
leantime.ticketsController.initDueDateTimePickers();
leantime.dateController.initDatePicker(".dates");
leantime.dateController.initDateRangePicker(".editFrom", ".editTo");
leantime.ticketsController.initTagsInput();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initStatusDropdown();
jQuery(".ticketTabs select").chosen();
<?php } else { ?>
leantime.authController.makeInputReadonly(".nyroModalCont");
<?php } ?>
<?php if ($login::userHasRole([$roles::$commenter])) { ?>
leantime.commentsController.enableCommenterForms();
<?php }?>
});
</script>

View File

@@ -0,0 +1,267 @@
<div class="row">
<div class="col-md-12">
<div class="row marginBottom">
<div class="col-md-12">
<!-- Type -->
<div class="form-group">
<label class="control-label">{!! __('label.todo_type') !!}</label>
<div class="">
<select id='type' name='type' class="span11">
@foreach ($ticketTypes as $types)
<option value="{{ strtolower($types) }}"
@if (strtolower($types) == strtolower($ticket->type ?? '')) selected='selected' @endif
>{!! __('label.' . strtolower($types)) !!}</option>
@endforeach
</select><br/>
</div>
</div>
</div>
</div>
<div class="row marginBottom">
<div class="col-md-12">
<h5 class="accordionTitle" id="accordion_link_tickets-organization" style="padding-bottom:15px; font-size:var(--font-size-l)">
<a href="javascript:void(0)"
class="accordion-toggle"
id="accordion_toggle_tickets-organization"
onclick="leantime.snippets.accordionToggle('tickets-organization');">
<i class="fa fa-angle-down"></i>
<span class="fa fa-folder-open"></span>
{!! __('subtitles.organization') !!}
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-tickets-organization" style="padding-left:0">
<!-- Project -->
<div class="form-group">
<label class="control-label">{!! __('label.project') !!}</label>
<select name="projectId" class="tw-w-full">
@foreach ($allAssignedprojects as $project)
<option value="{{ $project['id'] }}"
@if ($ticket->projectId == $project['id'])
selected
@elseif (session('currentProject') == $project['id'])
selected
@endif
>{{ $tpl->escape($project['name']) }}</option>
@endforeach
</select>
</div>
<!-- Milestones -->
<div class="form-group">
<label class="control-label">{!! __('label.milestone') !!}</label>
<div class="">
<div class="form-group">
<select name="milestoneid" class="span11" >
<option value="">{!! __('label.not_assigned_to_milestone') !!}</option>
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if ($ticket->milestoneid == $milestoneRow->id) selected='selected' @endif
>{{ $tpl->escape($milestoneRow->headline) }}</option>
@endforeach
</select>
</div>
</div>
</div>
<!-- Sprint -->
<div class="form-group">
<label class="control-label">{!! __('label.sprint') !!}</label>
<div class="">
<select id="sprint-select" class="span11" name="sprint"
data-placeholder="{{ $ticket->sprint }}">
<option value="">{!! __('label.backlog') !!}</option>
@if ($sprints)
@foreach ($sprints as $sprintRow)
<option value="{{ $sprintRow->id }}"
@if ($ticket->sprint == $sprintRow->id) selected='selected' @endif
>{{ $sprintRow->name }}</option>
@endforeach
@endif
</select>
</div>
</div>
<!-- Related -->
<div class="form-group">
<label class="control-label">{!! __('label.related_to') !!}</label>
<div class="">
<div class="form-group">
<select name="dependingTicketId" class="span11" >
<option value="">{!! __('label.not_related') !!}</option>
@if (is_array($ticketParents))
@foreach ($ticketParents as $ticketRow)
<option value="{{ $ticketRow->id }}"
@if ($ticket->dependingTicketId == $ticketRow->id) selected='selected' @endif
>{{ $tpl->escape($ticketRow->headline) }}</option>
@endforeach
@endif
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row marginBottom">
<div class="col-md-12">
<h5 class="accordionTitle" id="accordion_link_tickets-dates" style="padding-bottom:15px; font-size:var(--font-size-l)">
<a href="javascript:void(0)"
class="accordion-toggle"
id="accordion_toggle_tickets-dates"
onclick="leantime.snippets.accordionToggle('tickets-dates');">
<i class="fa fa-angle-down"></i>
<span class="fa fa-calendar"></span>
{!! __('subtitles.dates') !!}
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-tickets-dates" style="padding-left:0">
<div class="form-group">
<label class=" control-label">{!! __('label.working_date_from') !!}</label>
<div class="">
<input type="text" class="editFrom" style="width:100px;" name="editFrom" autocomplete="off"
value="{{ format($ticket->editFrom)->date() }}" placeholder="{{ __('language.dateformat') }}"/>
<input type="time" class="timepicker" style="width:120px;" id="timeFrom" autocomplete="off"
value="{{ format($ticket->editFrom)->time24() }}"
name="timeFrom"/>
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.working_date_to') !!}</label>
<div class="">
<input type="text" class="editTo" style="width:100px;" name="editTo" autocomplete="off"
value="{{ format($ticket->editTo)->date() }}" placeholder="{{ __('language.dateformat') }}"/>
<input type="time" class="timepicker" style="width:120px;" id="timeTo" autocomplete="off"
value="{{ format($ticket->editTo)->time24() }}"
name="timeTo"/>
</div>
</div>
</div>
</div>
</div>
<div class="row marginBottom">
<div class="col-md-12">
<h5 class="accordionTitle" id="accordion_link_tickets-timetracking" style="padding-bottom:15px; font-size:var(--font-size-l)">
<a href="javascript:void(0)"
class="accordion-toggle"
id="accordion_toggle_tickets-timetracking"
onclick="leantime.snippets.accordionToggle('tickets-timetracking');">
<i class="fa fa-angle-down"></i>
<span class="fa-regular fa-clock"></span>
{!! __('subtitle.time_tracking') !!}
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-tickets-timetracking" style="padding-left:0">
<div class="form-group">
<label class=" control-label">{!! __('label.planned_hours') !!}</label>
<div class="">
<x-global::forms.text-input value="{{ $ticket->planHours }}" name="planHours" style="width:90px;" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.estimated_hours_remaining') !!}</label>
<div class="">
<x-global::forms.text-input value="{{ $ticket->hourRemaining }}" name="hourRemaining" style="width:90px;" />
<a href="javascript:void(0)" class="infoToolTip" data-placement="left" data-toggle="tooltip" data-tippy-content="{{ __('tooltip.how_many_hours_remaining') }}">
&nbsp;<i class="fa fa-question-circle"></i>&nbsp;
</a>
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.booked_hours') !!}</label>
<div class="">
<x-global::forms.text-input disabled="disabled"
value="{{ $timesheetsAllHours }}" style="width:90px;" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.actual_hours_remaining') !!}</label>
<div class="">
<x-global::forms.text-input disabled="disabled" value="{{ $remainingHours }}" style="width:90px;" />
</div>
</div>
</div>
</div>
</div>
@dispatchEvent('beforeEndRightColumn', ['ticket' => $ticket])
</div>
</div>
<script>
jQuery(document).ready(function(){
//Set accordion states
//All accordions start open
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initComplexEditor();
// Hide the editor wrapper initially
jQuery('#descriptionEditor .tiptap-wrapper').hide();
}
});
// Initialize Tiptap complex editor
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initComplexEditor();
}
jQuery(".viewDescription").click(function(e){
if(!jQuery(e.target).is("a")) {
e.stopPropagation();
jQuery(this).hide();
jQuery('#descriptionEditor').show('fast',
function() {
// Show the Tiptap editor wrapper
jQuery('#descriptionEditor .tiptap-wrapper').show();
}
);
}
});
// Initialize recurring task dropdown
jQuery(document).ready(function($) {
$('.recurring-toggle').click(function(e) {
e.preventDefault();
e.stopPropagation();
const $dropdown = $('#recurringTaskForm');
if (!$dropdown.hasClass('loaded')) {
$dropdown.load('{{ BASE_URL }}/hx/recurringTasks/form?entityId={{ $ticket->id }}&module=tickets', function() {
$dropdown.addClass('loaded');
});
}
$dropdown.toggleClass('show');
});
$(document).click(function(e) {
if (!$(e.target).closest('.recurring-dropdown').length) {
$('.recurring-dropdown').removeClass('show');
}
});
});
Prism.highlightAll();
</script>

View File

@@ -0,0 +1,83 @@
<div class="mediamgr_category">
<form action='{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}#files' method='POST' enctype="multipart/form-data" class="formModal">
<div class="par f-left" style="margin-right: 15px;">
<input type="hidden" name="upload" value="1" />
<div class='fileupload fileupload-new' data-provides='fileupload'>
<input type="hidden" />
<div class="input-append">
<div class="uneditable-input span3">
<i class="fa-file fileupload-exists"></i><span class="fileupload-preview"></span>
</div>
<span class="btn btn-file">
<span class="fileupload-new">{!! __('buttons.select_file') !!}</span>
<span class='fileupload-exists'>{!! __('buttons.change') !!}</span>
<input type='file' name='file' />
</span>
<a href='#' class='btn fileupload-exists' data-dismiss='fileupload'>{!! __('buttons.remove') !!}</a>
</div>
</div>
</div>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.upload')" name="upload" />
</form>
<div class="clear"></div>
</div>
<div class="mediamgr_content">
<ul id='medialist' class='listfile'>
@foreach ($files as $file)
<li class="{{ $file['moduleId'] }}">
<div class="inlineDropDownContainer dropright" style="float:right;">
<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.file') !!}</li>
<li><a href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" target="_blank">{!! __('links.download') !!}</a></li>
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}?delFile={{ $file['id'] }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</a></li>
@endif
</ul>
</div>
<a class="cboxElement" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" target="_blank">
@if (in_array(strtolower($file['extension']), $imgExtensions))
<img style='max-height: 50px; max-width: 70px;' src="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" alt="" />
@else
<div style="font-size:50px; margin-bottom:10px;">
<span class="fa fa-file"></span>
</div>
@endif
<span class="filename">{{ $file['realName'] }}</span>
</a>
</li>
@endforeach
<br class="clearall" />
</ul>
</div><!--mediamgr_content-->
@if (count($files) == 0)
<div class="text-center">
<div style='width:33%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_image__folder_re_hgp7.svg') !!}
{!! __('text.no_files') !!}
</div>
</div>
@endif
<div style='clear:both'>&nbsp;</div>
<script type='text/javascript'>
leantime.replaceSVGColors();
</script>

View File

@@ -0,0 +1,24 @@
@php
use Leantime\Core\Controller\Frontcontroller;
$currentUrlPath = BASE_URL . '/' . str_replace('.', '/', Frontcontroller::getCurrentRoute());
@endphp
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon">
<span class="fa fa fa-briefcase"></span>
</div>
<div class="pagetitle">
<h1>{!! __('headlines.my_projects') !!}
</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')

View File

@@ -0,0 +1,96 @@
{{-- 关联资源区块:待办事项关联 BOM/工艺文件/工具清单/文件/wiki --}}
<div class="form-group">
<label class="control-label"><i class="fa fa-link"></i> {!! __('label.linked_resources', '关联资源') !!}</label>
{{-- 已关联资源 chips --}}
<div id="linkedResourcesList" style="margin-bottom:6px;">
@forelse (($linkedResources ?? []) as $lr)
<span class="label label-info" style="margin:2px 4px 2px 0; display:inline-block;">
{{ $lr['typeName'] ?? '' }}: {{ $lr['title'] ?? '' }}
<a href="javascript:void(0)" class="btnUnlinkResource" data-type="{{ $lr['type'] }}" data-id="{{ $lr['id'] }}" style="color:#fff;">&times;</a>
</span>
@empty
<em class="text-muted">{{ __('text.no_linked_resources', '暂未关联资源') }}</em>
@endforelse
</div>
{{-- 添加关联 --}}
<div class="row" style="margin:0;">
<select id="resourceTypeSelect" class="span5" style="width:40%;">
<option value="">选择类型</option>
@foreach (($resourceCandidates ?? []) as $group)
<option value="{{ $group['type'] }}">{{ $group['typeName'] }}</option>
@endforeach
</select>
<select id="resourceIdSelect" class="span7" style="width:55%; margin-left:5px;" disabled>
<option value="">先选类型</option>
</select>
</div>
<div style="margin-top:6px;">
<button type="button" class="btn btn-xs btn-default" id="btnAddResource"><i class="fa fa-plus"></i> 添加关联</button>
</div>
</div>
<script>
jQuery(document).ready(function () {
var TICKET_ID = {{ $ticket->id ?? 0 }};
var RESOURCE_CANDIDATES = {!! json_encode($resourceCandidates ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) !!};
var BASE_URL = '{{ BASE_URL }}';
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
// 类型切换 -> 填充资源下拉
jQuery('#resourceTypeSelect').on('change', function () {
var type = jQuery(this).val();
var $id = jQuery('#resourceIdSelect');
$id.empty();
if (!type) { $id.append('<option value="">先选类型</option>').prop('disabled', true); return; }
var group = RESOURCE_CANDIDATES.filter(function (g) { return g.type === type; })[0];
if (!group || !group.items || !group.items.length) {
$id.append('<option value="">(无可用)</option>').prop('disabled', true);
return;
}
group.items.forEach(function (it) {
$id.append('<option value="' + it.id + '">' + it.title + '</option>');
});
$id.prop('disabled', false);
});
// 添加关联
jQuery('#btnAddResource').on('click', function () {
var type = jQuery('#resourceTypeSelect').val();
var resourceId = jQuery('#resourceIdSelect').val();
if (!type || !resourceId) { alert('请选择类型和资源'); return; }
jQuery.ajax({
url: BASE_URL + '/tickets/resource-api/' + TICKET_ID + '/link',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ type: type, resourceId: parseInt(resourceId, 10) })
}).done(function () {
location.reload();
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
alert(m);
});
});
// 解除关联
jQuery('#linkedResourcesList').on('click', '.btnUnlinkResource', function () {
if (!confirm('确定解除该资源关联?')) { return; }
var type = jQuery(this).data('type');
var id = jQuery(this).data('id');
jQuery.ajax({
url: BASE_URL + '/tickets/resource-api/' + TICKET_ID + '/unlink/' + type + '/' + id,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrf() },
dataType: 'json'
}).done(function () {
location.reload();
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
alert(m);
});
});
});
</script>

View File

@@ -0,0 +1,128 @@
<p>{!! __('text.what_are_subtasks') !!}<br /><br /></p>
<ul class="sortableTicketList" style="margin-bottom:120px;">
<li class="">
<a href="javascript:void(0);" class="quickAddLink" id="subticket_new_link" onclick="jQuery('#subticket_new').toggle('fast', function() {jQuery(this).find('input[name=headline]').focus();}); jQuery(this).toggle('fast');"><i class="fas fa-plus-circle"></i> {!! __('links.quick_add_todo') !!}</a>
<div class="ticketBox hideOnLoad" id="subticket_new" >
<form method="post" class="form-group formModal" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}#substasks">
<input type="hidden" value="new" name="subtaskId" />
<input type="hidden" value="1" name="subtaskSave" />
<x-global::forms.text-input name="headline" title="{{ __('label.headline') }}" style="width:100%" placeholder="{{ __('input.placeholders.what_are_you_working_on') }}" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="quickadd" />
<input type="hidden" name="dateToFinish" id="dateToFinish" value="" />
<input type="hidden" name="status" value="3" />
<input type="hidden" name="sprint" value="{{ session('currentSprint') }}" />
<x-global::forms.button tag="a" link="javascript:void(0);" onclick="jQuery('#subticket_new').toggle('fast'); jQuery('#subticket_new_link').toggle('fast');" contentRole="tertiary">
{!! __('links.cancel') !!}
</x-global::forms.button>
</form>
<div class="clearfix"></div>
</div>
</li>
@php
$sumPlanHours = 0;
$sumEstHours = 0;
@endphp
@foreach ($allSubTasks as $subticket)
@php
$sumPlanHours = $sumPlanHours + $subticket['planHours'];
$sumEstHours = $sumEstHours + $subticket['hourRemaining'];
if ($subticket['dateToFinish'] == '0000-00-00 00:00:00' || $subticket['dateToFinish'] == '1969-12-31 00:00:00') {
$date = __('text.anytime');
} else {
$date = new DateTime($subticket['dateToFinish']);
$date = $date->format(__('language.dateformat'));
}
@endphp
<li class="ui-state-default" id="ticket_{{ $subticket['id'] }}" >
<div class="ticketBox fixed priority-border-{{ $subticket['priority'] }}" data-val="{{ $subticket['id'] }}" >
<div class="row">
<div class="col-md-12" style="padding:0 15px;">
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer" >
<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><a href="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}?delSubtask={{ $subticket['id'] }}" class="delete formModal"><i class="fa fa-trash"></i> {!! __('links.delete_todo') !!}</a></li>
</ul>
</div>
@endif
<a href="#/tickets/showTicket/{{ $subticket['id'] }}">{{ $tpl->escape($subticket['headline']) }}</a>
</div>
</div>
<div class="row">
<div class="col-md-9" style="padding:0 15px;">
<div class="row">
<div class="col-md-4">
{!! __('label.due') !!}<input type="text" title="{{ __('label.due') }}" value="{{ $date }}" class="duedates secretInput quickDueDates" data-id="{{ $subticket['id'] }}" name="date" />
</div>
<div class="col-md-4">
{!! __('label.planned_hours') !!}<input type="text" value="{{ $subticket['planHours'] }}" name="planHours" data-label="planHours-{{ $subticket['id'] }}" class="small-input secretInput asyncInputUpdate" style="width:40px"/>
</div>
<div class="col-md-4">
{!! __('label.estimated_hours_remaining') !!}<input type="text" value="{{ $subticket['hourRemaining'] }}" name="hourRemaining" data-label="hourRemaining-{{ $subticket['id'] }}" class="small-input secretInput asyncInputUpdate" style="width:40px"/>
</div>
</div>
</div>
<div class="col-md-3" style="padding-top:3px;" >
<div class="right">
<div class="dropdown ticketDropdown effortDropdown show">
<a class="dropdown-toggle f-left label-default effort" href="javascript:void(0);" role="button" id="effortDropdownMenuLink{{ $subticket['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">@if ($subticket['storypoints'] != '' && $subticket['storypoints'] > 0){{ $efforts['' . $subticket['storypoints']] }}@else{!! __('label.story_points_unkown') !!}@endif</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $subticket['id'] }}">
<li class="nav-header border">{!! __('dropdown.how_big_todo') !!}</li>
@foreach ($efforts as $effortKey => $effortValue)
<li class='dropdown-item'>
<a href='javascript:void(0);' data-value='{{ $subticket['id'] }}_{{ $effortKey }}' id='ticketEffortChange{{ $subticket['id'] }}{{ $effortKey }}'>{{ $effortValue }}</a>
</li>
@endforeach
</ul>
</div>
@php
if (isset($statusLabels[$subticket['status']])) {
$class = $statusLabels[$subticket['status']]['class'];
$name = $statusLabels[$subticket['status']]['name'];
} else {
$class = 'label-important';
$name = 'new';
}
@endphp
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a class="dropdown-toggle f-left status {{ $class }}" href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $subticket['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $name }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $subticket['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_status') !!}</li>
@foreach ($statusLabels as $key => $label)
<li class='dropdown-item'>
<a href='javascript:void(0);' class='{{ $label['class'] }}' data-label='{{ $tpl->escape($label['name']) }}' data-value='{{ $subticket['id'] }}_{{ $key }}_{{ $label['class'] }}' id='ticketStatusChange{{ $subticket['id'] }}{{ $key }}' >{{ $tpl->escape($label['name']) }}</a>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
</li>
@endforeach
</ul>

View File

@@ -0,0 +1,56 @@
@php
use Leantime\Core\Controller\Frontcontroller;
if (!function_exists('findActive')) {
function findActive($route): string
{
if (str_contains(Frontcontroller::getCurrentRoute(), $route)) {
return 'active';
}
return '';
}
}
// Program boards inject their own kanban/table/list URLs + the route fragments used to
// highlight the active tab. Per-project views fall back to the core /tickets/* routes.
$boardTabs = $boardTabs ?? [
'kanban' => ['url' => BASE_URL . '/tickets/showKanban', 'active' => 'Kanban'],
'table' => ['url' => BASE_URL . '/tickets/showAll', 'active' => 'showAll'],
'list' => ['url' => BASE_URL . '/tickets/showList', 'active' => 'showList'],
];
@endphp
<div class="lt-tabs lt-tabs--floating lt-tabs--links hideOnPrint">
<nav class="lt-tabs-group" aria-label="{{ __('links.kanban') }} / {{ __('links.table') }} / {{ __('links.list') }}">
<ul>
<li class="{{ findActive($boardTabs['kanban']['active']) }}">
<a href="{{ $boardTabs['kanban']['url'] }}{{ $searchParams }}" preload="mouseover">
{!! __('links.kanban') !!}
</a>
</li>
<li class="{{ findActive($boardTabs['table']['active']) }}">
<a href="{{ $boardTabs['table']['url'] }}{{ $searchParams }}" preload="mouseover">
{!! __('links.table') !!}
</a>
</li>
<li class="{{ findActive($boardTabs['list']['active']) }}">
<a href="{{ $boardTabs['list']['url'] }}{{ $searchParams }}" preload="mouseover">
{!! __('links.list') !!}
</a>
</li>
</ul>
</nav>
{{-- Board actions (New / Filter / Group By) live on the right of the nav bar
like the report's period picker — so the bar is balanced and the board
needs no separate toolbar row below it. Guarded on $searchCriteria so the
partial stays safe if the nav is ever reused without the board context. --}}
@isset($searchCriteria)
<div class="lt-tabs-actions">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
@endisset
</div>

View File

@@ -0,0 +1,354 @@
<input type="hidden" value="{{ $ticket->id }}" name="id" autocomplete="off" readonly/>
<div class="row">
<div class="col-md-9">
<div class="row marginBottom">
<div class="col-md-12">
<div class="form-group">
<x-global::forms.text-input value="{{ $ticket->headline }}" name="headline" variant="headline" autocomplete="off" style="width:99%; margin-bottom:10px;" placeholder="{{ __('input.placeholders.enter_title_of_todo') }}" />
</div>
<!-- Status -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.todo_status') !!}</label>
<div class="">
<select
id="status-select"
class=""
name="status"
data-placeholder="{{ isset($ticket->status) ? $statusLabels[$ticket->status]['name'] ?? '' : '' }}"
>
@foreach ($statusLabels as $key => $label)
<option value="{{ $key }}"
@if ($ticket->status == $key) selected='selected' @endif
>{{ $label['name'] }}</option>
@endforeach
</select>
</div>
</div>
<!-- Priority -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.priority') !!}</label>
<div class="">
<select id='priority' name='priority' class="">
<option value="">{!! __('label.priority_not_defined') !!}</option>
@foreach ($priorities as $priorityKey => $priorityValue)
<option value="{{ $priorityKey }}"
@if ($priorityKey == $ticket->priority) selected='selected' @endif
>{{ $priorityValue }}</option>
@endforeach
</select>
</div>
</div>
<!-- Effort -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.effort') !!}</label>
<div class="">
<select id='storypoints' name='storypoints' class="">
<option value="">{!! __('label.effort_not_defined') !!}</option>
@foreach ($efforts as $effortKey => $effortValue)
<option value="{{ $effortKey }}"
@if ($effortKey == $ticket->storypoints) selected='selected' @endif
>{{ $effortValue }}</option>
@endforeach
</select>
</div>
</div>
<!-- Editor -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.editor') !!}</label>
<div class="">
<select data-placeholder="{{ __('label.filter_by_user') }}" style="width:175px;"
name="editorId" id="editorId" class="user-select tw-mr-sm">
<option value="">{!! __('label.not_assigned_to_user') !!}</option>
@foreach ($users as $userRow)
<option value="{{ $userRow['id'] }}"
@if ($ticket->editorId == $userRow['id']) selected='selected' @endif
>{{ $userRow['firstname'] . ' ' . $userRow['lastname'] }}</option>
@endforeach
</select>&nbsp;
</div>
<div style="padding-top:6px;">
@if ($login::userIsAtLeast($roles::$editor))
<a href="javascript:void(0);" onclick="jQuery('#editorId').val({{ session('userdata.id') }}).trigger('chosen:updated');">{!! __('label.assign_to_me') !!}</a>
@endif
</div>
</div>
<!-- Collaborators -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.collaborators') !!}</label>
<div class="">
<select data-placeholder="{{ __('label.filter_by_user') }}"
style="width:175px;"
name="collaborators[]"
id="collaborators"
class="user-select tw-mr-sm"
multiple>
@foreach ($users as $userRow)
<option value="{{ $userRow['id'] }}"
@if (in_array($userRow['id'], $ticket->collaborators ?? [])) selected='selected' @endif
>
{{ $userRow['firstname'] . ' ' . $userRow['lastname'] }}
</option>
@endforeach
</select>
</div>
</div>
<!-- Due Date -->
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.due_date') !!}</label>
<div class="">
<input type="text" class="dates" style="width:110px;" id="deadline" autocomplete="off"
value="{{ format($ticket->dateToFinish)->date() }}"
name="dateToFinish" placeholder="{{ __('language.dateformat') }}"/>
<input type="time" class="timepicker tw-mr-sm" style="width:120px;" id="dueTime" autocomplete="off"
value="{{ format($ticket->dateToFinish)->time24() }}"
name="timeToFinish"/>
</div>
<div style="padding-top:6px;">
@dispatchEvent('afterDates', ['ticket' => $ticket])
</div>
</div>
<div class="form-group tw-flex tw-w-3/5">
<label class="control-label tw-mx-m tw-w-[100px]">{!! __('label.tags') !!}</label>
<div class="">
<input type="text" value="{{ $ticket->tags }}" name="tags" id="tags" />
</div>
</div>
<br />
<div class="form-group" id="descriptionEditor">
<textarea name="description" id="ticketDescription"
class="tiptapComplex">{!! $ticket->description !== null ? htmlentities($ticket->description) : '' !!}</textarea><br/>
</div>
<input type="hidden" name="acceptanceCriteria" value=""/>
</div>
</div>
<div class="sticky-modal-footer">
<div class="row">
<div class="col-md-12" style="margin-top:15px;">
<input type="hidden" name="saveTicket" value="1" />
<input type="hidden" id="saveAndCloseButton" name="saveAndCloseTicket" value="0" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveTicket" class="saveTicketBtn" />
<x-global::forms.button tag="input" inputType="submit" variant="outline" name="saveAndCloseTicket" onclick="jQuery('#saveAndCloseButton').val('1');" :labelText="__('buttons.save_and_close')" />
</div>
</div>
</div>
@if ($ticket->id)
<br />
<hr />
@dispatchEvent('beforeSubtasks', ['ticketId' => $ticket->id])
<h4 class="widgettitle title-light"><i class="fa-solid fa-sitemap"></i> {!! __('subtitles.subtasks') !!}</h4>
<x-global::hx
wrapperId="ticketSubtasks"
:for="\Leantime\Domain\Tickets\Hxcontrollers\Subtasks::class"
:id="$ticket->id"
trigger="load"
indicator=".subtaskIndicator"
/>
<div class="htmx-indicator subtaskIndicator">
Loading Subtasks ...<br /><br />
</div>
<h4 class="widgettitle title-light"><span
class="fa-solid fa-comments"></span>{!! __('subtitles.discussion') !!}</h4>
<div class="row-fluid">
<form method="post" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}" class="formModal">
<input type="hidden" name="comment" value="1" />
@include('comments::submodules.generalComment', ['formUrl' => BASE_URL . '/tickets/showTicket/' . $ticket->id])
</form>
</div>
@endif
</div>
<div class="col-md-3">
<div class="row marginBottom">
<div class="col-md-12">
<h5 class="accordionTitle" id="accordion_link_tickets-organization" style="padding-bottom:15px; font-size:var(--font-size-l)">
<a href="javascript:void(0)"
class="accordion-toggle"
id="accordion_toggle_tickets-organization"
onclick="leantime.snippets.accordionToggle('tickets-organization');">
<i class="fa fa-angle-down"></i>
<span class="fa fa-folder-open"></span>
{!! __('subtitles.organization') !!}
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-tickets-organization" style="padding-left:0">
<!-- Type -->
<div class="form-group">
<label class="control-label">{!! __('label.todo_type') !!}</label>
<div class="">
<select id='type' name='type' class="span11">
@foreach ($ticketTypes as $types)
<option value="{{ strtolower($types) }}"
@if (strtolower($types) == strtolower($ticket->type ?? '')) selected='selected' @endif
>{!! __('label.' . strtolower($types)) !!}</option>
@endforeach
</select><br/>
</div>
</div>
<!-- Project -->
<div class="form-group">
<label class="control-label">{!! __('label.project') !!}</label>
<select name="projectId" class="tw-w-full">
@foreach ($allAssignedprojects as $project)
<option value="{{ $project['id'] }}"
@if ($ticket->projectId == $project['id'])
selected
@elseif (session('currentProject') == $project['id'])
selected
@endif
>{{ $tpl->escape($project['name']) }}</option>
@endforeach
</select>
</div>
<!-- Milestones -->
<div class="form-group">
<label class="control-label">{!! __('label.milestone') !!}</label>
<div class="">
<div class="form-group">
<select name="milestoneid" class="span11" >
<option value="">{!! __('label.not_assigned_to_milestone') !!}</option>
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if ($ticket->milestoneid == $milestoneRow->id) selected='selected' @endif
>{{ $tpl->escape($milestoneRow->headline) }}</option>
@endforeach
</select>
</div>
</div>
</div>
<!-- Sprint -->
<div class="form-group">
<label class="control-label">{!! __('label.sprint') !!}</label>
<div class="">
<select id="sprint-select" class="span11" name="sprint"
data-placeholder="{{ $ticket->sprint }}">
<option value="">{!! __('label.backlog') !!}</option>
@if ($sprints)
@foreach ($sprints as $sprintRow)
<option value="{{ $sprintRow->id }}"
@if ($ticket->sprint == $sprintRow->id) selected='selected' @endif
>{{ $sprintRow->name }}@if (! empty($sprintRow->isInherited)) {{ __('label.program_sprint') }} @endif</option>
@endforeach
@endif
</select>
</div>
</div>
<!-- Related -->
<div class="form-group">
<label class="control-label">{!! __('label.related_to') !!}</label>
<div class="">
<div class="form-group">
<select name="dependingTicketId" class="span11" >
<option value="">{!! __('label.not_related') !!}</option>
@if (is_array($ticketParents))
@foreach ($ticketParents as $ticketRow)
<option value="{{ $ticketRow->id }}"
@if ($ticket->dependingTicketId == $ticketRow->id) selected='selected' @endif
>{{ $tpl->escape($ticketRow->headline) }}</option>
@endforeach
@endif
</select>
</div>
</div>
</div>
<!-- Linked Resources -->
@include('tickets::submodules.resourceLinks')
</div>
</div>
</div>
<div class="row marginBottom">
<div class="col-md-12">
<h5 class="accordionTitle" id="accordion_link_tickets-dates" style="padding-bottom:15px; font-size:var(--font-size-l)">
<a href="javascript:void(0)"
class="accordion-toggle"
id="accordion_toggle_tickets-dates"
onclick="leantime.snippets.accordionToggle('tickets-dates');">
<i class="fa fa-angle-down"></i>
<span class="fa fa-calendar"></span>
{!! __('subtitles.schedule') !!}
</a>
</h5>
<div class="simpleAccordionContainer" id="accordion_content-tickets-dates" style="padding-left:0">
<div class="form-group">
<label class=" control-label">{!! __('label.working_date_from') !!}</label>
<div class="">
<input type="text" class="editFrom" style="width:100px;" name="editFrom" autocomplete="off"
value="{{ format($ticket->editFrom)->date() }}" placeholder="{{ __('language.dateformat') }}"/>
<input type="time" class="timepicker" style="width:120px;" id="timeFrom" autocomplete="off"
value="{{ format($ticket->editFrom)->time24() }}"
name="timeFrom"/>
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.working_date_to') !!}</label>
<div class="">
<input type="text" class="editTo" style="width:100px;" name="editTo" autocomplete="off"
value="{{ format($ticket->editTo)->date() }}" placeholder="{{ __('language.dateformat') }}"/>
<input type="time" class="timepicker" style="width:120px;" id="timeTo" autocomplete="off"
value="{{ format($ticket->editTo)->time24() }}"
name="timeTo"/>
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.planned_hours') !!} / {!! __('label.estimated_hours_remaining') !!}</label>
<div class="">
<x-global::forms.text-input value="{{ $ticket->planHours }}" name="planHours" style="width:45px;" />&nbsp;/&nbsp;
<x-global::forms.text-input value="{{ $ticket->hourRemaining }}" name="hourRemaining" style="width:45px;" />
<a href="javascript:void(0)" class="infoToolTip" data-placement="left" data-toggle="tooltip" data-tippy-content="{{ __('tooltip.how_many_hours_remaining') }}">
&nbsp;<i class="fa fa-question-circle"></i>&nbsp;
</a>
</div>
</div>
</div>
</div>
</div>
@dispatchEvent('beforeEndRightColumn', ['ticket' => $ticket])
</div>
</div>
<script>
jQuery(document).ready(function(){
//Set accordion states
//All accordions start open
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initComplexEditor();
}
Prism.highlightAll();
});
</script>

View File

@@ -0,0 +1,249 @@
@php
use Leantime\Core\Controller\Frontcontroller;
$currentRoute = Frontcontroller::getCurrentRoute();
$currentUrlPath = BASE_URL . '/' . str_replace('.', '/', $currentRoute);
// Program boards render this partial under a /pgmPro/* route and can pass an explicit
// self-redirect target. Default to the current route for normal per-project views.
$searchFormUrl = $searchFormUrl ?? $currentUrlPath;
$groupBy = $groupByOptions;
$sortBy = $sortOptions;
$statusLabels = $allTicketStates;
$taskToggle = $enableTaskTypeToggle ?? false;
// Multi-project (program) context: show the project filter + the "project" group-by.
$showProjectFilter = isset($availableProjects);
// Kanban shows status as columns, so the status group-by is suppressed. Program kanban
// templates set $isKanbanView; per-project views derive it from the route.
$isKanbanView = $isKanbanView ?? ($currentRoute === 'tickets.showKanban');
@endphp
<form action="" method="get" id="ticketSearch">
<input type="hidden" value="1" name="search"/>
@unless ($showProjectFilter)
<input type="hidden" value="{{ session('currentProject') }}" name="projectId" id="projectIdInput"/>
@endunless
<div class="filterWrapper" style="display:inline-block; position:relative; vertical-align: bottom; margin-bottom:20px;">
{{-- Kept as a raw <a> (not forms.button): this button is whitespace-sensitive the
</a>@if adjacency below avoids an inline-block gap, and the component would reintroduce
surrounding whitespace. Migrate once the component guarantees whitespace-tight output. --}}
<a class="btn btn-link" onclick="leantime.ticketsController.toggleFilterBar();" style="margin-right:5px;"
data-tippy-content="{{ __('popover.filter') }}">
<i class="fas fa-filter"></i> Filter{!! $numOfFilters > 0 ? " <span class='badge badge-primary'>" . $numOfFilters . '</span> ' : '' !!}
{{-- Please don't change the code formatting below, if not right next to each other it somehow adds a space between the two buttons and increases the distance --}}
</a>@if ($currentRoute !== 'tickets.roadmap' && $currentRoute != 'tickets.showProjectCalendar')<div class="btn-group viewDropDown">
<button class="btn btn-link dropdown-toggle" type="button" data-toggle="dropdown" data-tippy-content="{{ __('popover.group_by') }}">
<span class="fa-solid fa-diagram-project"></span> Group By
@if ($searchCriteria['groupBy'] != 'all' && $searchCriteria['groupBy'] != '')
<span class="badge badge-primary">1</span>
@endif
</button>
<ul class="dropdown-menu">
@foreach ($groupBy as $input)
@if ($input['field'] === 'status' && $isKanbanView)
@continue
@endif
{{-- "Group by project" only makes sense on a multi-project (program) board. --}}
@if ($input['field'] === 'projectId' && ! $showProjectFilter)
@continue
@endif
<li>
<span class="radio">
<input
type="radio"
name="groupBy"
@if ($searchCriteria['groupBy'] == $input['field']) checked='checked' @endif
value="{{ $input['field'] }}"
id="{{ $input['id'] }}"
onclick="leantime.ticketsController.initTicketSearchUrlBuilder('{{ $searchFormUrl }}')"
/>
<label for="{{ $input['id'] }}">{!! __("label.{$input['label']}") !!}</label>
</span>
</li>
@endforeach
</ul>
</div>
@endif
<div class="filterBar hideOnLoad" style="width:250px;">
<div class="row-fluid">
@dispatchEvent('filters.beforeFirstBarField')
@if ($showProjectFilter)
<div class="">
<label class="inline">{!! __('label.project') !!}</label>
<div class="form-group">
<select data-placeholder="{{ __('label.project') }}" title="{{ __('label.project') }}" name="projects" multiple="multiple" class="project-select" id="projectsSelect">
<option value="" data-placeholder="true">{!! __('label.project') !!}</option>
@foreach ($availableProjects as $projectFilterId => $projectFilterName)
<option value="{{ $projectFilterId }}"
@if (isset($searchCriteria['projects']) && in_array((string) $projectFilterId, explode(',', (string) $searchCriteria['projects']), true)) selected='selected' @endif
>{{ $tpl->escape($projectFilterName) }}</option>
@endforeach
</select>
</div>
</div>
@endif
<div class="">
<label class="inline">{!! __('label.user') !!}</label>
<div class="form-group">
<select data-placeholder="{{ __('input.placeholders.filter_by_user') }}" title="{{ __('input.placeholders.filter_by_user') }}" name="users" multiple="multiple" class="user-select" id="userSelect">
<option value="" data-placeholder="true">All Users</option>
@foreach ($users as $userRow)
<option value="{{ $userRow['id'] }}"
@if ($searchCriteria['users'] !== false && $searchCriteria['users'] !== null && array_search($userRow['id'], explode(',', $searchCriteria['users'])) !== false) selected='selected' @endif
>{!! sprintf(__('text.full_name'), $tpl->escape($userRow['firstname']), $tpl->escape($userRow['lastname'])) !!}</option>
@endforeach
</select>
</div>
</div>
<div class="">
<label class="inline">{!! __('label.milestone') !!}</label>
<div class="form-group">
<select data-placeholder="{{ __('input.placeholders.filter_by_milestone') }}" multiple="multiple" title="{{ __('input.placeholders.filter_by_milestone') }}" name="milestone" id="milestoneSelect">
<option value="" data-placeholder="true">{!! __('label.all_milestones') !!}</option>
<option value="0" @if (isset($searchCriteria['milestone']) && in_array('0', explode(',', (string) $searchCriteria['milestone']), true)) selected='selected' @endif>{!! __('label.not_assigned_to_milestone') !!}</option>
@if (is_array($milestones))
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if (isset($searchCriteria['milestone']) && ($searchCriteria['milestone'] == $milestoneRow->id) && array_search($milestoneRow->id, explode(',', $searchCriteria['milestone'])) !== false) selected='selected' @endif
>{{ $tpl->escape($milestoneRow->headline) }}</option>
@endforeach
@endif
</select>
</div>
</div>
<div class="">
<label class="inline">{!! __('label.todo_type') !!}</label>
<div class="form-group">
<select multiple="multiple" data-placeholder="{{ __('input.placeholders.filter_by_type') }}" title="{{ __('input.placeholders.filter_by_type') }}" name="type" id="typeSelect">
<option value="" data-placeholder="true">{!! __('label.all_types') !!}</option>
@foreach ($types as $type)
<option value="{{ $type }}"
@if (isset($searchCriteria['type']) && array_search($type, explode(',', $searchCriteria['type'])) !== false) selected='selected' @endif
>{{ $type }}</option>
@endforeach
</select>
</div>
</div>
<div class="">
<label class="inline">{!! __('label.todo_priority') !!}</label>
<div class="form-group">
<select multiple="multiple" data-placeholder="{{ __('input.placeholders.filter_by_priority') }}" title="{{ __('input.placeholders.filter_by_priority') }}" name="priority" id="prioritySelect">
<option value="" data-placeholder="true">{!! __('label.all_priorities') !!}</option>
@foreach ($priorities as $priorityKey => $priorityValue)
<option value="{{ $priorityKey }}"
@if (isset($searchCriteria['priority']) && array_search($priorityKey, explode(',', $searchCriteria['priority'])) !== false) selected='selected' @endif
>{{ $priorityValue }}</option>
@endforeach
</select>
</div>
</div>
<div class="">
<label class="inline">{!! __('label.todo_status') !!}</label>
<div class="form-group">
<select multiple="multiple" data-placeholder="{{ __('input.placeholders.filter_by_status') }}" name="status" multiple="multiple" class="status-select" id="statusSelect">
<option value="" data-placeholder="true">All Statuses</option>
<option value="not_done" @if ($searchCriteria['status'] !== false && str_contains($searchCriteria['status'], 'not_done')) selected='selected' @endif>{!! __('label.not_done') !!}</option>
@foreach ($statusLabels as $key => $label)
<option value="{{ $key }}"
@if ($searchCriteria['status'] !== false && array_search((string) $key, explode(',', $searchCriteria['status'])) !== false) selected='selected' @endif
>{{ $tpl->escape($label['name']) }}</option>
@endforeach
</select>
</div>
</div>
<div class="">
<div class="form-group">
<label class="inline">{!! __('label.search_term') !!}</label>
<x-global::forms.text-input name="termInput" id="termInput"
style="width: 230px"
value="{{ $searchCriteria['term'] }}"
placeholder="{{ __('label.search_term') }}" />
</div>
</div>
<div class="" style="margin-top:15px;">
<x-global::forms.button tag="input" inputType="submit" :labelText="__('buttons.search')" name="search" class="form-control" contentRole="primary" />
</div>
</div>
</div>
@if (isset($taskToggle) && $taskToggle === true)
<div class="" style="float:right; margin-left:5px; ">
<input type="checkbox" class="toggle" id="taskTypeToggle" onchange="jQuery('#ticketSearch').submit();" name="showTasks" value="true" {{ ($showTasks === 'true') ? 'checked="checked"' : '' }} style="margin-right:5px;" />
<label style="text-wrap: nowrap; float:right;">Show Tasks</label>
</div>
@endif
</div>
<div class="clearall"></div>
@dispatchEvent('filters.beforeBar')
@dispatchEvent('filters.beforeFormClose')
</form>
<script>
jQuery(document).ready(function() {
new SlimSelect({
select: '#userSelect',
settings: {
placeholderText: 'All Users',
},
});
new SlimSelect({
select: '#milestoneSelect',
settings: {
placeholderText: 'All Milestones',
},
});
new SlimSelect({
select: '#prioritySelect',
settings: {
placeholderText: 'All Priorities',
},
});
new SlimSelect({
select: '#typeSelect',
settings: {
placeholderText: 'All Types',
},
});
new SlimSelect({
select: '#statusSelect',
settings: {
placeholderText: 'All Statuses',
},
});
@if ($showProjectFilter)
new SlimSelect({
select: '#projectsSelect',
settings: {
placeholderText: @js(__('label.project')),
},
});
@endif
leantime.ticketsController.initTicketSearchSubmit('{{ $searchFormUrl }}');
})
</script>

View File

@@ -0,0 +1,124 @@
@php
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Sprints\Models\Sprints;
$currentUrlPath = BASE_URL . '/' . str_replace('.', '/', Frontcontroller::getCurrentRoute());
$currentSprintId = $currentSprint;
$searchSprint = $searchCriteria['sprint'] ?? '';
$sprint = false;
$currentSprintId = $currentSprintId == '' ? 'all' : $currentSprintId;
if ($currentSprintId == 'all') {
$sprint = new Sprints;
$sprint->id = 'all';
$sprint->name = __('links.all_todos');
}
if ($currentSprintId == 'backlog') {
$sprint = new Sprints;
$sprint->id = 'backlog';
$sprint->name = __('links.backlog');
}
if (is_array($sprints)) {
foreach ($sprints as $sprintRow) {
if ($sprintRow->id == $currentSprintId) {
$sprint = $sprintRow;
break;
}
}
}
@endphp
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon">
<span class="fa fa-fw fa-thumb-tack"></span>
</div>
<div class="pagetitle">
{{-- Build "Client // Project" only from the parts that exist, so a
missing client/project never leaves a stray " // ". (`.` binds
tighter than `??`, so the old inline expression mis-grouped.) --}}
@php
$headerParts = array_filter(
[session('currentProjectClient'), session('currentProjectName')],
fn ($part) => $part !== null && $part !== ''
);
@endphp
<h5>{{ implode(' / ', $headerParts) }}</h5>
{{-- Migrated to the shared subject switcher (was a hand-rolled
header-title-dropdown). The sprint menu items stay here they're
domain-specific — but the "To-Dos // <current> ▾" chrome is now the
component. Zero visual change. --}}
<x-global::subjectSwitcher
:parent="__('headlines.todos')"
:current="$sprint !== false ? $sprint->name : __('dropdown.choose_sprint')">
<li><a class="wikiModal inlineEdit" href="#/sprints/editSprint/"><i class="fa-solid fa-plus"></i> {!! __('links.create_sprint_no_icon') !!}</a></li>
<li class='nav-header border'></li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('all'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.all_todos') !!}</a>
</li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('backlog'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.backlog') !!}</a>
</li>
@foreach ($sprints as $sprintRow)
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val({{ $sprintRow->id }}); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{{ $tpl->escape($sprintRow->name) }}@if (! empty($sprintRow->isInherited)) <span class="label label-info">{{ __('label.program_sprint') }}</span>@endif<br /><small>{!! sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()) !!}</small></a>
</li>
@endforeach
</x-global::subjectSwitcher>
<input type="hidden" name="sprintSelect" id="sprintSelect" value="{{ $currentSprintId }}" />
</div>
{{-- Right cluster on the breadcrumb bar: board stats + (for a real sprint
view only) the sprint edit/delete menu. --}}
<div class="pageheader-right">
{{-- Header rule (2026-08-03): LEFT = orientation (where you are),
RIGHT = information (what about it) meta, then status, then ,
one vertically-centered row. --}}
@isset($boardSummary)
@php
// Board metrics as a one-line meta string; segments join with a
// single " · " so it reads cleanly no matter which are present.
$summaryParts = [sprintf(__('label.board_task_count'), $boardSummary->total)];
if ($boardSummary->unassigned > 0) {
$summaryParts[] = sprintf(__('label.board_unassigned'), $boardSummary->unassigned);
}
if ($boardSummary->dueThisWeek > 0) {
$summaryParts[] = sprintf(__('label.board_due_this_week'), $boardSummary->dueThisWeek);
}
if ($boardSummary->lastUpdated !== null) {
$summaryParts[] = sprintf(__('label.board_updated'), $boardSummary->lastUpdated->setToUserTimezone()->diffForHumans());
}
@endphp
<div class="pageheader-meta">{{ implode(' · ', $summaryParts) }}</div>
@endisset
@if (
($currentSprint !== false)
&& ($currentSprint !== null)
&& count($sprints) > 0
&& $currentSprintId != 'all'
&& $currentSprintId != 'backlog'
)
<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">
{{-- Inherited (program-owned) sprints are managed at the program level, never edited
or deleted from a child project. The service IDOR-fences this server-side too.
$sprint can be false (stale/deleted currentSprint), so guard the object access. --}}
@if ($login::userIsAtLeast($roles::$editor) && (! is_object($sprint) || empty($sprint->isInherited)))
<li><a href="#/sprints/editSprint/{{ $currentSprint }}">{!! __('link.edit_sprint') !!}</a></li>
<li><a href="#/sprints/delSprint/{{ $currentSprint }}" class="delete">{!! __('links.delete_sprint') !!}</a></li>
@endif
</ul>
</span>
@endif
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')

View File

@@ -0,0 +1,15 @@
@if (can('tickets.create') && !empty($newField))
<div class="btn-group pull-left" style="margin-right:5px;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{!! __('links.new_with_icon') !!} <span class="caret"></span></button>
<ul class="dropdown-menu">
@foreach ($newField as $option)
<li>
<a
href="{{ !empty($option['url']) ? $option['url'] : '' }}"
class="{{ !empty($option['class']) ? $option['class'] : '' }}"
> {!! !empty($option['text']) ? __($option['text']) : '' !!}</a>
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,15 @@
@if (can('tickets.create') && !empty($newField))
<div class="btn-group">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{!! __('links.new_with_icon') !!} <span class="caret"></span></button>
<ul class="dropdown-menu">
@foreach ($newField as $option)
<li>
<a
href="{{ !empty($option['url']) ? $option['url'] : '' }}"
class="{{ !empty($option['class']) ? $option['class'] : '' }}"
> {!! !empty($option['text']) ? __($option['text']) : '' !!}</a>
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,88 @@
@php
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Sprints\Models\Sprints;
$currentUrlPath = BASE_URL . '/' . str_replace('.', '/', Frontcontroller::getCurrentRoute());
$currentSprintId = $currentSprint;
$searchSprint = $searchCriteria['sprint'] ?? '';
$sprint = false;
$currentSprintId = $currentSprintId == '' ? 'all' : $currentSprintId;
if ($currentSprintId == 'all') {
$sprint = new Sprints;
$sprint->id = 'all';
$sprint->name = __('links.all_todos');
}
if ($currentSprintId == 'backlog') {
$sprint = new Sprints;
$sprint->id = 'backlog';
$sprint->name = __('links.backlog');
}
if (is_array($sprints)) {
foreach ($sprints as $sprintRow) {
if ($sprintRow->id == $currentSprintId) {
$sprint = $sprintRow;
break;
}
}
}
@endphp
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon">
<span class="fa fa-fw fa-chart-gantt"></span>
</div>
<div class="pagetitle">
@if (($sprints !== false) && ($sprints !== null) && count($sprints) > 0)
<x-global::subjectSwitcher
:parent="__('headline.milestones')"
:current="$sprint !== false ? $sprint->name : __('label.select_board')">
<li><a class="wikiModal inlineEdit" href="#/sprints/editSprint/"><i class="fa-solid fa-plus"></i> {!! __('links.create_sprint_no_icon') !!}</a></li>
<li class='nav-header border'></li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('all'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.all_todos') !!}</a>
</li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('backlog'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('label.backlog') !!}</a>
</li>
@foreach ($sprints as $sprintRow)
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val({{ $sprintRow->id }}); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{{ $tpl->escape($sprintRow->name) }}<br /><small>{!! sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()) !!}</small></a>
</li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{!! __('headline.milestones') !!}</h1>
@endif
<input type="hidden" name="sprintSelect" id="sprintSelect" value="{{ $currentSprintId }}" />
</div>
@if (
($currentSprint !== false)
&& ($currentSprint !== null)
&& count($sprints) > 0
&& $currentSprintId != 'all'
&& $currentSprintId != 'backlog'
)
<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">
{{-- Inherited (program-owned) sprints are managed at the program level only.
$sprint can be false (stale/deleted currentSprint), so guard the object access. --}}
@if ($login::userIsAtLeast($roles::$editor) && (! is_object($sprint) || empty($sprint->isInherited)))
<li><a href="#/sprints/editSprint/{{ $currentSprint }}">{!! __('link.edit_sprint') !!}</a></li>
<li><a href="#/sprints/delSprint/{{ $currentSprint }}" class="delete">{!! __('links.delete_sprint') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')

View File

@@ -0,0 +1,35 @@
@php
use Leantime\Core\Controller\Frontcontroller;
if (!function_exists('findActive')) {
function findActive($route): string
{
if (str_contains(Frontcontroller::getCurrentRoute(), $route)) {
return 'active';
}
return '';
}
}
@endphp
<div class="lt-tabs lt-tabs--floating lt-tabs--links hideOnPrint">
<nav class="lt-tabs-group" aria-label="{{ __('links.timeline') }}">
<ul>
<li class="{{ findActive('roadmap') }}">
<a href="{{ BASE_URL }}/tickets/roadmap{{ $searchParams }}" preload="mouseover">
{!! __('links.timeline') !!}
</a>
</li>
<li class="{{ findActive('showAllMilestones') }}">
<a href="{{ BASE_URL }}/tickets/showAllMilestones{{ $searchParams }}" preload="mouseover">
{!! __('links.table') !!}
</a>
</li>
<li class="{{ findActive('Calendar') }}">
<a href="{{ BASE_URL }}/tickets/showProjectCalendar{{ $searchParams }}" preload="mouseover">
{!! __('links.calendar') !!}
</a>
</li>
</ul>
</nav>
</div>

View File

@@ -0,0 +1,90 @@
@php
$values = $timesheetValues;
if ($remainingHours < 0) {
$remainingHours = 0;
}
$currentPay = $userHours * $userInfo['wage'];
@endphp
<div class="row">
<div class="col-md-6">
<h4 class="widgettitle title-light"><span class="fa fa-clock-o"></span>{!! __('headline.add_time_entry', false) !!}</h4>
<br />
<form method="post" action="{{ BASE_URL }}/tickets/showTicket/{{ $ticket->id }}#timesheet" class="formModal">
<label for="kind">{!! __('label.timesheet_kind') !!}</label>
<span class="field">
<select id="kind" name="kind">
@foreach ($kind as $key => $row)
<option value="{{ $key }}"
@if ($row == $values['kind']) selected="selected" @endif
>{!! __(strtolower($row)) !!}</option>
@endforeach
</select>
</span>
<label for="timesheetdate">{!! __('label.date') !!}:</label>
<input type="text" id="timesheetdate" name="date" class="dates" value="{{ format($values['date'])->date() }}" /><br/>
<label for="hours">{!! __('label.hours') !!}</label>
<span class="field">
<x-global::forms.text-input id="hours" name="hours" value="{{ $values['hours'] }}" size="7" variant="small" />
</span>
<label for="description">{!! __('label.description') !!}</label>
<span class="field">
<x-global::forms.textarea rows="5" cols="50" id="description" name="description">{{ $values['description'] }}</x-global::forms.textarea><br />
</span>
<input type="hidden" name="saveTimes" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveTimes" />
</form>
</div>
<div class="col-md-6">
<h4 class="widgettitle title-light"><span class="fa fa-bar-chart"></span>{!! __('subtitles.logged_hours_chart') !!}</h4>
<br />
<canvas id="canvas"></canvas>
<p><br />
{!! __('label.planned_hours') !!}: {{ $ticket->planHours }}<br />
{!! __('label.booked_hours') !!}: {{ $timesheetsAllHours }}<br />
{!! __('label.actual_hours_remaining') !!}: {{ $remainingHours }}<br />
</p>
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function($) {
var d2 = [];
var d3 = [];
var labels = [];
@php
// Emit every value through json_encode so the generated JS is always
// valid. Raw concatenation broke when a value was null or non-numeric
// (e.g. Postgres SUM()/plan hours formatting), producing a JS syntax
// error that killed the whole time-tracking modal. (#3353)
$sum = 0;
$planHours = is_numeric($ticket->planHours) ? (float) $ticket->planHours : 0;
foreach ($ticketHours as $hours) {
$sum = $sum + (float) ($hours['summe'] ?? 0);
try {
$label = dtHelper()->parseDbDateTime($hours['utc'])->setToUserTimezone()->format('Y-m-d');
echo 'labels.push(' . json_encode($label) . ");\n";
echo 'd2.push(' . json_encode($sum) . ");\n";
echo 'd3.push(' . json_encode($planHours) . ");\n";
} catch (\Exception $e) {
// not much we can do at this point. Ignore the datapoint
}
}
@endphp
leantime.ticketsController.initTimeSheetChart(labels, d2, d3, "canvas")
});
</script>

View File

@@ -0,0 +1,73 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Add a new milestone to a project.
*/
class AddMilestoneTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'addMilestone';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Adds a new milestone to the project. Milestones are used as hierarchical element to group tasks.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('headline')->description('Title of the milestone.')->required()
->string('color')->description('Choose a color for this milestone using a hex code.')->required()
->string('editFrom')->description('Start date of the milestone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')->required()
->string('editTo')->description('End date of the milestone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')->required()
->integer('projectId')->description('Project ID.')->required()
->integer('editorId')->description('Editor ID.')->required()
->integer('dependentMilestone')->description('Dependent milestone ID.');
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$params = [
'headline' => $arguments['headline'],
'projectId' => (int) ($arguments['projectId'] ?? 0),
'editorId' => (int) ($arguments['editorId'] ?? 0),
'dependentMilestone' => ($arguments['dependentMilestone'] ?? null),
'tags' => $arguments['color'],
'editFrom' => $arguments['editFrom'],
'editTo' => $arguments['editTo'],
];
$result = $this->ticketsService->quickAddMilestone($params);
if ($result) {
return ToolResult::text("Milestone created successfully with ID: {$result}");
}
return ToolResult::error('Failed to create milestone.');
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create multiple milestones for a project in a single operation.
*/
class AddMilestonesForProjectTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'addMilestonesForProject';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Creates multiple milestones for a project in a single operation. This is the primary tool for project setup and should be used instead of multiple addMilestone calls. Each milestone should include headline, color, editFrom, editTo, and optionally dependentMilestone. Much more efficient than individual milestone creation for project planning phases.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID where milestones will be created.')->required()
->integer('editorId')->description('User ID who will be the editor/creator of these milestones.')->required()
->raw('milestones', ['type' => 'array', 'description' => 'Array of milestone definitions. Each element should contain: headline, color (hex), editFrom (ISO8601), editTo (ISO8601), and optionally dependentMilestone (ID). Example: [{"headline": "Phase 1", "color": "#FF0000", "editFrom": "2024-01-01T09:00:00Z", "editTo": "2024-01-31T17:00:00Z"}]'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$editorId = (int) ($arguments['editorId'] ?? 0);
$milestones = ($arguments['milestones'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
foreach ($milestones as $milestoneData) {
try {
if (! isset($milestoneData['headline']) || ! isset($milestoneData['color']) ||
! isset($milestoneData['editFrom']) || ! isset($milestoneData['editTo'])) {
$failureCount++;
$results[] = [
'headline' => $milestoneData['headline'] ?? 'Unknown',
'status' => 'error',
'message' => 'Missing required fields (headline, color, editFrom, editTo)',
];
continue;
}
$params = [
'headline' => $milestoneData['headline'],
'projectId' => $projectId,
'editorId' => $editorId,
'dependentMilestone' => $milestoneData['dependentMilestone'] ?? null,
'tags' => $milestoneData['color'],
'editFrom' => $milestoneData['editFrom'],
'editTo' => $milestoneData['editTo'],
];
$result = $this->ticketsService->quickAddMilestone($params);
if ($result) {
$successCount++;
$results[] = [
'headline' => $milestoneData['headline'],
'status' => 'success',
'id' => $result,
];
} else {
$failureCount++;
$results[] = [
'headline' => $milestoneData['headline'],
'status' => 'error',
'message' => 'Failed to create milestone',
];
}
} catch (\Exception $e) {
$failureCount++;
$results[] = [
'headline' => $milestoneData['headline'] ?? 'Unknown',
'status' => 'error',
'message' => $e->getMessage(),
];
}
}
return ToolResult::text(
"Milestone creation completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create a subtask for an existing task.
*/
class AddSubtaskTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'addSubtask';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Creates a new subtask of another existing task. Tool to break down large tasks into smaller elements.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('parentTicket')->description('ID of the parent ticket.')->required()
->string('headline')->description('Title of the subtask.')->required()
->string('description')->description('Subtask description.')
->integer('projectId')->description('Project ID.')
->integer('editorId')->description('Assigned user ID.')
->integer('userId')->description('Creator user ID.')
->string('dateToFinish')->description('Due date in ISO8601 format.')
->integer('status')->description('Status ID.')
->string('editFrom')->description('Scheduled start date in ISO8601 format.')
->string('editTo')->description('Scheduled end date in ISO8601 format.')
->integer('effort')->description('Effort T-shirt size: 1=XS, 2=S, 3=M, 5=L, 8=XL, 13=XXL.')
->integer('planHours')->description('Planned hours for this subtask.')
->integer('priority')->description('Priority: 1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest.');
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$params = [
'headline' => $arguments['headline'],
'description' => ($arguments['description'] ?? ''),
'projectId' => ($arguments['projectId'] ?? null),
'editorId' => ($arguments['editorId'] ?? null),
'userId' => ($arguments['userId'] ?? null),
'dateToFinish' => ($arguments['dateToFinish'] ?? null),
'status' => (int) ($arguments['status'] ?? 3),
'sprint' => null,
'editFrom' => ($arguments['editFrom'] ?? null),
'editTo' => ($arguments['editTo'] ?? null),
'milestone' => null,
'type' => 'subtask',
'dependingTicketId' => (int) ($arguments['parentTicket'] ?? 0),
'storypoints' => (int) ($arguments['effort'] ?? 3),
'priority' => (int) ($arguments['priority'] ?? 3),
'planHours' => (int) ($arguments['planHours'] ?? 0),
];
$result = $this->ticketsService->quickAddTicket($params);
if ($result) {
return ToolResult::text("Subtask created successfully. ID: {$result}");
}
return ToolResult::error('Failed to create subtask.');
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create a new task.
*/
class AddTaskTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'addTask';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Adds a new task quickly based on the provided parameters.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('headline')->description('Title of the task.')->required()
->string('description')->description('Task description.')
->integer('projectId')->description('Project ID.')
->integer('editorId')->description('Assigned user ID.')
->integer('userId')->description('Creator user ID.')
->string('dateToFinish')->description('Due date in ISO8601 format.')
->integer('status')->description('Status ID.')
->integer('sprint')->description('Sprint ID.')
->string('editFrom')->description('Scheduled start date in ISO8601 format.')
->string('editTo')->description('Scheduled end date in ISO8601 format.')
->integer('milestone')->description('Milestone ID.');
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$params = [
'headline' => $arguments['headline'],
'description' => ($arguments['description'] ?? ''),
'projectId' => ($arguments['projectId'] ?? null),
'editorId' => ($arguments['editorId'] ?? null),
'userId' => ($arguments['userId'] ?? null),
'dateToFinish' => ($arguments['dateToFinish'] ?? null),
'status' => (int) ($arguments['status'] ?? 3),
'sprint' => ($arguments['sprint'] ?? null),
'editFrom' => ($arguments['editFrom'] ?? null),
'editTo' => ($arguments['editTo'] ?? null),
'milestone' => ($arguments['milestone'] ?? null),
'type' => 'task',
];
$result = $this->ticketsService->quickAddTicket($params);
if ($result) {
return ToolResult::text("Task created successfully. ID: {$result}");
}
return ToolResult::error('Failed to create task.');
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create multiple tasks in a single operation.
*/
class BulkAddTasksTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'bulkAddTasks';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Adds multiple task in a single operation. Expects an array of task data where each element contains the same fields as addTicket. Required fields for each ticket: headline, projectId. Optional fields: description, editorId, userId, dateToFinish, status, sprint, editFrom, editTo, milestone.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('tasks', ['type' => 'array', 'description' => 'Array of task objects. Each must contain headline and projectId.'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$tasks = ($arguments['tasks'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
foreach ($tasks as $taskData) {
try {
$params = [
'headline' => $taskData['headline'] ?? '',
'description' => $taskData['description'] ?? '',
'projectId' => $taskData['projectId'] ?? null,
'editorId' => $taskData['editorId'] ?? null,
'userId' => $taskData['userId'] ?? null,
'dateToFinish' => $taskData['dateToFinish'] ?? null,
'status' => $taskData['status'] ?? 3,
'sprint' => $taskData['sprint'] ?? null,
'editFrom' => $taskData['editFrom'] ?? null,
'editTo' => $taskData['editTo'] ?? null,
'milestone' => $taskData['milestone'] ?? null,
'type' => 'task',
];
$result = $this->ticketsService->quickAddTicket($params);
if ($result) {
$successCount++;
$results[] = ['headline' => $taskData['headline'], 'status' => 'success', 'id' => $result];
} else {
$failureCount++;
$results[] = ['headline' => $taskData['headline'], 'status' => 'error', 'message' => 'Failed to create task'];
}
} catch (\Exception $e) {
$failureCount++;
$results[] = ['headline' => $taskData['headline'] ?? 'Unknown', 'status' => 'error', 'message' => $e->getMessage()];
}
}
return ToolResult::text(
"Bulk task creation completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Update multiple tasks in a single operation.
*/
class BulkEditTasksTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'bulkEditTasks';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Updates multiple tasks in a single operation. Expects an array where each element contains ticketId and the fields to update. Field names that can be updated are: headline, description, projectId, editorId, userId, dateToFinish, status, editFrom, editTo, milestoneId';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('updates', ['type' => 'array', 'description' => 'Array of update objects. Each must have id and the fields to update.'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$updates = ($arguments['updates'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
foreach ($updates as $update) {
if (! isset($update['id'])) {
$failureCount++;
$results[] = ['status' => 'error', 'message' => 'Missing task ID'];
continue;
}
$id = $update['id'];
unset($update['id']);
if ($this->ticketsService->patch($id, $update)) {
$successCount++;
$results[] = ['id' => $id, 'status' => 'success'];
} else {
$failureCount++;
$results[] = ['id' => $id, 'status' => 'error', 'message' => 'Failed to update task'];
}
}
return ToolResult::text(
"Bulk task update completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Schedule multiple tasks by setting editFrom and editTo dates.
*/
class BulkScheduleTasksTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'bulkScheduleTasks';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Schedules multiple tasks by setting editFrom and editTo dates in a single operation. This allows timeboxing multiple tasks efficiently. All tasks must have at least 15 minutes duration. Expects an array where each element contains taskId, editFrom, and editTo.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('schedules', ['type' => 'array', 'description' => 'Array of schedule objects. Each must have taskId, editFrom (ISO8601), and editTo (ISO8601).'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$schedules = ($arguments['schedules'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
$validationErrors = [];
foreach ($schedules as $index => $schedule) {
if (! isset($schedule['taskId']) || ! isset($schedule['editFrom']) || ! isset($schedule['editTo'])) {
$validationErrors[] = "Schedule #{$index} is missing required fields (taskId, editFrom, editTo)";
continue;
}
try {
$editFrom = new \DateTime($schedule['editFrom']);
$editTo = new \DateTime($schedule['editTo']);
} catch (\Exception $e) {
$validationErrors[] = "Schedule #{$index} has invalid date format. Use ISO8601 (e.g. 2024-04-30T15:00:00-04:00)";
continue;
}
$duration = $editTo->getTimestamp() - $editFrom->getTimestamp();
if ($duration < 900) {
$validationErrors[] = "Schedule #{$index} is shorter than the minimum 15 minute duration";
}
}
if (! empty($validationErrors)) {
return ToolResult::error("Validation failed:\n- ".implode("\n- ", $validationErrors));
}
foreach ($schedules as $schedule) {
$taskId = $schedule['taskId'];
$params = [
'editFrom' => $schedule['editFrom'],
'editTo' => $schedule['editTo'],
];
if ($this->ticketsService->patch($taskId, $params)) {
$successCount++;
$results[] = ['taskId' => $taskId, 'status' => 'success'];
} else {
$failureCount++;
$results[] = ['taskId' => $taskId, 'status' => 'error', 'message' => 'Failed to schedule task'];
}
}
return ToolResult::text(
"Bulk task scheduling completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create multiple subtasks for a parent task.
*/
class CreateSubtasksForTaskTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'createSubtasksForTask';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Creates multiple subtasks for a parent task in a single operation. This is useful for breaking down a large task into smaller components. Optionally schedules these subtasks with editFrom/editTo dates. Expects a parent task ID and an array of subtask definitions.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('parentTaskId')->description('ID of the parent task.')->required()
->raw('subtasks', ['type' => 'array', 'description' => 'Array of subtask objects. Each needs headline. Optional: description, userId, editFrom, editTo, dateToFinish (all ISO8601), priority (1=Critical to 5=Lowest), planHours, effort (1=XS to 13=XXL).'])->required()
->integer('projectId')->description('Project ID (defaults to parent task\'s project).');
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$parentTaskId = (int) ($arguments['parentTaskId'] ?? 0);
$subtasks = ($arguments['subtasks'] ?? []);
$projectId = ($arguments['projectId'] ?? null);
$parentTask = $this->ticketsService->getTicket($parentTaskId);
if (! $parentTask) {
return ToolResult::error("Parent task with ID {$parentTaskId} not found.");
}
if ($projectId === null) {
$projectId = $parentTask->projectId;
}
$results = [];
$successCount = 0;
$failureCount = 0;
foreach ($subtasks as $subtaskData) {
$params = [
'headline' => $subtaskData['headline'] ?? '',
'description' => $subtaskData['description'] ?? '',
'projectId' => $projectId,
'editorId' => null,
'userId' => $subtaskData['userId'] ?? session('userdata.id'),
'dateToFinish' => $subtaskData['dateToFinish'] ?? null,
'status' => $subtaskData['status'] ?? 3,
'sprint' => null,
'editFrom' => $subtaskData['editFrom'] ?? null,
'editTo' => $subtaskData['editTo'] ?? null,
'milestone' => null,
'type' => 'subtask',
'dependingTicketId' => $parentTaskId,
'storypoints' => $subtaskData['effort'] ?? 2,
'priority' => $subtaskData['priority'] ?? 3,
'planHours' => $subtaskData['planHours'] ?? 1,
];
$result = $this->ticketsService->quickAddTicket($params);
if ($result) {
$successCount++;
$results[] = ['headline' => $subtaskData['headline'], 'status' => 'success', 'id' => $result];
} else {
$failureCount++;
$results[] = ['headline' => $subtaskData['headline'] ?? 'Unknown', 'status' => 'error', 'message' => 'Failed to create subtask'];
}
}
return ToolResult::text(
"Subtask creation completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Update an existing milestone.
*/
class EditMilestoneTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'editMilestone';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Updates an existing milestone as defined by the `id` parameter and using an array `params` where they key is the column name and the value is the value that it should be updated to. Dates need to be provided as iso8601 strings (example: 2024-04-30T15:00:00-04:00). Commonly updated fields are: headline, type, description, projectId, editFrom, editTo, planHours.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the milestone to update.')->required()
->raw('params', ['type' => 'object', 'description' => 'Key-value pairs of fields to update. Example: {"headline": "New title", "editFrom": "2024-04-30T15:00:00-04:00"}'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$params = ($arguments['params'] ?? null);
if (is_array($params) && ! empty($params) && isset($params[0]) && is_array($params[0])) {
$params = $params[0];
}
if (! is_array($params)) {
return ToolResult::error('The params parameter is not a valid object. Provide key-value pairs.');
}
if ($this->ticketsService->patch($id, $params)) {
return ToolResult::text('Milestone updated successfully.');
}
return ToolResult::error('Failed to update milestone.');
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Update an existing task.
*/
class EditTaskTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'editTask';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Updates an existing task as defined by the `id` parameter and using an array `params` where they key is the column name and the value is the value that it should be updated to. Dates need to be provided as iso8601 strings (example: 2024-04-30T15:00:00-04:00). Commonly updated fields are: headline, type, description, projectId, status (needs to be a status id, see the getStatusLabel tool for further information), storypoints (often called effort), dateToFinish (due date), planHours. Due dates should not be used as a form of timeboxing since they may represent client due dates. Instead use editFrom and editTo dates to schedule a task for a specific user.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the task to update.')->required()
->raw('params', ['type' => 'object', 'description' => 'Key-value pairs of fields to update. Example: {"headline": "New title", "status": 3}'])->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$params = ($arguments['params'] ?? null);
if (is_array($params) && ! empty($params) && isset($params[0]) && is_array($params[0])) {
$params = $params[0];
}
if (! is_array($params)) {
return ToolResult::error('The params parameter is not a valid object. Provide key-value pairs.');
}
if ($this->ticketsService->patch($id, $params)) {
return ToolResult::text('Task updated successfully.');
}
return ToolResult::error('Failed to update task.');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
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\Tickets\Services\Tickets;
/**
* Search for milestones in a project.
*/
#[IsReadOnly]
class FindMilestonesTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'findMilestones';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Gets all milestones from the database given search criteria array. All dates are returned in the format YYYY-MM-DD hh:mm:ss in the UTC timezone';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID of the milestones to retrieve.')->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$milestones = $this->ticketsService->getAllMilestones(['currentProject' => $projectId, 'type' => 'milestone']);
$results = "## MILESTONES \n";
if (empty($milestones)) {
$results .= 'No Milestones found for this project.';
return ToolResult::text($results);
}
foreach ($milestones as $milestone) {
$progress = $this->ticketsService->getMilestoneProgress($milestone->id);
$results .= 'Title: '.$milestone->headline."\n";
$results .= 'Start Date: '.$milestone->editFrom."\n";
$results .= 'End Date: '.$milestone->editTo."\n";
$results .= 'Color: '.$milestone->tags."\n";
$results .= 'Progress: '.$progress."% completed\n";
}
return ToolResult::text($results);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
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\Tickets\Models\Tickets as TicketModel;
use Leantime\Domain\Tickets\Services\Tickets;
use Leantime\Domain\Tickets\Support\TicketFormatter;
/**
* Search for tasks across multiple projects efficiently.
*/
#[IsReadOnly]
class FindTasksTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'findTasks';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Search for tasks across multiple projects efficiently. This is the primary tool for task discovery and should be used for ALL task searches, whether for single projects or multiple projects. Use this instead of separate project queries. Supports filtering by user, status, and date ranges. Important: Execute this tool only ONCE. Ensure you have all project ids you want to query ready and in this array.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('projectIds', ['type' => 'array', 'description' => 'Array of project IDs (numbers) to search. For multiple projects use [1,3,4,5]. This is more efficient than separate calls.'])->required()
->string('dateRangeFrom')->description('Modified date range from filter. ISO8601 format (e.g. 2024-04-30T15:00:00-04:00).')
->string('dateRangeTo')->description('Modified date range to filter. ISO8601 format.')
->integer('userId')->description('User ID to filter by. Empty for all users. 0 for current user.')
->string('status')->description('Status filter: open (not completed), done (completed), all (everything). Default is all.')
->integer('limit')->description('Maximum tasks per project. Default 20.');
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectIds = ($arguments['projectIds'] ?? []);
$userId = ($arguments['userId'] ?? null);
$status = ($arguments['status'] ?? 'all');
$limit = (int) ($arguments['limit'] ?? 20);
$allResults = [];
$totalTasks = 0;
foreach ($projectIds as $projectId) {
$effectiveUserId = $userId;
if ($effectiveUserId === null) {
$effectiveUserId = '';
}
if ($effectiveUserId === 0) {
$effectiveUserId = session('userdata.id') ?? '';
}
$searchCriteria = [
'users' => $effectiveUserId,
'currentProject' => $projectId,
];
if ($status === 'open') {
$searchCriteria['status'] = 'not_done';
} elseif ($status === 'done') {
$searchCriteria['status'] = 'done';
}
$tickets = $this->ticketsService->getAll($searchCriteria, $limit);
if (! empty($tickets)) {
$allResults[$projectId] = $tickets;
$totalTasks += count($tickets);
}
}
if (empty($allResults)) {
return ToolResult::text('No tasks found for the specified criteria.');
}
$response = "## TASK RESULTS ACROSS PROJECTS\n";
if ($totalTasks >= ($limit * count($projectIds))) {
$response .= "**Showing first {$limit} results per project. Use more specific filters to reduce results.**\n\n";
}
foreach ($allResults as $projectId => $tickets) {
$projectName = $tickets[0]['projectName'] ?? "Project {$projectId}";
$response .= "## {$projectName} (Project ID: {$projectId})\n";
$response .= '**Found '.count($tickets)." tasks**\n\n";
foreach ($tickets as $ticket) {
$ticketModel = new TicketModel($ticket);
$formatter = new TicketFormatter($ticketModel);
$response .= $formatter->format()."\n\n";
}
$response .= "---\n\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Leantime\Domain\Tickets\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\Tickets\Services\Tickets;
/**
* Get status labels across all projects for a user.
*/
#[IsReadOnly]
class GetAllStatusLabelsByUserIdTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'getAllStatusLabelsByUserId';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Get the status labels available for a user across multiple projects This can help get the statuses in a human readable format for a given project.';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('userId')->description('User ID to get status labels for.')->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$userId = (int) ($arguments['userId'] ?? 0);
if ($userId === 0) {
$userId = session('userdata.id');
}
$status = $this->ticketsService->getAllStatusLabelsByUserId($userId);
$statusAIString = '## Status Labels';
foreach ($status as $projectKey => $projectStatus) {
foreach ($projectStatus as $key => $value) {
$result = [
'id' => $key,
'projectId' => $projectKey,
'name' => $value['name'],
'statusType' => $value['statusType'],
'isKanbanColumn' => $value['kanbanCol'] === '1' ? 'yes' : 'no',
];
$statusAIString .= Str::toMarkdown($result)."\n";
}
}
return ToolResult::text($statusAIString);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Leantime\Domain\Tickets\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\Tickets\Services\Tickets;
/**
* Get a single milestone by ID.
*/
#[IsReadOnly]
class GetMilestoneTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'getMilestone';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Gets one individual milestone by milestone id. If the user is not allowed to see the task, false is returned. All dates are returned in the format YYYY-MM-DD hh:mm:ss in the UTC timezone';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the milestone to retrieve.')->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$ticket = $this->ticketsService->getTicket($id);
if (! $ticket) {
return ToolResult::error("Milestone with ID {$id} not found.");
}
return ToolResult::text(Str::toMarkdown($ticket)."\n");
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Leantime\Domain\Tickets\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\Tickets\Services\Tickets;
/**
* Get status labels for a project.
*/
#[IsReadOnly]
class GetStatusLabelsTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'getStatusLabels';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Get the status labels available for a project. This can help get the statuses in a human readable format for a given project, since the database only stores the status id and each project can define its own statuses. The array is keyed by the status id and returns an array with name (language string), class (css class), statusType (INPROGRESS, DONE, NEW) and whether its available in a kanbanCol (true/false).';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to get status labels for.')->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$status = $this->ticketsService->getStatusLabels($projectId);
$statusAIString = '## Status Labels';
foreach ($status as $key => $value) {
$result = [
'id' => $key,
'name' => $value['name'],
'statusType' => $value['statusType'],
'isKanbanColumn' => $value['kanbanCol'] === '1' ? 'yes' : 'no',
];
$statusAIString .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($statusAIString);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Leantime\Domain\Tickets\Tools;
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\Tickets\Services\Tickets;
use Leantime\Domain\Tickets\Support\TicketFormatter;
/**
* Get a single task by ID.
*/
#[IsReadOnly]
class GetTaskTool extends Tool
{
public function __construct(
private Tickets $ticketsService,
) {}
/**
* Get the tool name.
*/
public function name(): string
{
return 'getTicket';
}
/**
* Get the tool description.
*/
public function description(): string
{
return 'Gets one individual task by task id. If the user is not allowed to see the task, false is returned. All dates are returned in the format YYYY-MM-DD hh:mm:ss in the UTC timezone';
}
/**
* Define the tool input schema.
*/
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the task to retrieve.')->required();
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$ticket = $this->ticketsService->getTicket($id);
if ($ticket) {
$formatter = new TicketFormatter($ticket);
return ToolResult::text($formatter->format());
}
return ToolResult::error("Could not find task with id {$id}");
}
}

View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Tickets\Controllers\TicketResourceApi;
/*
|--------------------------------------------------------------------------
| Tickets Domain Routes
|--------------------------------------------------------------------------
| 待办事项 ↔ 资源关联BOM/工艺文件/工具清单/文件/wikiJSON API。
| 每个动作都在 TicketResourceService 层按 ticket 所属项目自鉴权;
| CheckPermissions 中间件由 RouteLoader 统一应用到原生路由。
*/
Route::prefix('tickets/resource-api')->group(function () {
Route::post('/{ticketId}/link', [TicketResourceApi::class, 'link']);
Route::delete('/{ticketId}/unlink/{type}/{resourceId}', [TicketResourceApi::class, 'unlink']);
Route::get('/{projectId}/candidates', [TicketResourceApi::class, 'candidates']);
Route::get('/{ticketId}/links', [TicketResourceApi::class, 'links']);
});