Release 2.19 (#4922)

This commit is contained in:
Kevin Papst
2024-07-22 17:51:03 +02:00
committed by GitHub
parent ce22520d7f
commit 8788311faf
32 changed files with 585 additions and 372 deletions

View File

@@ -56,7 +56,7 @@ jobs:
run: composer install run: composer install
- name: Validate Composer - name: Validate Composer
run: composer validate --strict run: composer validate --strict --no-check-all
- name: Warmup cache - name: Warmup cache
run: APP_ENV=dev bin/console kimai:reload -n run: APP_ENV=dev bin/console kimai:reload -n

View File

@@ -1,8 +1,26 @@
@include color-mode(dark, true) { @include color-mode(dark, true) {
$dark-font-opacity: 0.6;
/*
--tblr-body-bg: #1e222b;
--tblr-bg-surface-tertiary: #1e222b;
--tblr-bg-surface: #1e222b;
--tblr-bg-surface-secondary: #17202b;
//--tblr-muted: #d9dcdf;
*/
.table thead th {
color: inherit;
opacity: $dark-font-opacity;
}
table.dataTable { table.dataTable {
th.today { th.today {
color: var(--tblr-muted); color: var(--tblr-muted);
} }
thead .sorting:after {
opacity: $dark-font-opacity;
}
} }
.overlay { .overlay {
@@ -12,4 +30,16 @@
color: var(--tblr-white); color: var(--tblr-white);
} }
} }
/* Fix tabs pills (e.g. user profile) */
.nav {
--tblr-nav-link-color: inherit;
opacity: $dark-font-opacity;
}
.datagrid-title {
color: inherit;
opacity: $dark-font-opacity;
}
.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {
color: inherit;
}
} }

View File

@@ -66,6 +66,7 @@
"symfony/security-bundle": "^6.0", "symfony/security-bundle": "^6.0",
"symfony/security-csrf": "^6.0", "symfony/security-csrf": "^6.0",
"symfony/serializer": "^6.0", "symfony/serializer": "^6.0",
"symfony/string": "v6.4.8",
"symfony/translation": "^6.0", "symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0", "symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0", "symfony/validator": "^6.0",
@@ -171,7 +172,6 @@
"@tests-integration" "@tests-integration"
], ],
"linting": [ "linting": [
"composer validate --strict",
"bin/console lint:container", "bin/console lint:container",
"bin/console lint:yaml config --parse-tags", "bin/console lint:yaml config --parse-tags",
"bin/console lint:twig templates --show-deprecations", "bin/console lint:twig templates --show-deprecations",

555
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4008,21 +4008,6 @@ parameters:
count: 2 count: 2
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounter\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounterBillable\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounterExported\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
- -
message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#" message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#"
count: 1 count: 1

View File

@@ -36,9 +36,14 @@ DirectoryIndex index.php
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$ RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1] RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache # Sets the HTTP_AUTHORIZATION header removed by Apache, if PHP is installed as CGI
# Required for the API and below are 3 different methods to achieve it
RewriteCond %{HTTP:Authorization} .+ RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# This might be the only required line
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
# Apache 2.4.13+
# CGIPassAuth On
# Redirect to URI without front controller to prevent duplicate content # Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial # (with and without `/index.php`). Only do this redirect on the initial

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
"/build/app.41121fe4.js" "/build/app.41121fe4.js"
], ],
"css": [ "css": [
"/build/app.ee4f7e19.css" "/build/app.a7ba9fd6.css"
] ]
}, },
"app-rtl": { "app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js" "/build/app-rtl.97153087.js"
], ],
"css": [ "css": [
"/build/app-rtl.db588f2b.css" "/build/app-rtl.6516df2d.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -73,9 +73,9 @@
"integrity": { "integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG", "/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.41121fe4.js": "sha384-4v3lVToJIcpMJ++4WaIZ5r9rXR5Vb4waSoHcEzx3vOfcLL+1+TN70di7XPzYSW7I", "/build/app.41121fe4.js": "sha384-4v3lVToJIcpMJ++4WaIZ5r9rXR5Vb4waSoHcEzx3vOfcLL+1+TN70di7XPzYSW7I",
"/build/app.ee4f7e19.css": "sha384-7E3ujVsOCkGatHu6khaWWqYhPkc85k22zuXZOwOVo1MlWcPSEBhS65Y0qbeWXdAp", "/build/app.a7ba9fd6.css": "sha384-GfMYf1vYI9zkkywMoxYk6wpC4UyK185zjDBi16qa0237oztrtOuWdBZsKjWOkyeY",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr", "/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl", "/build/app-rtl.6516df2d.css": "sha384-5hF5akg98Hnj/bihpbsiTjMyZ3BsAC4+Z1kzqi1O3/U+tgpnQ+JmwsOADDpMld6d",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF", "/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG", "/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",

View File

@@ -1,7 +1,7 @@
{ {
"build/app.css": "/build/app.ee4f7e19.css", "build/app.css": "/build/app.a7ba9fd6.css",
"build/app.js": "/build/app.41121fe4.js", "build/app.js": "/build/app.41121fe4.js",
"build/app-rtl.css": "/build/app-rtl.db588f2b.css", "build/app-rtl.css": "/build/app-rtl.6516df2d.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js", "build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js", "build/export-pdf.js": "/build/export-pdf.1442bee7.js",

View File

@@ -482,7 +482,7 @@ final class SystemConfiguration
} }
/** /**
* @internal will be made private soon after 2.18.0 - do ot access this method directly, but through getThemeColors() * @internal will be made private soon after 2.18.0 - do not access this method directly, but through getThemeColors()
*/ */
public function getThemeColorChoices(): string public function getThemeColorChoices(): string
{ {

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.18.0'; public const VERSION = '2.19.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 21800; public const VERSION_ID = 21900;
/** /**
* The software name * The software name
*/ */

View File

@@ -90,7 +90,6 @@ final class ContractController extends AbstractController
'boxes' => $controllerEvent->getController(), 'boxes' => $controllerEvent->getController(),
'year' => $year, 'year' => $year,
'user' => $profile, 'user' => $profile,
'form' => $form->createView(),
]); ]);
} }
} }

View File

@@ -11,7 +11,9 @@ namespace App\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Form\QuickEntryForm; use App\Form\QuickEntryForm;
use App\Form\WeekByUserForm;
use App\Model\QuickEntryWeek; use App\Model\QuickEntryWeek;
use App\Reporting\WeekByUser\WeekByUser;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService; use App\Timesheet\FavoriteRecordService;
@@ -25,7 +27,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* Controller used to enter times in weekly form. * Controller used to enter times in weekly form.
*/ */
#[Route(path: '/quick_entry')]
#[IsGranted('quick-entry')] #[IsGranted('quick-entry')]
final class QuickEntryController extends AbstractController final class QuickEntryController extends AbstractController
{ {
@@ -38,18 +39,30 @@ final class QuickEntryController extends AbstractController
{ {
} }
#[Route(path: '/{begin}', name: 'quick_entry', methods: ['GET', 'POST'])] #[Route(path: '/quick_entry/', name: 'quick_entry', methods: ['GET', 'POST'])]
public function quickEntry(Request $request, ?string $begin = null): Response public function quickEntry(Request $request): Response
{ {
$factory = $this->getDateTimeFactory(); $user = $this->getUser();
$factory = $this->getDateTimeFactory($user);
$defaultDate = $factory->createDateTime();
if ($begin !== null) { $values = new WeekByUser();
try { $values->setUser($user);
$begin = $factory->createDateTime($begin); $values->setDate($defaultDate);
} catch (\Exception $ex) {
$begin = null; $weeklyForm = $this->createFormForGetRequest(WeekByUserForm::class, $values, [
} 'include_user' => $this->isGranted('view_other_timesheet'),
} 'timezone' => $factory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'attr' => ['name' => 'quick_entry_weekrange_form']
]);
$weeklyForm->submit($request->query->all(), false);
$user = $values->getUser() ?? $user;
$factory = $this->getDateTimeFactory($user);
$begin = $values->getDate();
if ($begin === null) { if ($begin === null) {
$begin = $factory->createDateTime(); $begin = $factory->createDateTime();
@@ -57,7 +70,6 @@ final class QuickEntryController extends AbstractController
$startWeek = $factory->getStartOfWeek($begin); $startWeek = $factory->getStartOfWeek($begin);
$endWeek = $factory->getEndOfWeek($begin); $endWeek = $factory->getEndOfWeek($begin);
$user = $this->getUser();
$tmpDay = clone $startWeek; $tmpDay = clone $startWeek;
$week = []; $week = [];
@@ -235,7 +247,7 @@ final class QuickEntryController extends AbstractController
if ($saved) { if ($saved) {
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]); return $this->redirectToRoute('quick_entry', ['date' => $begin->format('Y-m-d'), 'user' => $user->getId()]);
} }
} catch (\Exception $ex) { } catch (\Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
@@ -244,6 +256,8 @@ final class QuickEntryController extends AbstractController
$page = new PageSetup('quick_entry.title'); $page = new PageSetup('quick_entry.title');
$page->setHelp('weekly-times.html'); $page->setHelp('weekly-times.html');
$page->setPaginationForm($weeklyForm);
$page->setActionName('weekly-times');
return $this->render('quick-entry/index.html.twig', [ return $this->render('quick-entry/index.html.twig', [
'page_setup' => $page, 'page_setup' => $page,

View File

@@ -61,9 +61,11 @@ final class ReportUsersYearController extends AbstractController
$dateTimeFactory = $this->getDateTimeFactory(); $dateTimeFactory = $this->getDateTimeFactory();
$defaultDate = $dateTimeFactory->createStartOfYear(); $defaultDate = $dateTimeFactory->createStartOfYear();
$isFinancialYear = false;
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) { if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear); $defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
$isFinancialYear = true;
} }
$values = new YearlyUserList(); $values = new YearlyUserList();
@@ -72,6 +74,7 @@ final class ReportUsersYearController extends AbstractController
$form = $this->createFormForGetRequest(YearlyUserListForm::class, $values, [ $form = $this->createFormForGetRequest(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(), 'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(), 'start_date' => $values->getDate(),
'show_range' => $isFinancialYear,
]); ]);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);

View File

@@ -11,7 +11,6 @@ namespace App\Form;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Form\Type\QuickEntryWeekType; use App\Form\Type\QuickEntryWeekType;
use App\Form\Type\WeekPickerType;
use App\Model\QuickEntryWeek; use App\Model\QuickEntryWeek;
use App\Validator\Constraints\QuickEntryModel; use App\Validator\Constraints\QuickEntryModel;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
@@ -57,13 +56,6 @@ final class QuickEntryForm extends AbstractType
} }
)); ));
$builder->add('date', WeekPickerType::class, [
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'label' => false,
]);
$builder->add('rows', CollectionType::class, [ $builder->add('rows', CollectionType::class, [
'label' => false, 'label' => false,
'entry_type' => QuickEntryWeekType::class, 'entry_type' => QuickEntryWeekType::class,

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Form\Type\UserType;
use App\Form\Type\WeekPickerType;
use App\Reporting\WeekByUser\WeekByUser;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class WeekByUserForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('date', WeekPickerType::class, [
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
]);
if ($options['include_user']) {
$builder->add('user', UserType::class, [
'width' => false,
'include_current_user_if_system_account' => true
]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WeekByUser::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'include_user' => false,
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -25,7 +25,7 @@ class QuickEntryModel
*/ */
private array $timesheets = []; private array $timesheets = [];
public function __construct(private ?User $user = null, private ?Project $project = null, private ?Activity $activity = null) public function __construct(private User $user, private ?Project $project = null, private ?Activity $activity = null)
{ {
} }
@@ -39,7 +39,7 @@ class QuickEntryModel
return $this->prototype; return $this->prototype;
} }
public function getUser(): ?User public function getUser(): User
{ {
return $this->user; return $this->user;
} }

View File

@@ -27,7 +27,7 @@ class QuickEntryWeek
{ {
} }
public function addRow(?User $user = null, ?Project $project = null, ?Activity $activity = null): QuickEntryModel public function addRow(User $user, ?Project $project = null, ?Activity $activity = null): QuickEntryModel
{ {
$model = $this->createRow($user, $project, $activity); $model = $this->createRow($user, $project, $activity);
@@ -36,7 +36,7 @@ class QuickEntryWeek
return $model; return $model;
} }
public function createRow(?User $user = null, ?Project $project = null, ?Activity $activity = null): QuickEntryModel public function createRow(User $user, ?Project $project = null, ?Activity $activity = null): QuickEntryModel
{ {
return new QuickEntryModel($user, $project, $activity); return new QuickEntryModel($user, $project, $activity);
} }

View File

@@ -28,7 +28,7 @@ final class YearlyUserListForm extends AbstractType
'model_timezone' => $options['timezone'], 'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'], 'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'], 'start_date' => $options['start_date'],
'show_range' => true, 'show_range' => $options['show_range'],
]); ]);
$builder->add('team', TeamType::class, [ $builder->add('team', TeamType::class, [
'multiple' => false, 'multiple' => false,
@@ -51,6 +51,7 @@ final class YearlyUserListForm extends AbstractType
'start_date' => new \DateTime(), 'start_date' => new \DateTime(),
'csrf_protection' => false, 'csrf_protection' => false,
'method' => 'GET', 'method' => 'GET',
'show_range' => false,
]); ]);
} }
} }

View File

@@ -37,6 +37,7 @@ final class Extensions extends AbstractExtension
public function getFunctions(): array public function getFunctions(): array
{ {
return [ return [
new TwigFunction('report_date', [$this, 'buildReportDate']),
new TwigFunction('class_name', [$this, 'getClassName']), new TwigFunction('class_name', [$this, 'getClassName']),
new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']), new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']),
new TwigFunction('random_color', [$this, 'randomColor']), new TwigFunction('random_color', [$this, 'randomColor']),
@@ -52,6 +53,38 @@ final class Extensions extends AbstractExtension
]; ];
} }
public function buildReportDate(string|int $year, string|int $month = 1, string|int $day = 1): \DateTimeImmutable
{
if (\is_string($month)) {
$month = (int) $month;
}
if ($month > 12 || $month < 1) {
throw new \InvalidArgumentException('Unknown month: ' . $month);
}
if ($month < 10) {
$month = '0' . $month;
}
if (\is_string($day)) {
$day = (int) $day;
}
if ($day > 31 || $day < 1) {
throw new \InvalidArgumentException('Unknown day: ' . $day);
}
if ($day < 10) {
$day = '0' . $day;
}
if (\is_string($year)) {
$year = (int) $year;
}
if ($year < 1980 || $year > 2100) {
throw new \InvalidArgumentException('Unknown year: ' . $year);
}
return \DateTimeImmutable::createFromFormat('Y-m-d', $year . '-' . $month . '-' . $day); // @phpstan-ignore-line
}
public function formatReportDate(\DateTimeInterface $dateTime): string public function formatReportDate(\DateTimeInterface $dateTime): string
{ {
return $dateTime->format(self::REPORT_DATE); return $dateTime->format(self::REPORT_DATE);

View File

@@ -189,18 +189,36 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
return $this->getFormatter()->dayName($dateTime, $short); return $this->getFormatter()->dayName($dateTime, $short);
} }
public function getJavascriptConfiguration(User $user): array public function getJavascriptConfiguration(?User $user = null): array
{ {
$language = User::DEFAULT_LANGUAGE;
$browserTitle = false;
$id = null;
$name = 'anonymous';
$admin = false;
$superAdmin = false;
$timezone = date_default_timezone_get();
if ($user !== null) {
$browserTitle = (bool) $user->getPreferenceValue('update_browser_title');
$language = $user->getLanguage();
$id = $user->getId();
$name = $user->getDisplayName();
$admin = $user->isAdmin();
$superAdmin = $user->isSuperAdmin();
$timezone = $user->getTimezone();
}
return [ return [
'locale' => $this->locale, 'locale' => $this->locale,
'language' => $user->getLanguage(), 'language' => $language,
'formatDuration' => $this->localeService->getDurationFormat($this->locale), 'formatDuration' => $this->localeService->getDurationFormat($this->locale),
'formatDate' => $this->localeService->getDateFormat($this->locale), 'formatDate' => $this->localeService->getDateFormat($this->locale),
'defaultColor' => Constants::DEFAULT_COLOR, 'defaultColor' => Constants::DEFAULT_COLOR,
'twentyFourHours' => $this->localeService->is24Hour($this->locale), 'twentyFourHours' => $this->localeService->is24Hour($this->locale),
'updateBrowserTitle' => (bool) $user->getPreferenceValue('update_browser_title'), 'updateBrowserTitle' => $browserTitle,
'timezone' => $user->getTimezone(), 'timezone' => $timezone,
'user' => ['id' => $user->getId(), 'name' => $user->getDisplayName(), 'admin' => $user->isAdmin(), 'superAdmin' => $user->isSuperAdmin()], 'user' => ['id' => $id, 'name' => $name, 'admin' => $admin, 'superAdmin' => $superAdmin],
]; ];
} }

View File

@@ -27,7 +27,14 @@ use Psr\EventDispatcher\EventDispatcherInterface;
*/ */
final class WorkingTimeService final class WorkingTimeService
{ {
public function __construct(private TimesheetRepository $timesheetRepository, private WorkingTimeRepository $workingTimeRepository, private EventDispatcherInterface $eventDispatcher) /** @var array<string, WorkingTime|null> */
private array $latestApprovals = [];
public function __construct(
private readonly TimesheetRepository $timesheetRepository,
private readonly WorkingTimeRepository $workingTimeRepository,
private readonly EventDispatcherInterface $eventDispatcher
)
{ {
} }
@@ -43,7 +50,36 @@ final class WorkingTimeService
public function getLatestApproval(User $user): ?WorkingTime public function getLatestApproval(User $user): ?WorkingTime
{ {
return $this->workingTimeRepository->getLatestApproval($user); if ($user->getId() === null) {
return null;
}
$key = 'u_' . $user->getId();
if (!\array_key_exists($key, $this->latestApprovals)) {
$this->latestApprovals[$key] = $this->workingTimeRepository->getLatestApproval($user);
}
return $this->latestApprovals[$key];
}
public function isApproved(User $user, \DateTimeInterface $dateTime): bool
{
$latestApproval = $this->getLatestApproval($user);
if ($latestApproval === null) {
return false;
}
$latestApprovalDate = $latestApproval->getDate();
$begin = \DateTimeImmutable::createFromInterface($dateTime);
$begin = $begin->setTime(0, 0, 0);
if ($begin > $latestApprovalDate) {
return false;
}
return true;
} }
public function getYear(User $user, \DateTimeInterface $yearDate, \DateTimeInterface $until): Year public function getYear(User $user, \DateTimeInterface $yearDate, \DateTimeInterface $until): Year
@@ -124,6 +160,11 @@ final class WorkingTimeService
$this->workingTimeRepository->persistScheduledWorkingTimes(); $this->workingTimeRepository->persistScheduledWorkingTimes();
$key = 'u_' . $user->getId();
if (\array_key_exists($key, $this->latestApprovals)) {
unset($this->latestApprovals[$key]);
}
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($user, $month, $approvalDate, $approvedBy)); $this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($user, $month, $approvalDate, $approvedBy));
} }

View File

@@ -281,20 +281,27 @@
{%- endblock secret_widget %} {%- endblock secret_widget %}
{% block yearpicker_widget -%} {% block yearpicker_widget -%}
{% set yearInt = year|date_format('Y') %}
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-left btn-icon" href="#" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}" <a class="btn btn-left btn-icon" href="#" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}"
data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ previousYear|report_date }}" data-event-bubbles="change"> data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ previousYear|report_date }}" data-event-bubbles="change">
{{ icon('left') }} {{ icon('left') }}
</a> </a>
<a class="btn" href="#" onclick="return false;"> {% if show_range %}
<span id="{{ form.vars.id }}_month_name"> <a class="btn" href="#" onclick="return false;">
{% if show_range %} <span id="{{ form.vars.id }}_month_name">{{ year|date_short }} &ndash; {{ nextYear|date_short }}</span>
{{ year|date_short }} &ndash; {{ nextYear|date_short }} </a>
{% else %} {% else %}
{{ year|date_format('Y') }} <button type="button" class="btn btn-default dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{% endif %} {{ yearInt }}
</span> </button>
</a> <ul class="dropdown-menu dropdown-menu-start pre-scrollable">
{% for i in (yearInt - 5)..(yearInt + 5) %}
{% set tmpYear = report_date(i) %}
<li><a href="#" data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ tmpYear|report_date }}" data-event-bubbles="change" class="dropdown-item">{{ tmpYear|date_format('Y') }}</a></li>
{% endfor %}
</ul>
{% endif %}
<a class="btn btn-right btn-icon" href="#" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}" <a class="btn btn-right btn-icon" href="#" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}"
data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ nextYear|report_date }}" data-event-bubbles="change"> data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ nextYear|report_date }}" data-event-bubbles="change">
{{ icon('right') }} {{ icon('right') }}

View File

@@ -1,8 +1,9 @@
{% macro init_frontend_loader() %} {% macro init_frontend_loader() %}
{% set fdow = app.user is not null ? app.user.firstDayOfWeek : 'monday' %}
{% set configurations = javascript_configurations(app.user)|merge({ {% set configurations = javascript_configurations(app.user)|merge({
login: path('login'), login: path('login'),
direction: tabler_bundle.rightToLeft ? 'rtl' : 'ltr', direction: tabler_bundle.rightToLeft ? 'rtl' : 'ltr',
first_dow_iso: iso_day_by_name(app.user.firstDayOfWeek), first_dow_iso: iso_day_by_name(fdow),
}) %} }) %}
<script type="text/javascript"> <script type="text/javascript">
window.addEventListener('load', function() { window.addEventListener('load', function() {

View File

@@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'page_setup.html.twig' %}
{% block page_class %}quick-entry-page{% endblock %} {% block page_class %}quick-entry-page{% endblock %}
@@ -10,7 +10,6 @@
{% block box_before %} {% block box_before %}
{{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }} {{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }}
{{ form_widget(form._token) }} {{ form_widget(form._token) }}
{{ form_widget(form.date) }}
{{ form_errors(form) }} {{ form_errors(form) }}
{% endblock %} {% endblock %}
{% block box_after %} {% block box_after %}
@@ -107,10 +106,6 @@
}; };
recalculateTotals(); recalculateTotals();
document.getElementById('{{ form.date.vars.id }}').addEventListener('change', function(ev) {
location.href = '{{ path('quick_entry', {'begin': '__BEGIN__'}) }}'.replace('__BEGIN__', ev.target.value);
});
document.getElementById('quick-entries-form').addEventListener('click', recalculateTotals); document.getElementById('quick-entries-form').addEventListener('click', recalculateTotals);
document.getElementById('quick-entries-form').addEventListener('change', recalculateTotals); document.getElementById('quick-entries-form').addEventListener('change', recalculateTotals);

View File

@@ -24,11 +24,13 @@ class QuickEntryControllerTest extends ControllerBaseTest
public function testIndexAction(): void public function testIndexAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(); $client = $this->getClientForAuthenticatedUser();
$this->request($client, '/quick_entry'); $this->request($client, '/quick_entry/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]'); $node = $client->getCrawler()->filter('form[name=quick_entry_weekrange_form]');
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count()); self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]');
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count()); self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
$addBtn = $node->filter('button.btn-success[type=button]'); $addBtn = $node->filter('button.btn-success[type=button]');
@@ -62,11 +64,10 @@ class QuickEntryControllerTest extends ControllerBaseTest
$fixture->setStartDate(new \DateTime('-7 days')); $fixture->setStartDate(new \DateTime('-7 days'));
$this->importFixture($fixture); $this->importFixture($fixture);
$this->request($client, '/quick_entry'); $this->request($client, '/quick_entry/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]'); $node = $client->getCrawler()->filter('form[name=quick_entry_form]');
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count()); self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
$addBtn = $node->filter('button.btn-success[type=button]'); $addBtn = $node->filter('button.btn-success[type=button]');

View File

@@ -23,11 +23,12 @@ class QuickEntryModelTest extends TestCase
{ {
public function testEmptyModel(): void public function testEmptyModel(): void
{ {
$sut = new QuickEntryModel(); $user = new User();
$sut = new QuickEntryModel($user);
self::assertFalse($sut->isPrototype()); self::assertFalse($sut->isPrototype());
self::assertNull($sut->getProject()); self::assertNull($sut->getProject());
self::assertNull($sut->getActivity()); self::assertNull($sut->getActivity());
self::assertNull($sut->getUser()); self::assertEquals($user, $sut->getUser());
self::assertEquals([], $sut->getNewTimesheet()); self::assertEquals([], $sut->getNewTimesheet());
self::assertEquals([], $sut->getTimesheets()); self::assertEquals([], $sut->getTimesheets());
self::assertNull($sut->getLatestEntry()); self::assertNull($sut->getLatestEntry());
@@ -103,7 +104,8 @@ class QuickEntryModelTest extends TestCase
public function testHasExistingTimesheet(): void public function testHasExistingTimesheet(): void
{ {
$sut = new QuickEntryModel(); $user = new User();
$sut = new QuickEntryModel($user);
self::assertFalse($sut->hasExistingTimesheet()); self::assertFalse($sut->hasExistingTimesheet());
$mock = $this->createMock(Timesheet::class); $mock = $this->createMock(Timesheet::class);
@@ -128,11 +130,12 @@ class QuickEntryModelTest extends TestCase
public function testPrototype(): void public function testPrototype(): void
{ {
$sut = new QuickEntryModel(); $user = new User();
$sut = new QuickEntryModel($user);
$sut->markAsPrototype(); $sut->markAsPrototype();
self::assertTrue($sut->isPrototype()); self::assertTrue($sut->isPrototype());
$sut = new QuickEntryModel(); $sut = new QuickEntryModel($user);
$sut->markAsPrototype(); $sut->markAsPrototype();
$mock = $this->createMock(Timesheet::class); $mock = $this->createMock(Timesheet::class);
$mock->method('getId')->willReturn(1); $mock->method('getId')->willReturn(1);

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Model; namespace App\Tests\Model;
use App\Entity\User;
use App\Model\QuickEntryModel; use App\Model\QuickEntryModel;
use App\Model\QuickEntryWeek; use App\Model\QuickEntryWeek;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -27,7 +28,7 @@ class QuickEntryWeekTest extends TestCase
self::assertEquals([], $sut->getRows()); self::assertEquals([], $sut->getRows());
$rows = [ $rows = [
new QuickEntryModel() new QuickEntryModel(new User())
]; ];
$sut->setRows($rows); $sut->setRows($rows);

View File

@@ -64,7 +64,7 @@ class ExtensionsTest extends TestCase
public function testGetFunctions(): void public function testGetFunctions(): void
{ {
$functions = ['class_name', 'iso_day_by_name', 'random_color']; $functions = ['report_date', 'class_name', 'iso_day_by_name', 'random_color'];
$sut = $this->getSut(); $sut = $this->getSut();
$twigFunctions = $sut->getFunctions(); $twigFunctions = $sut->getFunctions();
$this->assertCount(\count($functions), $twigFunctions); $this->assertCount(\count($functions), $twigFunctions);

View File

@@ -483,6 +483,33 @@ class LocaleFormatExtensionsTest extends TestCase
self::assertEquals($expected, $sut->getJavascriptConfiguration($user)); self::assertEquals($expected, $sut->getJavascriptConfiguration($user));
} }
public function testJavascriptConfigurationsForAnonymous(): void
{
$expected = [
'formatDuration' => '%h:%m',
'formatDate' => 'Y-m-d',
'defaultColor' => '#d2d6de',
'twentyFourHours' => false,
'updateBrowserTitle' => false,
'timezone' => 'Europe/Vienna',
'locale' => 'en',
'language' => 'en',
'user' => [
'id' => null,
'name' => 'anonymous',
'admin' => false,
'superAdmin' => false,
],
];
$security = $this->createMock(Security::class);
$sut = new LocaleFormatExtensions(new LocaleService($this->localeEn), $security);
$sut->setLocale('en');
self::assertEquals($expected, $sut->getJavascriptConfiguration());
}
public function testDurationDecimal(): void public function testDurationDecimal(): void
{ {
$record = $this->getTimesheet(9437); $record = $this->getTimesheet(9437);

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Validator\Constraints;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\QuickEntryModel as QuickEntryModelEntity; use App\Model\QuickEntryModel as QuickEntryModelEntity;
use App\Validator\Constraints\QuickEntryModel; use App\Validator\Constraints\QuickEntryModel;
use App\Validator\Constraints\QuickEntryModelValidator; use App\Validator\Constraints\QuickEntryModelValidator;
@@ -47,7 +48,8 @@ class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
public function testTriggersOnMissingProjectAndActivity(): void public function testTriggersOnMissingProjectAndActivity(): void
{ {
$model = new QuickEntryModelEntity(); $user = new User();
$model = new QuickEntryModelEntity($user);
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime()); $timesheet->setBegin(new \DateTime());
$timesheet->setBegin(new \DateTime('+ 1 hour')); $timesheet->setBegin(new \DateTime('+ 1 hour'));
@@ -66,7 +68,8 @@ class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
public function testTriggersOnMissingActivity(): void public function testTriggersOnMissingActivity(): void
{ {
$model = new QuickEntryModelEntity(); $user = new User();
$model = new QuickEntryModelEntity($user);
$model->setProject(new Project()); $model->setProject(new Project());
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime()); $timesheet->setBegin(new \DateTime());
@@ -83,7 +86,8 @@ class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
public function testTriggersOnMissingProject(): void public function testTriggersOnMissingProject(): void
{ {
$model = new QuickEntryModelEntity(); $user = new User();
$model = new QuickEntryModelEntity($user);
$model->setActivity(new Activity()); $model->setActivity(new Activity());
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime()); $timesheet->setBegin(new \DateTime());
@@ -100,7 +104,8 @@ class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
public function testDoesNotTriggerOnPrototype(): void public function testDoesNotTriggerOnPrototype(): void
{ {
$model = new QuickEntryModelEntity(); $user = new User();
$model = new QuickEntryModelEntity($user);
$this->validator->validate($model, new QuickEntryModel()); $this->validator->validate($model, new QuickEntryModel());
@@ -109,7 +114,8 @@ class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
public function testDoesNotTriggerOnProperlyFilled(): void public function testDoesNotTriggerOnProperlyFilled(): void
{ {
$model = new QuickEntryModelEntity(); $user = new User();
$model = new QuickEntryModelEntity($user);
$model->setActivity(new Activity()); $model->setActivity(new Activity());
$model->setProject(new Project()); $model->setProject(new Project());
$timesheet = new Timesheet(); $timesheet = new Timesheet();