diff --git a/.editorconfig b/.editorconfig index ef0ecb66bc..09f3fdc0cf 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,7 +3,7 @@ charset = utf-8 end_of_line = lf insert_final_newline = true -trim_trailing_whitespace = true +trim_trailing_whitespace = false indent_style = space # 4 space indenting diff --git a/.gitignore b/.gitignore index a9fc388eac..7aba974d53 100644 --- a/.gitignore +++ b/.gitignore @@ -110,7 +110,6 @@ logs/* app/custom/domain/ app/custom/plugin/ app/custom/language/ -public/theme/*/css/custom.css public/theme/dts/ nbproject config/config.yaml diff --git a/app/Domain/Menu/Templates/headMenu.blade.php b/app/Domain/Menu/Templates/headMenu.blade.php index 266940181d..3c49b6e8a5 100644 --- a/app/Domain/Menu/Templates/headMenu.blade.php +++ b/app/Domain/Menu/Templates/headMenu.blade.php @@ -72,6 +72,12 @@ class="dropdown-toggle profileHandler notificationHandler"
+
+ + {{ __('text.clear_all') ?? 'Clear all' }} + +
+
+
+ +
+ + + diff --git a/app/Domain/Timesheets/Controllers/ShowAll.php b/app/Domain/Timesheets/Controllers/ShowAll.php index 852b441cdf..f7cfbf6ce7 100644 --- a/app/Domain/Timesheets/Controllers/ShowAll.php +++ b/app/Domain/Timesheets/Controllers/ShowAll.php @@ -164,6 +164,26 @@ public function run(): Response } } } +$milestoneFilter = -1; +if (! empty($_POST['milestoneId'])) { + $milestoneFilter = (int) strip_tags($_POST['milestoneId']); +} + +$milestonesProject = is_array($projectFilter) ? -1 : (int) $projectFilter; +if ($milestonesProject > 0) { + $allMilestones = $this->ticketService->getAllMilestones([ + 'currentProject' => $milestonesProject + ]); +} else { + $allMilestones = $this->ticketService->getAll([ + 'type' => 'milestone', + 'currentProject' => '', + 'excludeType' => '', + 'currentUser' => '' + ]); +} +$this->tpl->assign('milestoneFilter', $milestoneFilter); +$this->tpl->assign('allMilestones', $allMilestones); $user = app()->make(UserRepository::class); $employees = $user->getAll(); @@ -208,7 +228,8 @@ public function run(): Response $invCompCheck, $ticketParameter, $paidCheck, - $clientId + $clientId, + $milestoneFilter )); // Pass user's hours format preference to template for CSV export diff --git a/app/Domain/Timesheets/Controllers/SlackMonthlyReportController.php b/app/Domain/Timesheets/Controllers/SlackMonthlyReportController.php index 59c1ff88b3..5d52a258da 100644 --- a/app/Domain/Timesheets/Controllers/SlackMonthlyReportController.php +++ b/app/Domain/Timesheets/Controllers/SlackMonthlyReportController.php @@ -28,11 +28,12 @@ public function sendCsvFromUsersProfilesWhichHaveTickboxTrue(): Response if(Auth::userIsAtLeast(Roles::$admin)) { $userId = session('userdata.id'); $profilesWithEnabledAutoExport = $this->slackReportService->getUsersProfilesWithEnabledAutoExport($userId); + $activeProfileName = isset($_POST['timesheet_export_profile']) ? trim((string) $_POST['timesheet_export_profile']) : ''; - $this->slackReportService->sendMonthlyReportToSlack($profilesWithEnabledAutoExport); + $this->slackReportService->sendMonthlyReportToSlack($profilesWithEnabledAutoExport, $activeProfileName); return Frontcontroller::redirect(BASE_URL.'/timesheets/showAll'); } return Frontcontroller::redirect(BASE_URL.'/timesheets/showAll'); } -} \ No newline at end of file +} diff --git a/app/Domain/Timesheets/Repositories/Timesheets.php b/app/Domain/Timesheets/Repositories/Timesheets.php index 77719bc168..708fc00180 100644 --- a/app/Domain/Timesheets/Repositories/Timesheets.php +++ b/app/Domain/Timesheets/Repositories/Timesheets.php @@ -37,7 +37,7 @@ public function __construct(DbCore $db) * * @return array|false An array of timesheets or false if there was an error */ - public function getAll(int|array|null $id, ?string $kind, ?CarbonInterface $dateFrom, ?CarbonInterface $dateTo, ?int $userId, ?string $invEmpl, ?string $invComp, ?string $paid, ?int $clientId, ?int $ticketFilter): array|false + public function getAll(int|array|null $id, ?string $kind, ?CarbonInterface $dateFrom, ?CarbonInterface $dateTo, ?int $userId, ?string $invEmpl, ?string $invComp, ?string $paid, ?int $clientId, ?int $ticketFilter, int $milestoneId = -1): array|false { $sanitizedProjectIds = []; $singleProjectId = null; @@ -126,6 +126,9 @@ public function getAll(int|array|null $id, ?string $kind, ?CarbonInterface $date if ($paid == '1') { $query .= ' AND (zp_timesheets.paid = 1)'; } + if ($milestoneId > 0) { + $query .= ' AND (zp_tickets.milestoneid = :milestoneId)'; +} $query .= ' GROUP BY zp_timesheets.id, @@ -166,6 +169,9 @@ public function getAll(int|array|null $id, ?string $kind, ?CarbonInterface $date if ($userId != 'all' && $userId != null) { $call->bindValue(':userId', $userId); } + if ($milestoneId > 0) { + $call->bindValue(':milestoneId', $milestoneId, PDO::PARAM_INT); + } return $call->fetchAll(); } diff --git a/app/Domain/Timesheets/Services/SlackMonthlyReportService.php b/app/Domain/Timesheets/Services/SlackMonthlyReportService.php index 8526fc6bf9..3b98431884 100644 --- a/app/Domain/Timesheets/Services/SlackMonthlyReportService.php +++ b/app/Domain/Timesheets/Services/SlackMonthlyReportService.php @@ -2,6 +2,7 @@ namespace Leantime\Domain\Timesheets\Services; +use Carbon\CarbonInterface; use Symfony\Component\HttpFoundation\Response; use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService; use Leantime\Domain\Tickets\Services\Tickets as TicketService; @@ -44,12 +45,12 @@ private function getFiltersFromRequest(): array $dateFrom = dtHelper()->userNow()->startOfMonth()->setToDbTimezone(); if (!empty($_POST['dateFrom'])) { - $dateFrom = dtHelper()->parseUserDateTime($_POST['dateFrom'])->setToDbTimezone(); + $dateFrom = dtHelper()->parseUserDateTime($_POST['dateFrom'])->startOfDay()->setToDbTimezone(); } $dateTo = dtHelper()->userNow()->endOfMonth()->setToDbTimezone(); - if (!empty($_POST['dateTo'])) { - $dateTo = dtHelper()->parseUserDateTime($_POST['dateTo'])->setToDbTimezone(); + if (! empty($_POST['dateTo'])) { + $dateTo = dtHelper()->parseUserDateTime($_POST['dateTo'])->endOfDay()->setToDbTimezone(); } $invEmplCheck = (isset($_POST['invEmpl']) && $_POST['invEmpl'] === '1') ? '1' : '-1'; @@ -100,6 +101,52 @@ private function getFiltersFromRequest(): array ]; } + /* Resolve dateFrom/dateTo at export time from a profile's saved filters (dateRange preset or dateFrom/dateTo) */ + private function resolveDateRangeFromProfile(array $profileFilters): array + { + $userNow = dtHelper()->userNow()->setToDbTimezone(); + $dateRange = $profileFilters['dateRange'] ?? 'Custom'; + + switch ($dateRange) { + case 'Today': + return [ + $userNow->copy()->startOfDay(), + $userNow->copy()->endOfDay(), + ]; + case 'This Week': + return [ + $userNow->copy()->startOfWeek(CarbonInterface::MONDAY), + $userNow->copy()->endOfWeek(CarbonInterface::SUNDAY), + ]; + case 'This Month': + return [ + $userNow->copy()->startOfMonth(), + $userNow->copy()->endOfMonth(), + ]; + case 'Last 7 Days': + return [ + $userNow->copy()->subDays(6)->startOfDay(), + $userNow->copy()->endOfDay(), + ]; + default: + break; + } + + $dateFrom = $profileFilters['dateFrom'] ?? null; + $dateTo = $profileFilters['dateTo'] ?? null; + if (! empty($dateFrom) && ! empty($dateTo)) { + return [ + dtHelper()->parseUserDateTime($dateFrom)->startOfDay()->setToDbTimezone(), + dtHelper()->parseUserDateTime($dateTo)->endOfDay()->setToDbTimezone(), + ]; + } + + return [ + $userNow->copy()->startOfMonth(), + $userNow->copy()->endOfMonth(), + ]; + } + public function getUsersProfilesWithEnabledAutoExport(int $userId): array { $settingKey = "user.{$userId}.timesheetFilters"; @@ -191,7 +238,7 @@ public function getProjectSlackChannelId(int $projectId): string } //Send monthly report to slack - public function sendMonthlyReportToSlack($profilesWithEnabledAutoExport): void { + public function sendMonthlyReportToSlack(array $profilesWithEnabledAutoExport, string $activeProfileName = ''): void { if (empty($profilesWithEnabledAutoExport)) { $this->tpl->setNotification( 'No profiles with checkbox Slack ticked!', @@ -205,6 +252,19 @@ public function sendMonthlyReportToSlack($profilesWithEnabledAutoExport): void { $failCount = 0; $debugInfo = []; + $useCurrentFormFilters = ! empty($_POST['dateFrom']) || ! empty($_POST['filterSubmit']); + $activeProfileName = trim($activeProfileName); + + if ($useCurrentFormFilters && $activeProfileName !== '') { + if (isset($profilesWithEnabledAutoExport[$activeProfileName])) { + $profilesWithEnabledAutoExport = [$activeProfileName => $profilesWithEnabledAutoExport[$activeProfileName]]; + } + } + + if ($useCurrentFormFilters) { + $currentFilters = $this->getFiltersFromRequest(); + } + foreach ($profilesWithEnabledAutoExport as $profileName => $profile) { // Get the project's Slack channel ID $slackProjectId = $profile['slackProjectId'] ?? null; @@ -223,35 +283,56 @@ public function sendMonthlyReportToSlack($profilesWithEnabledAutoExport): void { continue; // Skip if project has no Slack channel configured } - $profileFilters = $profile['filters'] ?? []; - $userId = $profileFilters['userId'] ?? null; - if ($userId === 'all' || $userId === '') { - $userId = null; - } elseif ($userId !== null) { - $userId = (int)$userId; + if ($useCurrentFormFilters) { + $projectFilter = $currentFilters['projectFilter']; + $profileProjects = $profile['filters']['projects'] ?? null; + if (is_array($profileProjects) && ! empty($profileProjects) && ! (count($profileProjects) === 1 && in_array('-1', $profileProjects, true))) { + $projectFilter = array_map('intval', array_values(array_filter($profileProjects, static fn ($v) => $v !== 0))); + if (empty($projectFilter) || in_array(-1, $projectFilter, true)) { + $projectFilter = -1; + } + } + [$dateFrom, $dateTo] = $this->resolveDateRangeFromProfile($profile['filters'] ?? []); + $filters = [ + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'projectFilter' => $projectFilter, + 'kind' => $currentFilters['kind'], + 'userId' => $currentFilters['userId'], + 'invEmplCheck' => $currentFilters['invEmplCheck'], + 'invCompCheck' => $currentFilters['invCompCheck'], + 'ticketParameter' => $currentFilters['ticketParameter'], + 'paidCheck' => $currentFilters['paidCheck'], + 'clientId' => $currentFilters['clientId'], + ]; + $reportColumnState = $profile['filters']['columnState'] ?? []; + } else { + $profileFilters = $profile['filters'] ?? []; + $userId = $profileFilters['userId'] ?? null; + if ($userId === 'all' || $userId === '') { + $userId = null; + } elseif ($userId !== null) { + $userId = (int) $userId; + } + $currentMonthStart = dtHelper()->userNow()->startOfMonth()->setToDbTimezone(); + $currentMonthEnd = dtHelper()->userNow()->endOfMonth()->setToDbTimezone(); + $filters = [ + 'dateFrom' => $currentMonthStart, + 'dateTo' => $currentMonthEnd, + 'projectFilter' => $profileFilters['projects'] ?? -1, + 'kind' => $profileFilters['kind'] ?? 'all', + 'userId' => $userId, + 'invEmplCheck' => $profileFilters['invEmpl'] ?? '-1', + 'invCompCheck' => $profileFilters['invComp'] ?? '0', + 'ticketParameter' => $profileFilters['ticketParameter'] ?? '-1', + 'paidCheck' => $profileFilters['paid'] ?? '0', + 'clientId' => $profileFilters['clientId'] ?? -1, + ]; + $reportColumnState = $profileFilters['columnState'] ?? []; } - // Always use current month instead of saved dates - $currentMonthStart = dtHelper()->userNow()->startOfMonth()->setToDbTimezone(); - $currentMonthEnd = dtHelper()->userNow()->endOfMonth()->setToDbTimezone(); - - $filters = [ - 'dateFrom' => $currentMonthStart, - 'dateTo' => $currentMonthEnd, - 'projectFilter' => $profileFilters['projects'] ?? -1, - 'kind' => $profileFilters['kind'] ?? 'all', - 'userId' => $userId, - 'invEmplCheck' => $profileFilters['invEmpl'] ?? '-1', - 'invCompCheck' => $profileFilters['invComp'] ?? '0', - 'ticketParameter' => $profileFilters['ticketParameter'] ?? '-1', - 'paidCheck' => $profileFilters['paid'] ?? '0', - 'clientId' => $profileFilters['clientId'] ?? -1, - ]; - - $columnState = $profileFilters['columnState'] ?? []; - $reportName = $profile['name'] ?? " "; - - $csvContent = $this->generateCsvString($filters, $columnState); + $reportName = $profile['name'] ?? ' '; + $csvContent = $this->generateCsvString($filters, $reportColumnState); if ($this->sendCsvToSlack($csvContent, $reportName, $channelId)) { $successCount++; @@ -299,6 +380,7 @@ private function sendCsvToSlack(string $csvContent, string $profileName, string $filename = "timesheet_{$profileName}_" . date('Y-m-d') . ".csv"; $getUploadUrlResponse = $this->httpClient->post('https://slack.com/api/files.getUploadURLExternal', [ + 'http_errors' => false, 'headers' => [ 'Authorization' => "Bearer {$slackBotToken}", 'Content-Type' => 'application/x-www-form-urlencoded', @@ -309,6 +391,15 @@ private function sendCsvToSlack(string $csvContent, string $profileName, string ] ]); + if ($getUploadUrlResponse->getStatusCode() === 429) { + $this->tpl->setNotification( + 'Slack upload rate exceeded. Please try again later.', + 'error', + 'save_timesheet' + ); + return false; + } + $uploadUrlData = json_decode($getUploadUrlResponse->getBody()->getContents(), true); if (!isset($uploadUrlData['ok']) || !$uploadUrlData['ok']) { @@ -330,6 +421,7 @@ private function sendCsvToSlack(string $csvContent, string $profileName, string } $completeResponse = $this->httpClient->post('https://slack.com/api/files.completeUploadExternal', [ + 'http_errors' => false, 'headers' => [ 'Authorization' => "Bearer {$slackBotToken}", 'Content-Type' => 'application/json', @@ -345,6 +437,15 @@ private function sendCsvToSlack(string $csvContent, string $profileName, string 'initial_comment' => "Monthly Timesheet Report: {$profileName}" ] ]); + + if ($completeResponse->getStatusCode() === 429) { + $this->tpl->setNotification( + 'Slack upload rate exceeded. Please try again later.', + 'error', + 'save_timesheet' + ); + return false; + } $completeData = json_decode($completeResponse->getBody()->getContents(), true); @@ -378,13 +479,7 @@ private function generateCsvString(array $filters, array $columnState = []): str return strtotime($a['workDate']) <=> strtotime($b['workDate']); }); - $userId = $filters['userId'] ?? session('userdata.id'); - $hoursFormat = 'decimal'; - if ($userId) { - $settingsService = app()->make(\Leantime\Domain\Setting\Services\Setting::class); - $hoursFormat = $settingsService->getSetting('usersettings.'.$userId.'.hours_format', 'decimal'); - } - + $hourTypes = $this->timesheetsService->getBookedHourTypes(); $allColumns = [ @@ -429,9 +524,9 @@ private function generateCsvString(array $filters, array $columnState = []): str 'tickId' => $row['ticketId'], 'date' => format($row['workDate'])->date(), 'ticket' => $row['headline'] ?? '', - 'hours' => format_hours($row['hours'], $hoursFormat), - 'planHours' => format_hours($row['planHours'], $hoursFormat), - 'difference' => format_hours($diff, $hoursFormat), + 'hours' => format_hours($row['hours']), + 'planHours' => format_hours($row['planHours']), + 'difference' => format_hours($diff), 'project' => $row['name'] ?? '', 'client' => $row['clientName'] ?? '', 'employee' => ($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''), @@ -453,7 +548,7 @@ private function generateCsvString(array $filters, array $columnState = []): str 'tickId' => '', 'date' => '', 'ticket' => '', - 'hours' => format_hours($totalHours, $hoursFormat), + 'hours' => format_hours($totalHours), 'planHours' => '', 'difference' => '', 'project' => '', diff --git a/app/Domain/Timesheets/Services/Timesheets.php b/app/Domain/Timesheets/Services/Timesheets.php index 36fd9196ce..7801650a74 100644 --- a/app/Domain/Timesheets/Services/Timesheets.php +++ b/app/Domain/Timesheets/Services/Timesheets.php @@ -343,7 +343,7 @@ public function getLoggableHourTypes(): array /** * @api */ - public function getAll(CarbonInterface $dateFrom, CarbonInterface $dateTo, int|array $projectId = -1, string $kind = 'all', ?int $userId = null, string $invEmpl = '-1', string $invComp = '-1', string $ticketFilter = '-1', string $paid = '-1', string $clientId = '-1'): array|false + public function getAll(CarbonInterface $dateFrom, CarbonInterface $dateTo, int|array $projectId = -1, string $kind = 'all', ?int $userId = null, string $invEmpl = '-1', string $invComp = '-1', string $ticketFilter = '-1', string $paid = '-1', string $clientId = '-1', int $milestoneId = -1): array|false { return $this->timesheetsRepo->getAll( id: $projectId, @@ -355,7 +355,8 @@ public function getAll(CarbonInterface $dateFrom, CarbonInterface $dateTo, int|a invComp: $invComp, paid: $paid, clientId: $clientId, - ticketFilter: $ticketFilter + ticketFilter: $ticketFilter, + milestoneId: $milestoneId ); } diff --git a/app/Domain/Timesheets/Templates/editTime.tpl.php b/app/Domain/Timesheets/Templates/editTime.tpl.php index b79620e56e..41a0c31b8d 100644 --- a/app/Domain/Timesheets/Templates/editTime.tpl.php +++ b/app/Domain/Timesheets/Templates/editTime.tpl.php @@ -60,8 +60,18 @@ jQuery.growl({ message: "Could not save time entry", style: "error" }); }); }); + jQuery(document).ready(function() { + jQuery('a.delete.editTimeModal').on('click', function(e) { + e.preventDefault(); + var deleteUrl = jQuery(this).attr('href'); + var parentParams = ''; + if (window.parent && window.parent.location) { + parentParams = window.parent.location.search; + } + window.parent.location.href = deleteUrl + (parentParams ? '?' + parentParams.substring(1) : ''); + }); +}); }); - displayNotification() ?> diff --git a/app/Domain/Timesheets/Templates/showAll.tpl.php b/app/Domain/Timesheets/Templates/showAll.tpl.php index 03e85ff174..e28a534d16 100644 --- a/app/Domain/Timesheets/Templates/showAll.tpl.php +++ b/app/Domain/Timesheets/Templates/showAll.tpl.php @@ -73,6 +73,65 @@ function updateProjectCountInline() { } jQuery(document).ready(function(){ + function saveFilterState() { + var formData = jQuery('#form').serialize(); + localStorage.setItem('timesheetFilterState', formData); + } + + jQuery(document).on('click', 'a[href]', function() { + var href = jQuery(this).attr('href'); + if (!href || href.indexOf('#') === 0) return; + if (href.indexOf('timesheets') === -1) { + localStorage.removeItem('timesheetFilterState'); + } + }); + jQuery('#form select, #form input[type="checkbox"]').on('change', saveFilterState); + document.getElementById('form').addEventListener('submit', saveFilterState, true); + +function restoreFilters() { + var savedState = localStorage.getItem('timesheetFilterState'); + if (!savedState) return; + + var params = new URLSearchParams(savedState); + + jQuery('#form input[type="checkbox"]').prop('checked', false); + + params.forEach(function(value, key) { + var safeKey = key.replace(/([\[\]])/g, '\\$1'); + var fields = jQuery('#form [name="' + safeKey + '"]'); + if (fields.length && fields.first().is(':checkbox')) { + fields.each(function() { + if (this.value === value) this.checked = true; + }); + } else { + fields.val(value); + } + }); + + jQuery('#allTimesheetsTable tbody').css('opacity', '0.3'); + jQuery('#allTimesheetsTable tbody').css('pointer-events', 'none'); + + jQuery('#form').submit(); + } + + var _wentToTicket = false; + + jQuery(window).on('hashchange', function() { + var hash = window.location.hash; + + if (hash.indexOf('/tickets/showTicket/') !== -1) { + _wentToTicket = true; + saveFilterState(); + } else if (_wentToTicket) { + _wentToTicket = false; + restoreFilters(); + } + }); + + var isPostBack = ; + if (!isPostBack) { + restoreFilters(); + } jQuery("#checkAllEmpl").change(function(){ jQuery(".invoicedEmpl").prop('checked', jQuery(this).prop("checked")); if (jQuery(this).prop("checked") == true) { @@ -162,11 +221,10 @@ function updateProjectCountInline() {
-
+
displayNotification() ?> - -
@@ -182,7 +240,7 @@ function updateProjectCountInline() { ?>
-
+
@@ -315,6 +373,18 @@ function updateProjectCountInline() { +
+ + + get('invEmpl') == '1') { @@ -351,7 +421,7 @@ function updateProjectCountInline() {
-
+
@@ -517,4 +587,4 @@ function updateProjectCountInline() { jQuery('input.reload[type="submit"]').trigger('click'); }); }); - \ No newline at end of file + diff --git a/app/Domain/Users/Templates/editOwn.blade.php b/app/Domain/Users/Templates/editOwn.blade.php index cedd5fef36..44bc55ecc0 100644 --- a/app/Domain/Users/Templates/editOwn.blade.php +++ b/app/Domain/Users/Templates/editOwn.blade.php @@ -354,7 +354,7 @@
@foreach($availableColorSchemes as $key => $scheme ) - + diff --git a/app/Domain/Wiki/Templates/templates.tpl.php b/app/Domain/Wiki/Templates/templates.tpl.php index d91f109ee5..5cea4730e3 100644 --- a/app/Domain/Wiki/Templates/templates.tpl.php +++ b/app/Domain/Wiki/Templates/templates.tpl.php @@ -1,6 +1,8 @@ $val) { $$var = $val; // necessary for blade refactor @@ -489,6 +491,27 @@ $labelGray->content = 'Gray'; $templates[] = $labelGray; +try { + $customTemplatesRepo = app()->make(\Leantime\Domain\Tickets\Repositories\CustomTemplatesRepository::class); + + $userId = session('userdata.id'); + + if ($userId) { + $customTemplatesData = $customTemplatesRepo->getAll($userId); + + foreach ($customTemplatesData as $customTemplate) { + $customTpl = app()->make(Template::class); + $customTpl->title = $customTemplate['title']; + $customTpl->category = 'My Custom Templates'; + $customTpl->description = $customTemplate['title']; + $customTpl->content = $customTemplate['content']; + $templates[] = $customTpl; + } + } +} catch (\Exception $e) { + error_log('Failed to load custom templates: ' . $e->getMessage()); +} + $templates = $tpl->dispatch_filter('documentTemplates', $templates); echo json_encode($templates); diff --git a/app/Language/en-GB.ini b/app/Language/en-GB.ini index e57fc25273..cb53fb2c0b 100644 --- a/app/Language/en-GB.ini +++ b/app/Language/en-GB.ini @@ -664,6 +664,8 @@ text.documentation="Our documentation goes into details about the usage of Leant text.feature_ideas="Head to our Github repository for any new feature ideas or bugs you may have found.

" text.support="For all other requests please reach out via our contact form.


" +text.clear_all = "Clear all" + buttons.generate_ical_url="Generate iCal URL" links.remove_access="Remove Calendar Access" links.manage_team="Manage Project Team" diff --git a/app/Language/en-US.ini b/app/Language/en-US.ini index 009c70b077..656f6e02d3 100644 --- a/app/Language/en-US.ini +++ b/app/Language/en-US.ini @@ -670,6 +670,8 @@ text.documentation = "Our documentation goes into details about the usage of Lea text.feature_ideas = "Head to our Github repository for any new feature ideas or bugs you may have found.

" text.support = "For all other requests please reach out via our contact form.


" +text.clear_all = "Clear all" + buttons.generate_ical_url = "Generate iCal URL" links.remove_access = "Remove Calendar Access" links.manage_team = "Manage Project Team" diff --git a/app/Views/Templates/layouts/app.blade.php b/app/Views/Templates/layouts/app.blade.php index 6c642d9408..ac539debec 100644 --- a/app/Views/Templates/layouts/app.blade.php +++ b/app/Views/Templates/layouts/app.blade.php @@ -5,7 +5,7 @@ @stack('styles') - + @include('global::sections.appAnnouncement') diff --git a/public/assets/css/components/wysiwyg-overrides.css b/public/assets/css/components/wysiwyg-overrides.css index c405015099..7eacf58e8d 100644 --- a/public/assets/css/components/wysiwyg-overrides.css +++ b/public/assets/css/components/wysiwyg-overrides.css @@ -524,3 +524,17 @@ body.mce-content-body div[class^="col-md-"] { .tox :not(svg) .fa-solid { font-family: 'Font Awesome 6 Free'; } +.advTemplates > .l .tox-listboxfield { + width: 100%; +} + +.advTemplates > .l .tox-listbox--select { + width: 100%; +} +.advTemplates > .l .tox-form__group { + font-style: italic; + margin-top: 15px; + width: auto; + display: flex; + cursor: pointer; +} \ No newline at end of file diff --git a/public/assets/js/app/core/datePickers.js b/public/assets/js/app/core/datePickers.js index d4852fa7d6..876d214614 100644 --- a/public/assets/js/app/core/datePickers.js +++ b/public/assets/js/app/core/datePickers.js @@ -1,13 +1,12 @@ leantime.dateController = (function () { - function getBaseDatePickerConfig(callback) - { + function getBaseDatePickerConfig(callback) { return { numberOfMonths: 1, - dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"), + dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"), dayNames: leantime.i18n.__("language.dayNames").split(","), - dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","), + dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","), dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","), monthNames: leantime.i18n.__("language.monthNames").split(","), monthNamesShort: leantime.i18n.__("language.monthNamesShort").split(","), @@ -24,15 +23,14 @@ leantime.dateController = (function () { }; } - function getDate( element ) - { + function getDate(element) { - var dateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"); + var dateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"); var date; try { date = jQuery.datepicker.parseDate(dateFormat, element.value); - } catch ( error ) { + } catch (error) { date = null; console.log(error); } @@ -57,24 +55,24 @@ leantime.dateController = (function () { }); var from = jQuery(fromElement).datepicker(getBaseDatePickerConfig()) - .on( - "change", - function (date) { - to.datepicker("option", "minDate", getDate(this)); - - if (jQuery(toElement).val() == '') { - jQuery(toElement).val(jQuery(fromElement).val()); - } - } - ); + .on( + "change", + function (date) { + to.datepicker("option", "minDate", getDate(this)); + + if (jQuery(toElement).val() == '') { + jQuery(toElement).val(jQuery(fromElement).val()); + } + } + ); var to = jQuery(toElement).datepicker(getBaseDatePickerConfig()) - .on( - "change", - function () { - from.datepicker("option", "maxDate", getDate(this)); - } - ); + .on( + "change", + function () { + from.datepicker("option", "maxDate", getDate(this)); + } + ); }; var initDatePicker = function (element, callback) { @@ -83,12 +81,24 @@ leantime.dateController = (function () { ); } var initModernDateRangePicker = function (fromElement, toElement, minDistance) { - var fromValue = jQuery(fromElement).val(); - var toValue = jQuery(toElement).val(); - - var startDate = fromValue ? moment(fromValue, 'MM/DD/YYYY') : moment().startOf('month'); - var endDate = toValue ? moment(toValue, 'MM/DD/YYYY') : moment().endOf('month'); - + + var jqueryFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"); + var userFormat = convertJqueryToMomentFormat(jqueryFormat); + + var fromValue = jQuery(fromElement).val(); + var toValue = jQuery(toElement).val(); + + var startDate = fromValue + ? moment(fromValue, [userFormat, 'YYYY-MM-DD', 'MM/DD/YYYY', 'DD.MM.YYYY'], true) + : moment().startOf('month'); + var endDate = toValue + ? moment(toValue, [userFormat, 'YYYY-MM-DD', 'MM/DD/YYYY', 'DD.MM.YYYY'], true) + : moment().endOf('month'); + + if (!startDate.isValid()) startDate = moment().startOf('month'); + if (!endDate.isValid()) endDate = moment().endOf('month'); + + var displayFormat = userFormat.replace(/ddd+,?\s*/g, '').trim(); jQuery(fromElement).daterangepicker({ autoUpdateInput: false, @@ -99,47 +109,58 @@ leantime.dateController = (function () { minDate: moment().subtract(1, 'years'), maxDate: moment().add(1, 'years'), locale: { - format: 'MM/DD/YYYY', - applyLabel: 'Apply', - cancelLabel: 'Cancel', - fromLabel: 'From', - toLabel: 'To', + format: displayFormat, + applyLabel: leantime.i18n.__("language.applyLabel") || 'Apply', + cancelLabel: leantime.i18n.__("language.cancelLabel") || 'Cancel', + fromLabel: leantime.i18n.__("language.fromLabel") || 'From', + toLabel: leantime.i18n.__("language.toLabel") || 'To', customRangeLabel: 'Custom', - firstDay: 1 + firstDay: parseInt(leantime.i18n.__("language.firstDayOfWeek")) || 1 }, ranges: { 'Today': [moment().startOf('day'), moment().endOf('day')], - 'This Week': [moment().startOf('week'), moment().endOf('week')], + 'This Week': [moment().startOf('isoWeek'), moment().endOf('isoWeek')], 'This Month': [moment().startOf('month'), moment().endOf('month')], 'Last 7 Days': [moment().subtract(6, 'days'), moment()] } }); - if (!fromValue) { - jQuery(fromElement).val(startDate.format('YYYY-MM-DD')); - } - if (!toValue) { - jQuery(toElement).val(endDate.format('YYYY-MM-DD')); - } + if (!fromValue) jQuery(fromElement).val(startDate.format(displayFormat)); + if (!toValue) jQuery(toElement).val(endDate.format(displayFormat)); - jQuery(fromElement).on('apply.daterangepicker', function(ev, picker) { + jQuery(fromElement).on('apply.daterangepicker', function (ev, picker) { jQuery(fromElement).val(picker.startDate.format('YYYY-MM-DD')); jQuery(toElement).val(picker.endDate.format('YYYY-MM-DD')); jQuery('#form').submit(); }); - jQuery(toElement).on('focus click', function(e) { + jQuery(toElement).on('focus click', function (e) { e.preventDefault(); jQuery(fromElement).data('daterangepicker').show(); }); }; + function convertJqueryToMomentFormat(jqueryFormat) { + if (!jqueryFormat) return 'DD.MM.YYYY'; + + return jqueryFormat + .replace(/DD/g, 'dddd') + .replace(/D/g, 'ddd') + .replace(/MM/g, 'MMMM') + .replace(/M/g, 'MMM') + .replace(/mm/g, 'MM') + .replace(/m/g, 'M') + .replace(/dd/g, 'DD') + .replace(/d/g, 'D') + .replace(/yy/g, 'YYYY') + .replace(/y/g, 'YY'); + } + - // Make public what you want to have public, everything else is private return { - initDateRangePicker:initDateRangePicker, - initModernDateRangePicker:initModernDateRangePicker, - initDatePicker:initDatePicker, + initDateRangePicker: initDateRangePicker, + initModernDateRangePicker: initModernDateRangePicker, + initDatePicker: initDatePicker, }; })(); diff --git a/public/assets/js/app/core/snippets.js b/public/assets/js/app/core/snippets.js index 81c2a34415..21aac571e3 100644 --- a/public/assets/js/app/core/snippets.js +++ b/public/assets/js/app/core/snippets.js @@ -64,19 +64,15 @@ leantime.snippets = (function () { }; - var toggleTheme = function (theme) { +var toggleTheme = function (theme) { + var themeUrl = jQuery("#themeStyleSheet").attr("href"); + themeUrl = themeUrl.replace(/light\.css|dark\.css/, theme + ".css"); + jQuery("#themeStyleSheet").attr("href", themeUrl); - var themeUrl = jQuery("#themeStyleSheet").attr("href"); - - if(theme == "light"){ - themeUrl = themeUrl.replace("dark.css", "light.css"); - jQuery("#themeStyleSheet").attr("href", themeUrl); - }else if (theme == "dark"){ - themeUrl = themeUrl.replace("light.css", "dark.css"); - jQuery("#themeStyleSheet").attr("href", themeUrl); - } - - }; + jQuery("body") + .removeClass("colorMode-light colorMode-dark") + .addClass("colorMode-" + theme); +}; var toggleBg = function (theme) { @@ -99,12 +95,15 @@ leantime.snippets = (function () { }; - var toggleColors = function (accent1, accent2) { - - jQuery("#colorSchemeSetter").html(":root { --accent1: "+accent1+"; --accent2: "+accent2+"}") +var toggleColors = function (accent1, accent2, schemeKey) { + jQuery("#colorSchemeSetter").html(":root { --accent1: " + accent1 + "; --accent2: " + accent2 + "}"); - - }; + if (schemeKey) { + jQuery("body").removeClass(function(i, cls) { + return (cls.match(/\bcolorScheme-\S+/g) || []).join(' '); + }).addClass("colorScheme-" + schemeKey); + } +}; diff --git a/public/assets/js/app/timesheets/filterPreferences.js b/public/assets/js/app/timesheets/filterPreferences.js index 45e1d1a370..c710623d22 100644 --- a/public/assets/js/app/timesheets/filterPreferences.js +++ b/public/assets/js/app/timesheets/filterPreferences.js @@ -104,7 +104,7 @@ }); dateInput.on('apply.daterangepicker', function (ev, picker) { - if (picker.chosenLabel && picker.chosenLabel !== 'Custom Range') { + if (picker.chosenLabel && picker.chosenLabel !== 'Custom Range' && picker.chosenLabel !== 'Custom') { selectedRangeName = picker.chosenLabel; picker._storedChosenLabel = picker.chosenLabel; } @@ -144,6 +144,7 @@ localStorage.setItem('activeProfileDateRange', data.preference.filters.dateRange || 'Custom'); localStorage.setItem('activeProfileLastApplied', new Date().toISOString()); updateActiveProfileDisplay(); + window._timesheetSubmitFromProfileLoad = true; jQuery('#form').submit(); return true; } else { @@ -162,11 +163,18 @@ function updateActiveProfileDisplay() { const button = jQuery('#filterPreferencesBtn'); + const exportProfileInput = document.getElementById('timesheetExportProfile'); if (activeProfileName) { button.html(` Profile: ${activeProfileName}`); + if (exportProfileInput) { + exportProfileInput.value = activeProfileName; + } } else { button.html(` None selected`); button.css('background-color', ''); + if (exportProfileInput) { + exportProfileInput.value = ''; + } } } @@ -236,7 +244,11 @@ const formElement = document.getElementById('form'); if (formElement) { formElement.addEventListener('submit', function(e) { - if (activeProfileName && !isEditMode && !isApplyingFilters) { + if (window._timesheetSubmitFromProfileLoad) { + window._timesheetSubmitFromProfileLoad = false; + } else if (window._timesheetSubmitFromExportToSlack) { + window._timesheetSubmitFromExportToSlack = false; + } else if (activeProfileName && !isEditMode && !isApplyingFilters) { clearActiveProfile(); } @@ -462,11 +474,14 @@ if (dateInput.length && dateInput.data('daterangepicker')) { const picker = dateInput.data('daterangepicker'); - if (picker._storedChosenLabel && picker._storedChosenLabel !== 'Custom Range') { + const isPreset = function (label) { + return label && label !== 'Custom Range' && label !== 'Custom'; + }; + if (isPreset(picker._storedChosenLabel)) { dateRange = picker._storedChosenLabel; - } else if (picker.chosenLabel && picker.chosenLabel !== 'Custom Range') { + } else if (isPreset(picker.chosenLabel)) { dateRange = picker.chosenLabel; - } else if (selectedRangeName && selectedRangeName !== 'Custom Range') { + } else if (isPreset(selectedRangeName)) { dateRange = selectedRangeName; } else { dateRange = detectRangeFromDates(dateFrom, dateTo); @@ -524,8 +539,10 @@ function detectRangeFromDates(dateFrom, dateTo) { if (!dateFrom || !dateTo) return 'Custom'; - const from = moment(dateFrom, 'MM/DD/YYYY'); - const to = moment(dateTo, 'MM/DD/YYYY'); + const from = moment(dateFrom, ['YYYY-MM-DD', 'MM/DD/YYYY'], true); + const to = moment(dateTo, ['YYYY-MM-DD', 'MM/DD/YYYY'], true); + if (!from.isValid() || !to.isValid()) return 'Custom'; + const today = moment().startOf('day'); if (from.isSame(today, 'day') && to.isSame(today, 'day')) { @@ -544,6 +561,10 @@ return 'This Month'; } + const last7Start = moment().subtract(6, 'days').startOf('day'); + if (from.isSame(last7Start, 'day') && to.isSame(today, 'day')) { + return 'Last 7 Days'; + } return 'Custom'; } @@ -1048,6 +1069,40 @@ saveAutoExportSetting(profileName, isEnabled); }); + jQuery(document).on('click', '#exportToSlackBtn', function (e) { + e.preventDefault(); + e.stopPropagation(); + const btn = this; + const form = document.getElementById('form'); + const hiddenInput = document.getElementById('timesheetExportProfile'); + const slackUrl = btn.getAttribute('formaction') || (leantime.appUrl + '/timesheets/slackMonthlyReportController/sendCsvFromUsersProfilesWhichHaveTickboxTrue'); + const profileName = localStorage.getItem('activeProfileName') || ''; + + function doSubmit() { + if (hiddenInput) { + hiddenInput.value = profileName; + } + window._timesheetSubmitFromExportToSlack = true; + form.action = slackUrl; + form.submit(); + } + + if (profileName) { + loadAllPreferences().then(function () { + const pref = currentPreferences[profileName]; + if (pref && pref.filters) { + applyFilters(pref.filters); + setTimeout(doSubmit, 450); + } else { + doSubmit(); + } + }).catch(function () { + doSubmit(); + }); + } else { + doSubmit(); + } + }); } async function saveAutoExportSetting(profileName, enabled) { @@ -1084,4 +1139,4 @@ getCurrent: getCurrentFilters, apply: applyFilters } -})() \ No newline at end of file +})() diff --git a/public/theme/default/css/custom.css b/public/theme/default/css/custom.css new file mode 100644 index 0000000000..8e87664a8a --- /dev/null +++ b/public/theme/default/css/custom.css @@ -0,0 +1,138 @@ +body.colorScheme-grayscale1.colorMode-light, +body.colorScheme-grayscale1.colorMode-light h1, +body.colorScheme-grayscale1.colorMode-light h2, +body.colorScheme-grayscale1.colorMode-light h3, +body.colorScheme-grayscale1.colorMode-light h4, +body.colorScheme-grayscale1.colorMode-light h5, +body.colorScheme-grayscale1.colorMode-light h6, +body.colorScheme-grayscale1.colorMode-light p, +body.colorScheme-grayscale1.colorMode-light label, +body.colorScheme-grayscale1.colorMode-light select, +body.colorScheme-grayscale1.colorMode-light textarea { + color: black !important; +} + +body.colorScheme-grayscale1.colorMode-dark, +body.colorScheme-grayscale1.colorMode-dark h1, +body.colorScheme-grayscale1.colorMode-dark h2, +body.colorScheme-grayscale1.colorMode-dark h3, +body.colorScheme-grayscale1.colorMode-dark h4, +body.colorScheme-grayscale1.colorMode-dark h5, +body.colorScheme-grayscale1.colorMode-dark h6, +body.colorScheme-grayscale1.colorMode-dark p, +body.colorScheme-grayscale1.colorMode-dark span, +body.colorScheme-grayscale1.colorMode-dark td, +body.colorScheme-grayscale1.colorMode-dark th, +body.colorScheme-grayscale1.colorMode-dark label, +body.colorScheme-grayscale1.colorMode-dark a, +body.colorScheme-grayscale1.colorMode-dark li, +body.colorScheme-grayscale1.colorMode-dark input, +body.colorScheme-grayscale1.colorMode-dark select, +body.colorScheme-grayscale1.colorMode-dark textarea, +body.colorScheme-grayscale1.colorMode-dark button, +body.colorScheme-grayscale1.colorMode-dark .selectableContent { + color: white !important; +} +body.colorScheme-grayscale1.colorMode-dark button, +body.colorScheme-grayscale1.colorMode-dark .daterangepicker, +body.colorScheme-grayscale1.colorMode-dark .table-condensed thead tr th, +body.colorScheme-grayscale1.colorMode-dark .table-condensed, +body.colorScheme-grayscale1.colorMode-dark .off.ends.in-range.available, +body.colorScheme-grayscale1.colorMode-dark .off.ends.in-range.available, +body.colorScheme-grayscale1.colorMode-dark .off.ends.available { + background-color: #292929 !important; +} + +body.colorScheme-grayscale1.colorMode-dark .in-range.available, +body.colorScheme-grayscale1.colorMode-dark li { + background-color: black!important; +} + +body.colorScheme-grayscale2.colorMode-light, +body.colorScheme-grayscale2.colorMode-light h1, +body.colorScheme-grayscale2.colorMode-light h2, +body.colorScheme-grayscale2.colorMode-light h3, +body.colorScheme-grayscale2.colorMode-light h4, +body.colorScheme-grayscale2.colorMode-light h5, +body.colorScheme-grayscale2.colorMode-light h6, +body.colorScheme-grayscale2.colorMode-light p, +body.colorScheme-grayscale2.colorMode-light span, +body.colorScheme-grayscale2.colorMode-light td, +body.colorScheme-grayscale2.colorMode-light th, +body.colorScheme-grayscale2.colorMode-light label, +body.colorScheme-grayscale2.colorMode-light a, +body.colorScheme-grayscale2.colorMode-light li, +body.colorScheme-grayscale2.colorMode-light input, +body.colorScheme-grayscale2.colorMode-light select, +body.colorScheme-grayscale2.colorMode-light textarea, +body.colorScheme-grayscale2.colorMode-dark li, +body.colorScheme-grayscale2.colorMode-light .selectableContent + { + color: black !important; +} + +body.colorScheme-grayscale2.colorMode-dark h1, +body.colorScheme-grayscale2.colorMode-dark h2, +body.colorScheme-grayscale2.colorMode-dark h3, +body.colorScheme-grayscale2.colorMode-dark h4, +body.colorScheme-grayscale2.colorMode-dark h5, +body.colorScheme-grayscale2.colorMode-dark h6, +body.colorScheme-grayscale2.colorMode-dark p, +body.colorScheme-grayscale2.colorMode-dark a, +body.colorScheme-grayscale2.colorMode-dark span, +body.colorScheme-grayscale2.colorMode-dark select, +body.colorScheme-grayscale2.colorMode-dark textarea { + color: white !important; +} +body.colorScheme-grayscale2.colorMode-dark button { + background-color: #292929 !important; +} + +body.colorScheme-themeDefault.colorMode-light, +body.colorScheme-themeDefault.colorMode-light h2, +body.colorScheme-themeDefault.colorMode-light h3, +body.colorScheme-themeDefault.colorMode-light h4, +body.colorScheme-themeDefault.colorMode-light h5, +body.colorScheme-themeDefault.colorMode-light h6, +body.colorScheme-themeDefault.colorMode-light p, +body.colorScheme-themeDefault.colorMode-light td, +body.colorScheme-themeDefault.colorMode-light th, +body.colorScheme-themeDefault.colorMode-light label, +body.colorScheme-themeDefault.colorMode-light li, +body.colorScheme-themeDefault.colorMode-light select, +body.colorScheme-themeDefault.colorMode-light textarea, +body.colorScheme-themeDefault.colorMode-light .selectableContent { + color: #006E9F !important; +} + +body.colorScheme-themeDefault.colorMode-dark, +body.colorScheme-themeDefault.colorMode-dark h1, +body.colorScheme-themeDefault.colorMode-dark h2, +body.colorScheme-themeDefault.colorMode-dark h3, +body.colorScheme-themeDefault.colorMode-dark h4, +body.colorScheme-themeDefault.colorMode-dark h5, +body.colorScheme-themeDefault.colorMode-dark h6, +body.colorScheme-themeDefault.colorMode-dark p, +body.colorScheme-themeDefault.colorMode-dark td, +body.colorScheme-themeDefault.colorMode-dark th, +body.colorScheme-themeDefault.colorMode-dark a, +body.colorScheme-themeDefault.colorMode-dark label, +body.colorScheme-themeDefault.colorMode-dark input, +body.colorScheme-themeDefault.colorMode-dark select, +body.colorScheme-themeDefault.colorMode-dark textarea, +body.colorScheme-themeDefault.colorMode-dark .selectableContent { + color: #00B3FF !important; +} + +body.colorScheme-grayscale1.colorMode-light .project-checkbox-dropdown { + background-color: white !important; +} + +body.colorScheme-grayscale1.colorMode-light .project-checkbox-dropdown label { + background-color: white !important; + color: black !important; +} + +body.colorScheme-grayscale1.colorMode-light .project-checkbox-dropdown label:first-child { + background-color: #f7f9fc !important; +} \ No newline at end of file diff --git a/public/theme/default/css/dark.css b/public/theme/default/css/dark.css index 87218d2f54..ab130a2973 100644 --- a/public/theme/default/css/dark.css +++ b/public/theme/default/css/dark.css @@ -7,7 +7,7 @@ --accent2: hsla(168, 100%, 33%, 1); --accent2-hsl: 168, 100%, 33%; - --accent2-hover: #555; + --accent2-hover: #989898; --accent2-color: #fff; --nav-link-color: #fff; diff --git a/public/theme/default/css/light.css b/public/theme/default/css/light.css index 69fe613cbb..8433dcdd38 100644 --- a/public/theme/default/css/light.css +++ b/public/theme/default/css/light.css @@ -2,12 +2,12 @@ --accent1: hsla(199, 100%, 20%, 1); --accent1-hsl: 199, 100%, 20%; - --accent1-hover: #555; + --accent1-hover: #989898; --accent1-color:#fff; --accent2: hsla(168, 100%, 33%, 1); --accent2-hsl: 168, 100%, 33%; - --accent2-hover: #555; + --accent2-hover: #989898; --accent2-color:#fff; --nav-link-color: #fff;