From 11ad6938aa5ae285f45b489a92700c7ed4eb6a2a Mon Sep 17 00:00:00 2001
From: farukvukovic <104840615+farukvukovic@users.noreply.github.com>
Date: Wed, 7 Jan 2026 16:25:08 +0100
Subject: [PATCH 01/28] #5427 Add visual indicator for pinned ticket
---
app/Domain/Tickets/Templates/showKanban.tpl.php | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/app/Domain/Tickets/Templates/showKanban.tpl.php b/app/Domain/Tickets/Templates/showKanban.tpl.php
index d543e274e2..56c4a98864 100644
--- a/app/Domain/Tickets/Templates/showKanban.tpl.php
+++ b/app/Domain/Tickets/Templates/showKanban.tpl.php
@@ -268,7 +268,12 @@ class="ticketBox moveable container priority-border-= $row['priority']?>"
__('label.'.strtolower($row['type'])); ?>
-
+
escapeMinimalRemoveImage($row['description']); ?>
From 98bc24f44892d09d272c102532cf2de532019735 Mon Sep 17 00:00:00 2001
From: farukvukovic <104840615+farukvukovic@users.noreply.github.com>
Date: Wed, 7 Jan 2026 15:10:10 +0100
Subject: [PATCH 02/28] #5343 disable profile date saving
---
.../js/app/timesheets/filterPreferences.js | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/public/assets/js/app/timesheets/filterPreferences.js b/public/assets/js/app/timesheets/filterPreferences.js
index 2f6a9f7587..2e87b66e46 100644
--- a/public/assets/js/app/timesheets/filterPreferences.js
+++ b/public/assets/js/app/timesheets/filterPreferences.js
@@ -119,12 +119,7 @@
jQuery('select[name="kind"]').val(filters.kind);
}
- if (filters.dateFrom) {
- jQuery('input[name="dateFrom"]').val(filters.dateFrom);
- }
- if (filters.dateTo) {
- jQuery('input[name="dateTo"]').val(filters.dateTo);
- }
+
jQuery('input[name="invEmpl"]').prop('checked', filters.invEmpl === '1');
jQuery('input[name="invComp"]').prop('checked', filters.invComp === '1');
jQuery('input[name="paid"]').prop('checked', filters.paid === '1');
@@ -192,8 +187,6 @@
clientId: jQuery('select[name="clientId"]').val() || '-1',
userId: jQuery('select[name="userId"]').val() || 'all',
kind: jQuery('select[name="kind"]').val() || 'all',
- dateFrom: jQuery('input[name="dateFrom"]').val() || '',
- dateTo: jQuery('input[name="dateTo"]').val() || '',
invEmpl: jQuery('input[name="invEmpl"]').is(':checked') ? '1' : '0',
invComp: jQuery('input[name="invComp"]').is(':checked') ? '1' : '0',
paid: jQuery('input[name="paid"]').is(':checked') ? '1' : '0'
@@ -285,7 +278,7 @@ function buildPreferencesDropdown() {
keys.forEach(function (key) {
const pref = currentPreferences[key];
const checked = pref.autoExport ? 'checked' : '';
-
+
html += `
${key}
@@ -304,7 +297,7 @@ function buildPreferencesDropdown() {
}
function initAutoExportListeners() {
- jQuery(document).on('change', '.auto-export', function() {
+ jQuery(document).on('change', '.auto-export', function() {
fetch(leantime.appUrl + '/timesheets/saveFilterPreferences', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -449,7 +442,7 @@ jQuery(document).ready(initAutoExportListeners);
e.stopPropagation();
const profileName = jQuery(this).data('name');
const isEnabled = jQuery(this).is(':checked');
-
+
saveAutoExportSetting(profileName, isEnabled);
});
@@ -470,7 +463,7 @@ jQuery(document).ready(initAutoExportListeners);
autoExport: enabled
})
});
-
+
if (response.ok) {
const data = await response.json();
if (data.status === 'success') {
From d67cfa492bc84da008c499475743fd548f677ee8 Mon Sep 17 00:00:00 2001
From: Haris Macic
Date: Tue, 6 Jan 2026 15:50:21 +0100
Subject: [PATCH 03/28] #5418 Add initial logic for navigating to monthly view
through button
---
.../Controllers/ShowMyMonthlyTimesheet.php | 170 ++++++++++++
.../Timesheets/Repositories/Timesheets.php | 62 +++++
app/Domain/Timesheets/Services/Timesheets.php | 4 +
.../Timesheets/Templates/showMy.tpl.php | 1 +
.../Timesheets/Templates/showMyList.tpl.php | 1 +
.../Templates/showMyMonthlyTimesheet.tpl.php | 245 ++++++++++++++++++
app/Language/en-US.ini | 1 +
7 files changed, 484 insertions(+)
create mode 100644 app/Domain/Timesheets/Controllers/ShowMyMonthlyTimesheet.php
create mode 100644 app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
diff --git a/app/Domain/Timesheets/Controllers/ShowMyMonthlyTimesheet.php b/app/Domain/Timesheets/Controllers/ShowMyMonthlyTimesheet.php
new file mode 100644
index 0000000000..44f7fda37f
--- /dev/null
+++ b/app/Domain/Timesheets/Controllers/ShowMyMonthlyTimesheet.php
@@ -0,0 +1,170 @@
+timesheetService = $timesheetService;
+ $this->timesheetRepo = $timesheetRepo;
+ $this->projects = $projects;
+ $this->tickets = $tickets;
+ $this->userRepo = $userRepo;
+ }
+
+ /**
+ * run - display template and edit data
+ *
+ *
+ * @throws \Illuminate\Contracts\Container\BindingResolutionException
+ */
+ public function run(): Response
+ {
+ Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager, Roles::$editor], true);
+
+ $fromDate = dtHelper()->userNow()->startOfMonth()->setToDbTimezone();
+
+ $kind = 'all';
+ if (isset($_POST['search'])) {
+ // User date comes is in user date format and user timezone. Change it to utc.
+ if (! empty($_POST['startDate'])) {
+ try {
+ $fromDate = dtHelper()->parseUserDateTime($_POST['startDate'])->setToDbTimezone();
+ } catch (\Exception $e) {
+ Log::warning($e);
+ Log::warning('User timezone: '.session('usersettings.timezone'));
+ Log::warning('User dateTime format: '.session('usersettings.date_format'));
+ $this->tpl->setNotification('Could not parse date', 'error', 'save_timesheet');
+ }
+ }
+ }
+
+ if (isset($_POST['saveTimeSheet'])) {
+ $this->saveTimeSheet($_POST);
+ }
+
+ $myTimesheets = $this->timesheetService->getWeeklyTimesheets(-1, $fromDate, session('userdata.id'));
+ $existingTicketIds = array_map(fn ($item) => $item['ticketId'], $myTimesheets);
+
+ $this->tpl->assign('existingTicketIds', $existingTicketIds);
+ $this->tpl->assign('dateFrom', $fromDate);
+ $this->tpl->assign('actKind', $kind);
+ $this->tpl->assign('kind', $this->timesheetRepo->kind);
+ $this->tpl->assign('allProjects', $this->projects->getUserProjects(
+ userId: session('userdata.id'),
+ projectTypes: 'project'
+ ));
+ $this->tpl->assign('allTickets', $this->tickets->getUsersTickets(
+ id: session('userdata.id'),
+ limit: -1
+ ));
+ $this->tpl->assign('allTimesheets', $myTimesheets);
+
+ return $this->tpl->display('timesheets.showMyMonthlyTimesheet');
+ }
+
+ /**
+ * @throws BindingResolutionException
+ */
+ public function saveTimeSheet(array $postData): void
+ {
+ foreach ($postData as $key => $dateEntry) {
+ // The temp data should contain four parts, spectated by "|":
+ // TICKET ID | Type of booked hours | Date | Timestamp
+ $tempData = explode('|', $key);
+
+ if (count($tempData) === 4) {
+ $ticketId = $tempData[0];
+ $kind = $tempData[1];
+ $date = $tempData[2];
+ $timestamp = $tempData[3];
+ $hours = $dateEntry;
+
+ // if ticket ID is set to new, pull id and hour type from form field
+ if ($ticketId === 'new' || $ticketId === 0) {
+ $ticketId = (int) $postData['ticketId'];
+ $kind = $postData['kindId'];
+
+ if ($ticketId == 0 && $hours > 0) {
+ $this->tpl->setNotification('Task ID is required for new entries', 'error', 'save_timesheet');
+
+ return;
+ }
+ }
+
+ // Parse hours using TimeParser for flexible input formats
+ $parsedHours = $hours;
+ if (!empty($hours) && !is_numeric($hours)) {
+ try {
+ $parser = app(\Leantime\Domain\Timesheets\Services\TimeParser::class);
+ $parsedHours = $parser->parseTimeToDecimal($hours);
+
+ // Additional validation: check for unreasonably large values
+ if ($parsedHours > 24 * 365) { // More than a year of work
+ throw new \InvalidArgumentException('Time value is unreasonably large. Please enter a valid amount of time.');
+ }
+ } catch (\InvalidArgumentException $e) {
+ $this->tpl->setNotification($e->getMessage(), 'error', 'time_parse_error');
+
+ // Skip saving this entry if parsing failed
+ continue;
+ }
+ }
+
+ $values = [
+ 'userId' => session('userdata.id'),
+ 'ticket' => $ticketId,
+ 'date' => $date,
+ 'timestamp' => $timestamp,
+ 'hours' => $parsedHours,
+ 'kind' => $kind,
+ ];
+
+ // This should not be the case since we set the input to disabled, but check anyways
+ if ($timestamp !== 'false' && $timestamp != false) {
+ try {
+ $this->timesheetService->upsertTime($ticketId, $values);
+ $this->tpl->setNotification('Timesheet saved successfully', 'success', 'save_timesheet');
+ } catch (\Exception $e) {
+ $this->tpl->setNotification('Error logging time: '.$e->getMessage(), 'error', 'save_timesheet');
+ report($e);
+
+ continue;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/Domain/Timesheets/Repositories/Timesheets.php b/app/Domain/Timesheets/Repositories/Timesheets.php
index d46465a9a2..001e01ef89 100644
--- a/app/Domain/Timesheets/Repositories/Timesheets.php
+++ b/app/Domain/Timesheets/Repositories/Timesheets.php
@@ -354,6 +354,68 @@ public function getWeeklyTimesheets(int $projectId, CarbonInterface $fromDate, i
return $call->fetchAll();
}
+ public function getMonthlyTimesheets(int $projectId, CarbonInterface $fromDate, int $userId = 0): mixed
+ {
+ $query = 'SELECT
+ zp_timesheets.id,
+ zp_timesheets.userId,
+ zp_timesheets.ticketId,
+ zp_timesheets.workDate as workDate,
+ zp_timesheets.hours,
+ zp_timesheets.description,
+ zp_timesheets.kind,
+ zp_timesheets.invoicedEmpl,
+ zp_timesheets.invoicedComp,
+ zp_timesheets.invoicedEmplDate,
+ zp_timesheets.invoicedCompDate,
+ zp_timesheets.paid,
+ zp_timesheets.paidDate,
+ zp_timesheets.kind,
+ zp_timesheets.modified,
+ zp_tickets.headline,
+ zp_tickets.planHours,
+ zp_projects.name,
+ zp_projects.id AS projectId,
+ zp_projects.clientId AS clientId,
+ zp_clients.name AS clientName
+ FROM
+ zp_timesheets
+ LEFT JOIN zp_tickets ON zp_tickets.id = zp_timesheets.ticketId
+ LEFT JOIN zp_projects ON zp_tickets.projectId = zp_projects.id
+ LEFT JOIN zp_clients ON zp_clients.id = zp_projects.clientId
+ WHERE
+ (zp_timesheets.workDate >= :dateStart1 AND zp_timesheets.workDate < :dateEnd)
+ AND (zp_timesheets.userId = :userId)
+ AND hours > 0
+ ';
+
+ if ($projectId > 0) {
+ $query .= ' AND zp_tickets.projectId = :projectId';
+ }
+
+ $query .= ' ORDER BY zp_timesheets.ticketId, zp_timesheets.kind, zp_timesheets.workDate DESC';
+
+ $call = $this->dbcall(func_get_args());
+
+ $call->prepare($query);
+
+ if (! $fromDate->isUtc()) {
+ $fromDate->setTimezone('UTC');
+ }
+
+ $call->bindValue(':dateStart1', $fromDate);
+
+ $endDate = $fromDate->addDays(7);
+ $call->bindValue(':dateEnd', $endDate);
+ $call->bindValue(':userId', $userId, PDO::PARAM_INT);
+
+ if ($projectId > 0) {
+ $call->bindValue(':projectId', $projectId, PDO::PARAM_INT);
+ }
+
+ return $call->fetchAll();
+ }
+
/**
* getUsersTicketHours - get the total hours
*
diff --git a/app/Domain/Timesheets/Services/Timesheets.php b/app/Domain/Timesheets/Services/Timesheets.php
index c4ac0fe2bf..3f106b283f 100644
--- a/app/Domain/Timesheets/Services/Timesheets.php
+++ b/app/Domain/Timesheets/Services/Timesheets.php
@@ -593,4 +593,8 @@ private function prepareDatesForApiResponse($timesheet)
return $timesheet;
}
+
+ public function getMonthlyTimesheets (CarbonInterface $dateFrom) {
+
+ }
}
diff --git a/app/Domain/Timesheets/Templates/showMy.tpl.php b/app/Domain/Timesheets/Templates/showMy.tpl.php
index e24df082d7..099dbcd26e 100644
--- a/app/Domain/Timesheets/Templates/showMy.tpl.php
+++ b/app/Domain/Timesheets/Templates/showMy.tpl.php
@@ -229,6 +229,7 @@
diff --git a/app/Domain/Timesheets/Templates/showMyList.tpl.php b/app/Domain/Timesheets/Templates/showMyList.tpl.php
index 2c3e340280..b23e019bce 100644
--- a/app/Domain/Timesheets/Templates/showMyList.tpl.php
+++ b/app/Domain/Timesheets/Templates/showMyList.tpl.php
@@ -75,6 +75,7 @@ class="dateTo"
diff --git a/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php b/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
new file mode 100644
index 0000000000..ecff019f29
--- /dev/null
+++ b/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
@@ -0,0 +1,245 @@
+ $val) {
+ $$var = $val; // necessary for blade refactor
+}
+$hoursFormat = session('usersettings.hours_format', 'decimal');
+?>
+
+
+
+
+
+
+
+
+ displayNotification();
+?>
+
+
+
+
+
+
diff --git a/app/Language/en-US.ini b/app/Language/en-US.ini
index 33a30093c1..eada441df2 100644
--- a/app/Language/en-US.ini
+++ b/app/Language/en-US.ini
@@ -466,6 +466,7 @@ links.create_first_sprint = "
Create your firs
links.kanban = "
Kanban"
links.list = "
List"
links.week_view = "
Week View"
+links.monthly_view = "
Monthly View"
links.list_view = "
List View"
links.backlog = "Backlog"
links.table = "
Table"
From 5980f8edf94d9f498c68f6df4e20da5f3dc52905 Mon Sep 17 00:00:00 2001
From: Haris Macic
Date: Tue, 6 Jan 2026 16:01:13 +0100
Subject: [PATCH 04/28] #5418 Add getMonthlyTimesheet method to timesheet
repository
---
.../Timesheets/Repositories/Timesheets.php | 17 ++++++-----------
app/Domain/Timesheets/Services/Timesheets.php | 1 -
2 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/app/Domain/Timesheets/Repositories/Timesheets.php b/app/Domain/Timesheets/Repositories/Timesheets.php
index 001e01ef89..c5a52e176a 100644
--- a/app/Domain/Timesheets/Repositories/Timesheets.php
+++ b/app/Domain/Timesheets/Repositories/Timesheets.php
@@ -354,8 +354,11 @@ public function getWeeklyTimesheets(int $projectId, CarbonInterface $fromDate, i
return $call->fetchAll();
}
- public function getMonthlyTimesheets(int $projectId, CarbonInterface $fromDate, int $userId = 0): mixed
+ public function getMonthlyTimesheets(int $projectId, int $userId = 0): mixed
{
+ $fromDate = dtHelper()->userNow()->startOfMonth()->setToDbTimezone();
+ $endDate = dtHelper()->userNow()->endOfMonth()->addDay()->setToDbTimezone();
+
$query = 'SELECT
zp_timesheets.id,
zp_timesheets.userId,
@@ -370,7 +373,6 @@ public function getMonthlyTimesheets(int $projectId, CarbonInterface $fromDate,
zp_timesheets.invoicedCompDate,
zp_timesheets.paid,
zp_timesheets.paidDate,
- zp_timesheets.kind,
zp_timesheets.modified,
zp_tickets.headline,
zp_tickets.planHours,
@@ -384,7 +386,7 @@ public function getMonthlyTimesheets(int $projectId, CarbonInterface $fromDate,
LEFT JOIN zp_projects ON zp_tickets.projectId = zp_projects.id
LEFT JOIN zp_clients ON zp_clients.id = zp_projects.clientId
WHERE
- (zp_timesheets.workDate >= :dateStart1 AND zp_timesheets.workDate < :dateEnd)
+ (zp_timesheets.workDate >= :dateStart AND zp_timesheets.workDate < :dateEnd)
AND (zp_timesheets.userId = :userId)
AND hours > 0
';
@@ -396,16 +398,9 @@ public function getMonthlyTimesheets(int $projectId, CarbonInterface $fromDate,
$query .= ' ORDER BY zp_timesheets.ticketId, zp_timesheets.kind, zp_timesheets.workDate DESC';
$call = $this->dbcall(func_get_args());
-
$call->prepare($query);
- if (! $fromDate->isUtc()) {
- $fromDate->setTimezone('UTC');
- }
-
- $call->bindValue(':dateStart1', $fromDate);
-
- $endDate = $fromDate->addDays(7);
+ $call->bindValue(':dateStart', $fromDate);
$call->bindValue(':dateEnd', $endDate);
$call->bindValue(':userId', $userId, PDO::PARAM_INT);
diff --git a/app/Domain/Timesheets/Services/Timesheets.php b/app/Domain/Timesheets/Services/Timesheets.php
index 3f106b283f..1e2bac825d 100644
--- a/app/Domain/Timesheets/Services/Timesheets.php
+++ b/app/Domain/Timesheets/Services/Timesheets.php
@@ -372,7 +372,6 @@ public function getWeeklyTimesheets(int $projectId, CarbonInterface $fromDate, i
fromDate: $fromDate,
userId: $userId
);
-
// Timesheets are grouped by ticketId + type
$timesheetGroups = [];
foreach ($allTimesheets as $timesheet) {
From 64363509e14a00b7b5a30857f623491389d4fb69 Mon Sep 17 00:00:00 2001
From: Haris Macic
Date: Wed, 7 Jan 2026 09:44:58 +0100
Subject: [PATCH 05/28] #5418 Change jquery ids
---
.../Templates/showMyMonthlyTimesheet.tpl.php | 639 ++++++++++++------
1 file changed, 429 insertions(+), 210 deletions(-)
diff --git a/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php b/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
index ecff019f29..53fd2dcd0b 100644
--- a/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
+++ b/app/Domain/Timesheets/Templates/showMyMonthlyTimesheet.tpl.php
@@ -1,245 +1,464 @@
$val) {
$$var = $val; // necessary for blade refactor
}
+
+/** @var \Carbon\Carbon $currentDate */
+$dateFrom = $tpl->get('dateFrom');
$hoursFormat = session('usersettings.hours_format', 'decimal');
+
?>
+
-
displayNotification();
-?>
-