Skip to content
This repository was archived by the owner on Oct 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
11ad693
#5427 Add visual indicator for pinned ticket
farukvukovic Jan 7, 2026
98bc24f
#5343 disable profile date saving
farukvukovic Jan 7, 2026
d67cfa4
#5418 Add initial logic for navigating to monthly view through button
harismacic1 Jan 6, 2026
5980f8e
#5418 Add getMonthlyTimesheet method to timesheet repository
harismacic1 Jan 6, 2026
6436350
#5418 Change jquery ids
harismacic1 Jan 7, 2026
09c8172
#5418 Fix logic for dynamically changing months so user can navigate …
harismacic1 Jan 7, 2026
dce0a3c
#5418 Add displaying of all days in the month
harismacic1 Jan 7, 2026
9b06548
#5418 Add automatic resizing to the table cols
harismacic1 Jan 7, 2026
6d37231
#5418 Position info icon to be in the same row as time log
harismacic1 Jan 7, 2026
7373e84
#5418 Make option to save in monthly view after changing time log
harismacic1 Jan 7, 2026
f11e991
#5418 Hide css classes that caused UI bug, add autosave on month choose
harismacic1 Jan 7, 2026
87af409
#5418 Extract business logic from controller to a service class
harismacic1 Jan 7, 2026
9db0c29
Add icon to monthly view and remove flag active from it while user is…
harismacic1 Jan 7, 2026
a3550c8
#5418 Adjust layout of month and year calendar
harismacic1 Jan 7, 2026
6873640
#5418 Remove unused code
harismacic1 Jan 7, 2026
5329a97
[#5418] Add line break
harismacic1 Jan 7, 2026
00f1856
#5418 Write full dates instead of just days in the table head
harismacic1 Jan 8, 2026
d33632a
#5418 Change label before calendar in monthly view
harismacic1 Jan 8, 2026
f4a0c88
#5393 Move comments above ticket history
farukvukovic Jan 8, 2026
6954295
#5438 Fix timesheet profiles display issues
farukvukovic Jan 8, 2026
b197db7
#5436 Fix date fields display after search in all timesheets
farukvukovic Jan 9, 2026
d75ca25
#5476 Add logic for retrieving filters from current date picker's state
harismacic1 Jan 12, 2026
a3fb81d
#5471 Add total hours to csv that gets exported to slack
harismacic1 Jan 12, 2026
b0ab536
#5471 Add label Total to row where total hours are located
harismacic1 Jan 12, 2026
ed2244e
#5469 Add notifications for unsuccessful and successful export to Slack
harismacic1 Jan 9, 2026
d3904cb
#5469 Remove empty lines
harismacic1 Jan 9, 2026
2eaf306
#5489 Delete label Task from tickets on Kanban board
farukvukovic Jan 12, 2026
d36a7af
#5461 Add ticket ID to My timesheets
farukvukovic Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/Domain/Tickets/Templates/showKanban.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,15 @@ class="ticketBox moveable container priority-border-<?= $row['priority']?>"
<?php if ($row['dependingTicketId'] > 0) { ?>
<small><a href="#/tickets/showTicket/<?= $row['dependingTicketId'] ?>" class="form-modal"><?= $tpl->escape($row['parentHeadline']) ?></a></small> //
<?php } ?>
<small><i class="fa <?php echo $todoTypeIcons[strtolower($row['type'])]; ?>"></i> <?php echo $tpl->__('label.'.strtolower($row['type'])); ?></small>
<small><i class="fa <?php echo $todoTypeIcons[strtolower($row['type'])]; ?>"></i></small>
<small><?php echo (!empty($row['projectKey']) ? $row['projectKey'] . '-' : '#') . $row['id']; ?></small>
<div class="kanbanCardContent">
<h4><a href="#/tickets/showTicket/<?php echo $row['id']; ?>" data-hx-get="<?= BASE_URL?>/tickets/showTicket/<?php echo $row['id']; ?>" hx-swap="none" preload="mouseover"><?php $tpl->e($row['headline']); ?></a></h4>
<h4>
<?php if (isset($row['pinned']) && $row['pinned']) { ?>
<i class="fa fa-thumbtack" style="color: #dc3545; margin-right: 5px; transform: rotate(45deg);" data-tippy-content="Pinned to top"></i>
<?php } ?>
<a href="#/tickets/showTicket/<?php echo $row['id']; ?>" data-hx-get="<?= BASE_URL?>/tickets/showTicket/<?php echo $row['id']; ?>" hx-swap="none" preload="mouseover"><?php $tpl->e($row['headline']); ?></a>
</h4>

<div class="kanbanContent" style="margin-bottom: 20px">
<?php echo $tpl->escapeMinimalRemoveImage($row['description']); ?>
Expand Down
29 changes: 16 additions & 13 deletions app/Domain/Tickets/Templates/submodules/ticketDetails.sub.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ class="complexEditor"><?php echo $ticket->description !== null ? htmlentities($t
</div>
</div>

<?php if ($ticket->id) {?>
<h4 class="widgettitle title-light"><span
class="fa-solid fa-comments"></span><?php echo $tpl->__('subtitles.discussion'); ?></h4>

<div class="row-fluid">
<form method="post" action="<?= BASE_URL ?>/tickets/showTicket/<?php echo $ticket->id; ?>" class="formModal">
<input type="hidden" name="comment" value="1" />
<?php
$tpl->assign('formUrl', ''.BASE_URL.'/tickets/showTicket/'.$ticket->id.'');

$tpl->displaySubmodule('comments-generalComment');
?>
</form>
</div>
<?php } ?>

<div class="status-change-log" id="status-change-log" data-ticket-id="<?= $ticket->id ?>"></div>

<script>
Expand Down Expand Up @@ -308,19 +324,6 @@ function loadStatusHistory(ticketId) {
Loading Subtasks ...<br /><br />
</div>

<h4 class="widgettitle title-light"><span
class="fa-solid fa-comments"></span><?php echo $tpl->__('subtitles.discussion'); ?></h4>

<div class="row-fluid">
<form method="post" action="<?= BASE_URL ?>/tickets/showTicket/<?php echo $ticket->id; ?>" class="formModal">
<input type="hidden" name="comment" value="1" />
<?php
$tpl->assign('formUrl', ''.BASE_URL.'/tickets/showTicket/'.$ticket->id.'');

$tpl->displaySubmodule('comments-generalComment');
?>
</form>
</div>
<?php } ?>
</div>
<div class="col-md-3">
Expand Down
82 changes: 82 additions & 0 deletions app/Domain/Timesheets/Controllers/ShowMyMonthlyTimesheet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Leantime\Domain\Timesheets\Controllers;

use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Symfony\Component\HttpFoundation\Response;
use Leantime\Domain\Timesheets\Services\ShowMyMonthlyTimesheetService as MonthlyTimesheetService;

class ShowMyMonthlyTimesheet extends Controller
{
private TimesheetService $timesheetService;

private TimesheetRepository $timesheetRepo;

private ProjectRepository $projects;

private TicketRepository $tickets;

private MonthlyTimesheetService $monthlyTimesheetService;

public function init(
TimesheetService $timesheetService,
TimesheetRepository $timesheetRepo,
ProjectRepository $projects,
TicketRepository $tickets,
MonthlyTimesheetService $monthlyTimesheetService
): void {
$this->timesheetService = $timesheetService;
$this->timesheetRepo = $timesheetRepo;
$this->projects = $projects;
$this->tickets = $tickets;
$this->monthlyTimesheetService = $monthlyTimesheetService;
}

public function run(): Response
{
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager, Roles::$editor], true);

$fromDate = dtHelper()->userNow()->startOfMonth();

$kind = 'all';
if (isset($_POST['search'])) {
if (! empty($_POST['startDate'])) {
try {
$fromDate = dtHelper()->parseUserDateTime($_POST['startDate'])->startOfMonth();
} catch (\Exception $e) {
$this->tpl->setNotification('Could not parse date', 'error', 'save_timesheet');
}
}
}

if (isset($_POST['saveTimeSheet'])) {
$this->monthlyTimesheetService->saveTimeSheet($_POST);
}

$fromDateDb = $fromDate->copy()->setToDbTimezone();
$myTimesheets = $this->timesheetService->getWeeklyTimesheets(-1, $fromDateDb, 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');
}
}
12 changes: 0 additions & 12 deletions app/Domain/Timesheets/Controllers/SlackMonthlyReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,4 @@ public function sendCsvFromUsersProfilesWhichHaveTickboxTrue(): Response
}
return Frontcontroller::redirect(BASE_URL.'/timesheets/showAll');
}

public function sendCsvFromAllProfiles(): Response
{
if(Auth::userIsAtLeast(Roles::$admin)) {
$allProfiles = $this->slackReportService->getAllProfiles();

$this->slackReportService->sendAutomaticMonthlyReportToSlack($allProfiles);

return Frontcontroller::redirect(BASE_URL.'/timesheets/showAll');
}
return Frontcontroller::redirect(BASE_URL.'/timesheets/showAll');
}
}
82 changes: 82 additions & 0 deletions app/Domain/Timesheets/Services/ShowMyMonthlyTimesheetService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Leantime\Domain\Timesheets\Services;

use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Leantime\Core\UI\Template;


class ShowMyMonthlyTimesheetService
{
private TimesheetService $timesheetService;
private Template $tpl;

public function __construct(TimesheetService $timesheetService, Template $tpl)
{
$this->timesheetService = $timesheetService;
$this->tpl = $tpl;
}

public function saveTimeSheet(array $postData): void
{
foreach ($postData as $key => $dateEntry) {

$tempData = explode('|', $key);

if (count($tempData) === 4) {
$ticketId = $tempData[0];
$kind = $tempData[1];
$date = $tempData[2];
$timestamp = $tempData[3];
$hours = $dateEntry;

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;
}
}

$parsedHours = $hours;
if (!empty($hours) && !is_numeric($hours)) {
try {
$parser = app(\Leantime\Domain\Timesheets\Services\TimeParser::class);
$parsedHours = $parser->parseTimeToDecimal($hours);

if ($parsedHours > 24 * 365) {
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');

continue;
}
}

$values = [
'userId' => session('userdata.id'),
'ticket' => $ticketId,
'date' => $date,
'timestamp' => $timestamp,
'hours' => $parsedHours,
'kind' => $kind,
];

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;
}
}
}
}
}
}
51 changes: 48 additions & 3 deletions app/Domain/Timesheets/Services/SlackMonthlyReportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Leantime\Domain\Users\Services\Users as UserService;
use Illuminate\Support\Facades\Cache;
use Leantime\Core\UI\Template;

class SlackMonthlyReportService {
private string $webhookUrl;
Expand All @@ -18,20 +18,23 @@ class SlackMonthlyReportService {
private SettingRepository $settingRepository;
private UserService $userService;
private \GuzzleHttp\Client $httpClient;
private Template $tpl;

public function __construct(
TimesheetService $timesheetsService,
TicketService $ticketService,
SettingRepository $settingRepository,
\GuzzleHttp\Client $httpClient,
UserService $userService
UserService $userService,
Template $tpl
) {
$this->webhookUrl = env('SLACK_WEBHOOK_URL', '');
$this->timesheetsService = $timesheetsService;
$this->ticketService = $ticketService;
$this->settingRepository = $settingRepository;
$this->httpClient = $httpClient;
$this->userService = $userService;
$this->tpl = $tpl;
}

private function getFiltersFromRequest(): array
Expand Down Expand Up @@ -184,6 +187,14 @@ public function getAllProfiles(): array {

public function sendMonthlyReportToSlack($profilesWithEnabledAutoExport): void
{
if (empty($profilesWithEnabledAutoExport)) {
$this->tpl->setNotification(
'No profiles with checkbox Slack ticked!',
'error',
'save_timesheet'
);
return;
}
foreach ($profilesWithEnabledAutoExport as $profileName => $profile) {
$profileFilters = $profile['filters'] ?? [];
$userId = $profileFilters['userId'] ?? null;
Expand Down Expand Up @@ -212,6 +223,11 @@ public function sendMonthlyReportToSlack($profilesWithEnabledAutoExport): void

$this->sendCsvToSlack($csvContent, $reportName);
}
$this->tpl->setNotification(
'Monthly reports successfully sent to Slack.',
'success',
'save_timesheet'
);
}

private function sendCsvToSlack(string $csvContent, string $profileName): bool
Expand Down Expand Up @@ -349,10 +365,11 @@ private function generateCsvString(array $filters, array $columnState = []): str
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));

fputcsv($output, array_values($headers));
$totalHours = 0;

foreach ($allTimesheets as $row) {
$diff = $row['planHours'] - $row['hours'];

$totalHours += (float) $row['hours'];
$rowData = [
'id' => $row['id'],
'tickId' => $row['ticketId'],
Expand All @@ -376,6 +393,34 @@ private function generateCsvString(array $filters, array $columnState = []): str
$filteredRow = array_intersect_key($rowData, $activeColumns);
fputcsv($output, array_values($filteredRow));
}
$totalsRowData = [
'id' => '',
'tickId' => '',
'date' => '',
'ticket' => '',
'hours' => format_hours($totalHours, $hoursFormat),
'planHours' => '',
'difference' => '',
'project' => '',
'client' => '',
'employee' => '',
'type' => '',
'milestone' => '',
'tags' => '',
'description' => '',
'invoiced' => '',
'mgrApproval' => '',
'paid' => ''
];

foreach($totalsRowData as $key => $value) {
if (isset($activeColumns[$key]) && isset($activeColumns['hours'])) {
$totalsRowData[$key] = 'TOTAL';
break;
}
}
$filteredTotalsRow = array_intersect_key($totalsRowData, $activeColumns);
fputcsv($output, array_values($filteredTotalsRow));

rewind($output);
$csvContent = stream_get_contents($output);
Expand Down
2 changes: 2 additions & 0 deletions app/Domain/Timesheets/Services/Timesheets.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ public function getWeeklyTimesheets(int $projectId, CarbonInterface $fromDate, i
'name' => $timesheet['name'],
'headline' => $timesheet['headline'],
'ticketId' => $timesheet['ticketId'],
'projectId' => $timesheet['projectId'],
'projectKey' => $timesheet['projectKey'],
'hasTimesheetOffset' => $workdateOffsetStart !== 0,
'day1' => [
'start' => $fromDate,
Expand Down
16 changes: 6 additions & 10 deletions app/Domain/Timesheets/Templates/showAll.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,12 @@ function updateProjectCountInline() {
<div class="maincontent">
<div class="maincontentinner">
<div style="float:left;">
<button class="dt-button" onclick="exportToSlack()" style = "padding: 4px 14px;">Export to Slack</button>
</div>
</form>
<script>
function exportToSlack() {
fetch('<?= BASE_URL?>/timesheets/slackMonthlyReportController/sendCsvFromUsersProfilesWhichHaveTickboxTrue', {
method: 'POST'
});
}
</script>
<?php echo $tpl->displayNotification() ?>
<form id="exportSlackForm" action="<?= BASE_URL ?>/timesheets/slackMonthlyReportController/sendCsvFromUsersProfilesWhichHaveTickboxTrue" method="post" style="display:none;"></form>
<button class="dt-button" onclick="document.getElementById('exportSlackForm').submit();" style="padding: 4px 14px;">
Export to Slack
</button>
</div>
<form action="<?php echo BASE_URL ?>/timesheets/showAll" method="post" id="form" name="form">
<div class="pull-right">
<div id="tableButtons" style="display:inline-block; vertical-align: middle;"></div>
Expand Down
Loading
Loading