diff --git a/app/Domain/Tickets/Js/ticketsController.js b/app/Domain/Tickets/Js/ticketsController.js
index 73c53dc618..2af84b45ba 100644
--- a/app/Domain/Tickets/Js/ticketsController.js
+++ b/app/Domain/Tickets/Js/ticketsController.js
@@ -1285,6 +1285,10 @@ leantime.ticketsController = (function () {
var $lastCard = $column.children('.ticketBox.moveable').last();
if ($lastCard.length) {
+ requestData.afterPinnedRank = parseInt($lastCard.attr('data-kanban-pinned-rank'), 10);
+ if (isNaN(requestData.afterPinnedRank)) {
+ requestData.afterPinnedRank = 1;
+ }
requestData.afterSortIndex = parseInt($lastCard.attr('data-kanban-sort-index'), 10) || 0;
requestData.afterTicketId = parseInt($lastCard.attr('data-ticket-id'), 10) || 0;
}
diff --git a/app/Domain/Tickets/Repositories/Tickets.php b/app/Domain/Tickets/Repositories/Tickets.php
index 70e7dfb117..933de13b75 100644
--- a/app/Domain/Tickets/Repositories/Tickets.php
+++ b/app/Domain/Tickets/Repositories/Tickets.php
@@ -638,10 +638,19 @@ public function getAllBySearchCriteria(array $searchCriteria, string $sort = 'st
public function getKanbanPageBySearchCriteria(array $searchCriteria, int $limit, int $offset = 0): array
{
+ $pinnedTicketIds = array_values(array_unique(array_map('intval', $searchCriteria['pinnedTicketIds'] ?? [])));
+ $pinnedRankExpression = '1';
+ if (! empty($pinnedTicketIds)) {
+ $pinnedIdIn = DbCore::arrayToPdoBindingString('pinnedTicket', count($pinnedTicketIds));
+ $pinnedRankExpression = "CASE WHEN zp_tickets.id IN({$pinnedIdIn}) THEN 0 ELSE 1 END";
+ }
+ $kanbanCursorCondition = '';
+
$query = "
SELECT
zp_tickets.id,
- zp_tickets.kanbanSortIndex
+ zp_tickets.kanbanSortIndex,
+ {$pinnedRankExpression} AS pinnedRank
FROM
zp_tickets
LEFT JOIN zp_projects ON zp_tickets.projectId = zp_projects.id
@@ -712,23 +721,33 @@ public function getKanbanPageBySearchCriteria(array $searchCriteria, int $limit,
}
if (
- isset($searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId'])
+ isset($searchCriteria['afterPinnedRank'], $searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId'])
+ && $searchCriteria['afterPinnedRank'] !== null
&& $searchCriteria['afterSortIndex'] !== null
&& $searchCriteria['afterTicketId'] !== null
) {
- $query .= ' AND (zp_tickets.kanbanSortIndex > :afterSortIndex OR (zp_tickets.kanbanSortIndex = :afterSortIndex AND zp_tickets.id < :afterTicketId))';
+ $kanbanCursorCondition = '
+ HAVING (
+ pinnedRank > :afterPinnedRank
+ OR (pinnedRank = :afterPinnedRank AND kanbanSortIndex > :afterSortIndex)
+ OR (pinnedRank = :afterPinnedRank AND kanbanSortIndex = :afterSortIndex AND id < :afterTicketId)
+ )
+ ';
}
- $query .= '
+ $query .= "
GROUP BY zp_tickets.id
- ORDER BY zp_tickets.kanbanSortIndex ASC, zp_tickets.id DESC
+ {$kanbanCursorCondition}
+ ORDER BY pinnedRank ASC, zp_tickets.kanbanSortIndex ASC, zp_tickets.id DESC
LIMIT :limit
- ';
+ ";
$query = "
SELECT
page.id,
page.kanbanSortIndex,
+ page.pinnedRank,
+ IF(page.pinnedRank = 0, 1, 0) AS pinned,
ticket.headline,
ticket.description,
ticket.date,
@@ -775,7 +794,7 @@ public function getKanbanPageBySearchCriteria(array $searchCriteria, int $limit,
LEFT JOIN zp_user AS editor ON ticket.editorId = editor.id
LEFT JOIN zp_tickets AS milestone ON ticket.milestoneid = milestone.id AND ticket.milestoneid > 0 AND milestone.type = 'milestone'
LEFT JOIN zp_tickets AS parent ON ticket.dependingTicketId = parent.id
- ORDER BY page.kanbanSortIndex ASC, ticket.id DESC
+ ORDER BY page.pinnedRank ASC, page.kanbanSortIndex ASC, ticket.id DESC
";
$stmn = $this->db->database->prepare($query);
@@ -849,14 +868,20 @@ public function getKanbanPageBySearchCriteria(array $searchCriteria, int $limit,
}
if (
- isset($searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId'])
+ isset($searchCriteria['afterPinnedRank'], $searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId'])
+ && $searchCriteria['afterPinnedRank'] !== null
&& $searchCriteria['afterSortIndex'] !== null
&& $searchCriteria['afterTicketId'] !== null
) {
+ $stmn->bindValue(':afterPinnedRank', $searchCriteria['afterPinnedRank'], PDO::PARAM_INT);
$stmn->bindValue(':afterSortIndex', $searchCriteria['afterSortIndex'], PDO::PARAM_INT);
$stmn->bindValue(':afterTicketId', $searchCriteria['afterTicketId'], PDO::PARAM_INT);
}
+ foreach ($pinnedTicketIds as $key => $ticketId) {
+ $stmn->bindValue(':pinnedTicket'.$key, $ticketId, PDO::PARAM_INT);
+ }
+
$stmn->bindValue(':requestorId', session('userdata.id') ?? '-1', PDO::PARAM_INT);
$stmn->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmn->execute();
diff --git a/app/Domain/Tickets/Services/Tickets.php b/app/Domain/Tickets/Services/Tickets.php
index 85ea4f40bb..88c4144c7c 100644
--- a/app/Domain/Tickets/Services/Tickets.php
+++ b/app/Domain/Tickets/Services/Tickets.php
@@ -765,6 +765,10 @@ public function getKanbanTicketsForStatus(array $params, int $status, int $limit
$searchCriteria['status'] = (string) $status;
$searchCriteria['afterSortIndex'] = isset($params['afterSortIndex']) ? (int) $params['afterSortIndex'] : null;
$searchCriteria['afterTicketId'] = isset($params['afterTicketId']) ? (int) $params['afterTicketId'] : null;
+ $searchCriteria['afterPinnedRank'] = isset($params['afterPinnedRank']) ? (int) $params['afterPinnedRank'] : null;
+ $searchCriteria['pinnedTicketIds'] = session()->exists('currentProject')
+ ? $this->getPinnedTickets((int) session('currentProject'))
+ : [];
$tickets = $this->ticketRepository->getKanbanPageBySearchCriteria(
$searchCriteria,
diff --git a/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php b/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php
index f3d1b101ca..a566863a30 100644
--- a/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php
+++ b/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php
@@ -8,7 +8,7 @@
$isLastColumn = $key === end($columnKeys);
?>
diff --git a/app/Domain/Tickets/Templates/submodules/kanbanSearchBar.sub.php b/app/Domain/Tickets/Templates/submodules/kanbanSearchBar.sub.php
index 26c07b4337..5457255f31 100644
--- a/app/Domain/Tickets/Templates/submodules/kanbanSearchBar.sub.php
+++ b/app/Domain/Tickets/Templates/submodules/kanbanSearchBar.sub.php
@@ -33,6 +33,7 @@ class="modern-search-input"
(function () {
var baseUrl = '= BASE_URL ?>';
var initialQuery = = json_encode($initialSearchTerm) ?>;
+ var enterHint = = json_encode($tpl->__('label.kanban_search_enter_hint')) ?>;
console.log('[KanbanSearch] Base URL:', baseUrl);
console.log('[KanbanSearch] Initial query:', initialQuery);
@@ -94,7 +95,8 @@ function tryInit() {
leantime.kanbanSearch.init({
inputSelector: '#kanbanSearch',
wrapperSelector: '#kanbanSearchWrapper',
- initialQuery: initialQuery
+ initialQuery: initialQuery,
+ enterHint: enterHint
});
console.log('[KanbanSearch] Init completed');
return;
diff --git a/app/Domain/Timesheets/Templates/showAll.tpl.php b/app/Domain/Timesheets/Templates/showAll.tpl.php
index d207681b34..61e62885c1 100644
--- a/app/Domain/Timesheets/Templates/showAll.tpl.php
+++ b/app/Domain/Timesheets/Templates/showAll.tpl.php
@@ -221,7 +221,7 @@ function restoreFilters() {
-
+
displayNotification() ?>