diff --git a/.idea/leantime-oss.iml b/.idea/leantime-oss.iml index fc8d27cf07..65dc0d0f04 100644 --- a/.idea/leantime-oss.iml +++ b/.idea/leantime-oss.iml @@ -120,8 +120,9 @@ + - + \ No newline at end of file diff --git a/app/Core/Middleware/AuthCheck.php b/app/Core/Middleware/AuthCheck.php index df08ad89e2..0865d0e7e5 100644 --- a/app/Core/Middleware/AuthCheck.php +++ b/app/Core/Middleware/AuthCheck.php @@ -40,6 +40,7 @@ class AuthCheck 'cron.run', 'auth.callback', 'auth.redirect', + 'api.timelogs', ]; public function __construct( diff --git a/app/Domain/Api/Controllers/Timelogs.php b/app/Domain/Api/Controllers/Timelogs.php new file mode 100644 index 0000000000..e2ef6a9692 --- /dev/null +++ b/app/Domain/Api/Controllers/Timelogs.php @@ -0,0 +1,84 @@ +timesheetsRepo = $timesheetsRepo; + $this->usersRepo = $usersRepo; + $this->tokenRepo = $tokenRepo; + } + + public function get(array $params): Response + { + $token = $this->incomingRequest->getBearerToken(); + if (empty($token)) { + return new JsonResponse(['error' => 'Unauthorized'], 401); + } + + $tokenRecord = $this->tokenRepo->findToken($token); + if (!$tokenRecord) { + return new JsonResponse(['error' => 'Unauthorized'], 401); + } + + $email = trim($params['email'] ?? ''); + $date = trim($params['date'] ?? ''); + + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new JsonResponse(['error' => 'Missing or invalid email parameter'], 400); + } + + if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return new JsonResponse(['error' => 'Missing or invalid date parameter (expected YYYY-MM-DD)'], 400); + } + + $user = $this->usersRepo->getUserByEmail($email); + if (!$user) { + return new JsonResponse(['error' => 'No user found for the given email'], 404); + } + + $logs = $this->timesheetsRepo->getTimesheetsByUserAndDate((int) $user['id'], $date); + + $grouped = []; + foreach ($logs as $log) { + $ticketName = '#'.$log['ticketId'].' - '.($log['headline'] ?? 'Unknown ticket'); + $grouped[$ticketName][] = [ + 'duration_minutes' => (int) round((float) $log['hours'] * 60), + 'description' => $log['description'] ?? '', + ]; + } + + $data = array_map( + fn($ticketName, $entries) => [ + 'ticket_name' => $ticketName, + 'logs' => $entries, + ], + array_keys($grouped), + array_values($grouped) + ); + + $this->tokenRepo->updateLastUsedAt($tokenRecord['id']); + + return new JsonResponse([ + 'email' => $email, + 'date' => $date, + 'data' => array_values($data), + ]); + } +} \ No newline at end of file diff --git a/app/Domain/Install/Repositories/Install.php b/app/Domain/Install/Repositories/Install.php index 626fd05b65..7ecee78d4f 100644 --- a/app/Domain/Install/Repositories/Install.php +++ b/app/Domain/Install/Repositories/Install.php @@ -73,6 +73,7 @@ class Install 30408, 30409, 30410, + 30411, ]; /** @@ -476,6 +477,7 @@ private function sqlPrep(): string `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) DEFAULT NULL, `projectKey` varchar(10) DEFAULT NULL, + `incrementalTicketId` tinyint(1) NOT NULL DEFAULT 1, `clientId` int(100) DEFAULT NULL, `details` text, `state` int(2) DEFAULT NULL, @@ -2123,6 +2125,34 @@ public function update_sql_30410(): bool|array } + return count($errors) ? $errors : true; + } + + public function update_sql_30411(): bool|array + { + $errors = []; + + try { + $columnExists = $this->connection->select( + "SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'zp_projects' + AND COLUMN_NAME = 'incrementalTicketId'" + ); + + if ($columnExists[0]->count == 0) { + $this->connection->statement( + 'ALTER TABLE `zp_projects` ADD COLUMN `incrementalTicketId` TINYINT(1) NOT NULL DEFAULT 1 AFTER `projectKey`' + ); + } + } catch (\Exception $e) { + Log::error('Failed to add incrementalTicketId column: '.$e->getMessage()); + if (! str_contains($e->getMessage(), 'Duplicate column name')) { + array_push($errors, 'Failed to add incrementalTicketId column: '.$e->getMessage()); + } + } + return count($errors) ? $errors : true; } } diff --git a/app/Domain/Notifications/Services/Messengers.php b/app/Domain/Notifications/Services/Messengers.php index 8cc98b3f40..e9e3dfd082 100644 --- a/app/Domain/Notifications/Services/Messengers.php +++ b/app/Domain/Notifications/Services/Messengers.php @@ -76,8 +76,16 @@ private function slackWebhook(NotificationModel $notification): bool $statusLabelsArray = $ticketService->getStatusLabels($notification->projectId); $slackWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackWebhookURL"); + $slackChannelId = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackChannelId"); - if ($slackWebhookURL !== '' && $slackWebhookURL !== false && !is_array($notification->entity) && (str_contains(strtolower($statusLabelsArray[$notification->entity->status]['name']), 'ready to test') || str_contains(strtolower($statusLabelsArray[$notification->entity->status]['name']), 'qa')) ) { + $statusKey = !is_array($notification->entity) ? ($notification->entity->status ?? null) : null; + $statusLabel = $statusKey !== null && isset($statusLabelsArray[$statusKey]) ? $statusLabelsArray[$statusKey] : null; + $slackNotifyEnabled = $statusLabel !== null && !empty($statusLabel['slackNotify']); + + $slackConfigured = ($slackWebhookURL !== '' && $slackWebhookURL !== false) + && ($slackChannelId !== '' && $slackChannelId !== false); + + if ($slackConfigured && !is_array($notification->entity) && $slackNotifyEnabled) { $message = $this->prepareMessage($notification); $entity = $notification->entity; @@ -91,9 +99,12 @@ private function slackWebhook(NotificationModel $notification): bool $title = $ticketNumber ? "$ticketNumber - $headline" : $headline; + $statusName = $statusLabel['name'] ?? ''; + $pretext = session('userdata.name') . ' has moved the ticket to ' . $statusName; + $message[0] = [ - 'pretext' => session('userdata.name') . " has the ticket ready to test", - 'fallback' => session('userdata.name') . " has the ticket ready to test", + 'pretext' => $pretext, + 'fallback' => $pretext, 'title' => $title, 'title_link' => $ticketUrl, 'color' => '#006d9f', diff --git a/app/Domain/Projects/Controllers/ShowProject.php b/app/Domain/Projects/Controllers/ShowProject.php index 4020d31134..ec3993b00e 100644 --- a/app/Domain/Projects/Controllers/ShowProject.php +++ b/app/Domain/Projects/Controllers/ShowProject.php @@ -247,6 +247,7 @@ public function run() $values = [ 'name' => $_POST['name'], 'projectKey' => $projectKey, + 'incrementalTicketId' => isset($_POST['incrementalTicketId']) && $_POST['incrementalTicketId'] === '1', 'details' => $_POST['details'], 'clientId' => $_POST['clientId'], 'state' => $_POST['projectState'], diff --git a/app/Domain/Projects/Js/projectsController.js b/app/Domain/Projects/Js/projectsController.js index 123fa7bce7..80a5658491 100644 --- a/app/Domain/Projects/Js/projectsController.js +++ b/app/Domain/Projects/Js/projectsController.js @@ -190,6 +190,18 @@ leantime.projectsController = (function () { jQuery('#todoStatusList').append("
  • " + statusCopy.html() + "
  • "); + var todosettings = document.getElementById("todosettings"); + if (todosettings) { + var slackEnabled = todosettings.getAttribute("data-slack-enabled") === "1"; + var lastLine = todosettings.querySelector("#todoStatusList li:last-child"); + var slackCheckbox = lastLine && lastLine.querySelector(".slack-notify-checkbox"); + if (slackCheckbox && !slackEnabled) { + slackCheckbox.disabled = true; + var msg = todosettings.getAttribute("data-slack-channel-required-msg"); + if (msg) { slackCheckbox.setAttribute("title", msg); } + } + } + jQuery("#todosettings select.colorChosen").chosen("destroy"); leantime.projectsController.initSelectFields(); jQuery("#todoStatusList").sortable("destroy"); diff --git a/app/Domain/Projects/Repositories/Projects.php b/app/Domain/Projects/Repositories/Projects.php index d212be00e7..aea84bf43c 100644 --- a/app/Domain/Projects/Repositories/Projects.php +++ b/app/Domain/Projects/Repositories/Projects.php @@ -420,6 +420,7 @@ public function getProject($id): array|bool zp_projects.id, zp_projects.name, zp_projects.projectKey, + zp_projects.incrementalTicketId, zp_projects.clientId, zp_projects.details, zp_projects.state, @@ -666,6 +667,7 @@ public function editProject(array $values, $id): void $query = 'UPDATE zp_projects SET name = :name, projectKey = :projectKey, + incrementalTicketId = :incrementalTicketId, details = :details, clientId = :clientId, state = :state, @@ -687,6 +689,8 @@ public function editProject(array $values, $id): void $stmn->bindValue('name', $values['name'], PDO::PARAM_STR); $projectKeyValue = $values['projectKey'] ?? null; $stmn->bindValue('projectKey', $projectKeyValue, $projectKeyValue === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $incrementalTicketId = isset($values['incrementalTicketId']) && $values['incrementalTicketId'] ? 1 : 0; + $stmn->bindValue('incrementalTicketId', $incrementalTicketId, PDO::PARAM_INT); $stmn->bindValue('details', $values['details'] ?? '', PDO::PARAM_STR); $stmn->bindValue('clientId', $values['clientId'] ?? '', PDO::PARAM_STR); $stmn->bindValue('state', $values['state'] ?? '', PDO::PARAM_STR); diff --git a/app/Domain/Projects/Templates/showProject.tpl.php b/app/Domain/Projects/Templates/showProject.tpl.php index 2858fda3ec..7a43ca639b 100644 --- a/app/Domain/Projects/Templates/showProject.tpl.php +++ b/app/Domain/Projects/Templates/showProject.tpl.php @@ -310,7 +310,7 @@ -
    +
      get('todoStatus') as $key => $ticketStatus) { ?> @@ -367,12 +367,19 @@ />
    -
    -
    +
    + - +
    + +
    + get('slackChannelId'); $slackEnabled = !empty($slackChannelId); ?> + + e($tpl->__('text.slack_channel_required_for_notify')) .'"' : ''; ?>/> +
    +
    @@ -444,9 +451,13 @@
    -
    -
    - +
    + + +
    +
    + +
    diff --git a/app/Domain/Projects/Templates/submodules/projectDetails.sub.php b/app/Domain/Projects/Templates/submodules/projectDetails.sub.php index 3875a8c586..6a21593bd0 100644 --- a/app/Domain/Projects/Templates/submodules/projectDetails.sub.php +++ b/app/Domain/Projects/Templates/submodules/projectDetails.sub.php @@ -64,6 +64,23 @@ +
    +
    +
    +
    + + style="margin: 0; flex-shrink: 0; width: 1.25rem; height: 1.25rem;" /> + +
    +

    + __('label.incremental_ticket_id_description'); echo $incDesc === 'label.incremental_ticket_id_description' ? 'Incremental ticket numbering (1, 2, 3...) for this project. When disabled, tickets use a different ID scheme.' : $incDesc; ?> +

    +
    +
    +

    diff --git a/app/Domain/Tickets/Models/Tickets.php b/app/Domain/Tickets/Models/Tickets.php index 20dae33c77..443ecf809b 100644 --- a/app/Domain/Tickets/Models/Tickets.php +++ b/app/Domain/Tickets/Models/Tickets.php @@ -68,6 +68,10 @@ class Tickets public ?string $projectKey = null; + public mixed $incrementalTicketId = null; + + public mixed $projectTicketNumber = null; + public ?string $clientName = ''; public ?string $userFirstname = ''; @@ -125,6 +129,8 @@ public function __construct(array|bool $values = false) $this->milestoneid = $values['milestoneid'] ?? ''; $this->projectName = $values['projectName'] ?? ''; $this->projectKey = $values['projectKey'] ?? null; + $this->incrementalTicketId = $values['incrementalTicketId'] ?? null; + $this->projectTicketNumber = $values['projectTicketNumber'] ?? null; $this->clientName = $values['clientName'] ?? ''; $this->userFirstname = $values['userFirstname'] ?? ''; $this->userLastname = $values['userLastname'] ?? ''; diff --git a/app/Domain/Tickets/Repositories/Tickets.php b/app/Domain/Tickets/Repositories/Tickets.php index 5e9f5b7469..115e21c27f 100644 --- a/app/Domain/Tickets/Repositories/Tickets.php +++ b/app/Domain/Tickets/Repositories/Tickets.php @@ -30,6 +30,7 @@ class Tickets 'statusType' => 'NEW', 'kanbanCol' => true, 'sortKey' => 1, + 'slackNotify' => false, ], 1 => [ 'name' => 'status.blocked', @@ -37,6 +38,7 @@ class Tickets 'statusType' => 'INPROGRESS', 'kanbanCol' => true, 'sortKey' => 2, + 'slackNotify' => false, ], 4 => [ 'name' => 'status.in_progress', @@ -44,6 +46,7 @@ class Tickets 'statusType' => 'INPROGRESS', 'kanbanCol' => true, 'sortKey' => 3, + 'slackNotify' => false, ], 2 => [ 'name' => 'status.waiting_for_approval', @@ -51,6 +54,7 @@ class Tickets 'statusType' => 'INPROGRESS', 'kanbanCol' => true, 'sortKey' => 4, + 'slackNotify' => false, ], 0 => [ 'name' => 'status.done', @@ -58,6 +62,7 @@ class Tickets 'statusType' => 'DONE', 'kanbanCol' => true, 'sortKey' => 5, + 'slackNotify' => false, ], -1 => [ 'name' => 'status.archived', @@ -65,6 +70,7 @@ class Tickets 'statusType' => 'DONE', 'kanbanCol' => false, 'sortKey' => 6, + 'slackNotify' => false, ], ]; @@ -156,6 +162,9 @@ public function getStateLabels($projectId = null): array } } else { $statusList[$key] = $status; + if (! isset($statusList[$key]['slackNotify'])) { + $statusList[$key]['slackNotify'] = false; + } } } } @@ -346,6 +355,8 @@ public function getAllBySearchCriteria(array $searchCriteria, string $sort = 'st COALESCE(ROUND(timesheet_agg.total_hours, 2), 0) AS bookedHours, zp_projects.name AS projectName, zp_projects.projectKey AS projectKey, + COALESCE(zp_projects.incrementalTicketId, 1) AS incrementalTicketId, + (SELECT COUNT(*) FROM zp_tickets t2 WHERE t2.projectId = zp_tickets.projectId AND t2.id <= zp_tickets.id) AS projectTicketNumber, zp_clients.name AS clientName, zp_clients.id AS clientId, t1.id AS authorId, @@ -893,6 +904,8 @@ public function getTicket($id): \Leantime\Domain\Tickets\Models\Tickets|bool milestones.headline AS milestoneHeadline, zp_projects.name AS projectName, zp_projects.projectKey AS projectKey, + COALESCE(zp_projects.incrementalTicketId, 1) AS incrementalTicketId, + (SELECT COUNT(*) FROM zp_tickets t2 WHERE t2.projectId = zp_tickets.projectId AND t2.id <= zp_tickets.id) AS projectTicketNumber, zp_projects.details AS projectDescription, zp_clients.name AS clientName, zp_user.firstname AS userFirstname, diff --git a/app/Domain/Tickets/Services/Tickets.php b/app/Domain/Tickets/Services/Tickets.php index 72a15c356f..fad454ed01 100644 --- a/app/Domain/Tickets/Services/Tickets.php +++ b/app/Domain/Tickets/Services/Tickets.php @@ -141,6 +141,7 @@ public function saveStatusLabels($params): bool 'statusType' => $params['labelType-'.$labelKey] ?? 'NEW', 'kanbanCol' => $params['labelKanbanCol-'.$labelKey] ?? false, 'sortKey' => $params['labelSort-'.$labelKey] ?? 99, + 'slackNotify' => isset($params['labelSlackNotify-'.$labelKey]) && $params['labelSlackNotify-'.$labelKey], ]; } @@ -714,6 +715,17 @@ public function getAllGrouped($searchCriteria): array // Sort main groups switch ($searchCriteria['groupBy']) { + case 'editorId': + $currentUserId = (string) session('userdata.id'); + if (isset($ticketGroups[$currentUserId])) { + $currentUserGroup = $ticketGroups[$currentUserId]; + unset($ticketGroups[$currentUserId]); + $ticketGroups = array_sort($ticketGroups, 'label'); + $ticketGroups = [$currentUserId => $currentUserGroup] + $ticketGroups; + } else { + $ticketGroups = array_sort($ticketGroups, 'label'); + } + break; case 'status': case 'priority': case 'storypoints': diff --git a/app/Domain/Tickets/Templates/partials/ticketCard.blade.php b/app/Domain/Tickets/Templates/partials/ticketCard.blade.php index a7dea364cb..a6f315ac1d 100644 --- a/app/Domain/Tickets/Templates/partials/ticketCard.blade.php +++ b/app/Domain/Tickets/Templates/partials/ticketCard.blade.php @@ -7,7 +7,11 @@ {{ $row['parentHeadline'] }} // @endif @endif - {{ !empty($row['projectKey']) ? $row['projectKey'] . '-' : '#' }}{{ $row['id'] }}
    + @php + $useIncremental = isset($row['incrementalTicketId']) && (int)($row['incrementalTicketId'] ?? 0) === 1; + $displayNum = $useIncremental && isset($row['projectTicketNumber']) ? (int)$row['projectTicketNumber'] : $row['id']; + $prefix = !empty($row['projectKey']) ? $row['projectKey'] . '-' : '#'; + @endphp{{ $prefix }}{{ $displayNum }}
    @if(isset($row['pinned']) && $row['pinned']) diff --git a/app/Domain/Tickets/Templates/showAll.tpl.php b/app/Domain/Tickets/Templates/showAll.tpl.php index df7b025d21..b4eb16e158 100644 --- a/app/Domain/Tickets/Templates/showAll.tpl.php +++ b/app/Domain/Tickets/Templates/showAll.tpl.php @@ -136,11 +136,10 @@ dispatchTplEvent('allTicketsTable.afterRowStart', ['rowNum' => $rowNum, 'tickets' => $allTickets]); ?> escape($row['projectKey']) . '-' . $tpl->escape($row['id']); - } else { - echo '#' . $tpl->escape($row['id']); - } + $useIncremental = isset($row['incrementalTicketId']) && (int) $row['incrementalTicketId'] === 1; + $displayNum = $useIncremental && isset($row['projectTicketNumber']) ? (int) $row['projectTicketNumber'] : $row['id']; + $prefix = !empty($row['projectKey']) ? $tpl->escape($row['projectKey']) . '-' : '#'; + echo $prefix . $tpl->escape($displayNum); ?> diff --git a/app/Domain/Tickets/Templates/showKanban.tpl.php b/app/Domain/Tickets/Templates/showKanban.tpl.php index e1179e54b5..f8667ac10a 100644 --- a/app/Domain/Tickets/Templates/showKanban.tpl.php +++ b/app/Domain/Tickets/Templates/showKanban.tpl.php @@ -266,7 +266,12 @@ class="ticketBox moveable container priority-border-" escape($row['parentHeadline']) ?> // - +

    @@ -418,8 +423,14 @@ class="ticketBox moveable container priority-border-" - + + htmlspecialchars(trim($tag)), $tags); + echo implode(', ', $tags); + ?>