Release 2.24 (#5097)

This commit is contained in:
Kevin Papst
2024-10-11 21:56:54 +02:00
committed by GitHub
parent 5fac6a642c
commit ff9bf163ee
32 changed files with 381 additions and 463 deletions

View File

@@ -13,8 +13,8 @@
#
# docker build --no-cache -t kimai-fpm --build-arg BASE=fpm .
# docker build --no-cache -t kimai-apache --build-arg BASE=apache .
# docker run -d --name kimai-apache
# docker exec -ti kimai-apache /bin/bash
# docker run -d --name kimai-apache-app kimai-apache
# docker exec -ti kimai-apache-app /bin/bash
# ---------------------------------------------------------------------
# Official PHP images: https://hub.docker.com/_/php/
# https://github.com/docker-library/docs/blob/master/php/README.md#supported-tags-and-respective-dockerfile-links
@@ -259,7 +259,7 @@ RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} >
# copy startup script & DB checking script
COPY .docker/dbtest.php /dbtest.php
COPY .docker/startup.sh /startup.sh
COPY .docker/entrypoint.sh /entrypoint.sh
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
ENV APP_SECRET=change_this_to_something_unique
@@ -277,7 +277,7 @@ ENV COMPOSER_ALLOW_SUPERUSER=1
VOLUME [ "/opt/kimai/var" ]
CMD [ "/startup.sh" ]
CMD [ "/entrypoint.sh" ]
###########################
# final builds

View File

@@ -236,8 +236,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
options.push(optGroup);
}
// log the one with a group name first (e.g. non-global activities)
options.forEach(child => node.appendChild(child));
emptyOpts.forEach(child => node.appendChild(child));
// append the ones with no parent at the end (e.g. global activities)
const optGroupEmpty = this._createOptgroup('');
emptyOpts.forEach(child => optGroupEmpty.appendChild(child));
node.appendChild(optGroupEmpty)
// if available, re-select the previous selected option (mostly usable for global activities)
node.value = selectedValue;

View File

@@ -41,4 +41,11 @@ fieldset:empty {
.nav-pills .nav-link.active {
border-width: 1px;
border-style: solid;
}
}
[data-bs-theme=dark] {
/* fixes https://github.com/tabler/tabler/issues/1974 */
.litepicker .container__days .day-item.is-in-range {
--litepicker-is-in-range-color: var(--tblr-primary-text-emphasis);
}
}

View File

@@ -469,36 +469,6 @@ parameters:
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between '\\$2y\\$13\\$X8…' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'Administrator'\\|'CFO'\\|'Developer'\\|'Developer \\(left…'\\|'Head of Development'\\|'Quality Tester 1'\\|'Quality Tester 2'\\|'Super Administrator' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin'\\|'chris_user'\\|'clara_customer'\\|'john_user'\\|'susan_super'\\|'test_user_1'\\|'test_user_2'\\|'tony_teamlead' and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin@example…'\\|'chris_user@example…'\\|'clara_customer…'\\|'john_user@example…'\\|'susan_super@example…'\\|'test_user_1@example…'\\|'test_user_2@example…'\\|'tony_teamlead…' and null will always evaluate to true\\.$#"
count: 2
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between 0\\|1 and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Strict comparison using \\!\\=\\= between array\\{\\}\\|array\\{'ROLE_ADMIN'\\}\\|array\\{'ROLE_CUSTOMER'\\}\\|array\\{'ROLE_SUPER_ADMIN'\\}\\|array\\{'ROLE_TEAMLEAD'\\} and null will always evaluate to true\\.$#"
count: 1
path: src/Command/ResetTestCommand.php
-
message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#"
count: 1
@@ -1114,16 +1084,6 @@ parameters:
count: 2
path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserDefinition\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/UserFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserPreferences\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/UserFixtures.php
-
message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
count: 1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.74179306.js",
"/build/app.3463e2a4.js"
"/build/app.6cdb49cb.js"
],
"css": [
"/build/app.75450933.css"
"/build/app.1ed18844.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js"
],
"css": [
"/build/app-rtl.dbee41f9.css"
"/build/app-rtl.d38ad12c.css"
]
},
"export-pdf": {
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.3463e2a4.js": "sha384-8Kq7fZHUdtxWd3CVdPUapwwO8qjw3/N2mP4w14TczsDI3jQ3WlJZeUyL0WPZP8QD",
"/build/app.75450933.css": "sha384-tW+c6BIV2/3j9k6AiygV7qMVFqcsYG8dNY2CzCcIuMjOf3inAYoZlGvrV8VgfR3+",
"/build/app.6cdb49cb.js": "sha384-psXCV8SG68O80ZJHHeSbCJPQ8RoepaMeI1pEypTHu0Q8ySLrhlclhKHIL2dZ0ZQJ",
"/build/app.1ed18844.css": "sha384-fEjS/tjc8m7jrw28/eWZFOTO6bugR9ILfksKvBrF46wtmqlage1s/P4+ZeYuqgpq",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.dbee41f9.css": "sha384-52I26CWbi5SCx6Qr+s+xsgmaVX2Ybd4TVVVoEMP87laXeLbu6jtJ/4ZGwo7YDUQ0",
"/build/app-rtl.d38ad12c.css": "sha384-eFvMRueGVAxDxpr3s4y1EhHnVb8FQOr+KQgOuTzbW11ILydpLIvObiiuqNpf4mWO",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.75450933.css",
"build/app.js": "/build/app.3463e2a4.js",
"build/app-rtl.css": "/build/app-rtl.dbee41f9.css",
"build/app.css": "/build/app.1ed18844.css",
"build/app.js": "/build/app.6cdb49cb.js",
"build/app-rtl.css": "/build/app-rtl.d38ad12c.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
abstract class AbstractResetCommand extends Command
{
public function __construct(private string $kernelEnvironment)
public function __construct(private readonly string $kernelEnvironment)
{
parent::__construct();
}

View File

@@ -25,12 +25,13 @@ use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
*
* This command is NOT used during runtime and only meant for developers and the CI processes for quality management.
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
* This is one of the cases where it is necessary to add tests:
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
@@ -38,6 +39,7 @@ final class ResetTestCommand extends AbstractResetCommand
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
string $kernelEnvironment
)
{
@@ -80,227 +82,127 @@ final class ResetTestCommand extends AbstractResetCommand
$project->setCustomer($customer);
$this->entityManager->persist($project);
$user1 = new User();
$user1->setPreferenceValue(UserPreference::HOURLY_RATE, 53);
$user1->setAlias('Clara Haynes');
$user1->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user1->setTitle('CFO');
$user1->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y');
$user1->setEnabled(true);
$user1->setRoles(['ROLE_CUSTOMER']);
$user1->setUserIdentifier('clara_customer');
$user1->setEmail('clara_customer@example.com');
$token1 = new AccessToken($user1, UserFixtures::DEFAULT_API_TOKEN . '_customer');
$token1->setName('Test fixture');
$user2 = new User();
$user2->setPreferenceValue(UserPreference::HOURLY_RATE, 82);
$user2->setAlias('John Doe');
$user2->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user2->setTitle('Developer');
$user2->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$user2->setEnabled(true);
$user2->setRoles(['ROLE_USER']);
$user2->setUserIdentifier('john_user');
$user2->setEmail('john_user@example.com');
$token2 = new AccessToken($user2, UserFixtures::DEFAULT_API_TOKEN . '_user');
$token2->setName('Test fixture');
$user3 = new User();
$user3->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$user3->setAlias('Chris Deactive');
$user3->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user3->setTitle('Developer (left company)');
$user3->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$user3->setEnabled(false);
$user3->setRoles(['ROLE_USER']);
$user3->setUserIdentifier('chris_user');
$user3->setEmail('chris_user@example.com');
$token3 = new AccessToken($user3, UserFixtures::DEFAULT_API_TOKEN . '_inactive');
$token3->setName('Test fixture');
$user4 = new User();
$user4->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$user4->setAlias('Tony Maier');
$user4->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user4->setTitle('Head of Development');
$user4->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$user4->setEnabled(true);
$user4->setRoles(['ROLE_TEAMLEAD']);
$user4->setUserIdentifier('tony_teamlead');
$user4->setEmail('tony_teamlead@example.com');
$token4 = new AccessToken($user4, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
$token4->setName('Test fixture');
$user5 = new User();
$user5->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
$user5->setAlias('Anna Smith');
$user5->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user5->setTitle('Administrator');
$user5->setEnabled(true);
$user5->setRoles(['ROLE_ADMIN']);
$user5->setUserIdentifier('anna_admin');
$user5->setEmail('anna_admin@example.com');
$token5 = new AccessToken($user5, UserFixtures::DEFAULT_API_TOKEN . '_admin');
$token5->setName('Test fixture');
$user6 = new User();
$user6->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$user6->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user6->setTitle('Super Administrator');
$user6->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$user6->setEnabled(true);
$user6->setRoles(['ROLE_SUPER_ADMIN']);
$user6->setUserIdentifier('susan_super');
$user6->setEmail('susan_super@example.com');
$token6 = new AccessToken($user6, UserFixtures::DEFAULT_API_TOKEN . '_super');
$token6->setName('Test fixture');
$user7 = new User();
$user7->setAlias('Test User 1');
$user7->setTitle('Quality Tester 1');
$user7->setEnabled(true);
$user7->setRoles(['ROLE_USER']);
$user7->setUserIdentifier('test_user_1');
$user7->setEmail('test_user_1@example.com');
$token7 = new AccessToken($user7, UserFixtures::DEFAULT_API_TOKEN . '_qa1');
$token7->setName('Test fixture');
$user8 = new User();
$user8->setAlias('Test User 2');
$user8->setTitle('Quality Tester 2');
$user8->setEnabled(true);
$user8->setRoles(['ROLE_USER']);
$user8->setUserIdentifier('test_user_2');
$user8->setEmail('test_user_2@example.com');
$token8 = new AccessToken($user8, UserFixtures::DEFAULT_API_TOKEN . '_qa2');
$token8->setName('Test fixture');
/** @var array<int, array{0: User, 1: AccessToken}> $users */
$users = [
// 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
[
1,
53,
'Clara Haynes',
'2018-02-06 23:28:57',
'CFO',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
1,
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
['ROLE_CUSTOMER'],
'clara_customer',
'clara_customer',
'clara_customer@example.com',
'clara_customer@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_customer',
],
[
2,
82,
'John Doe',
'2018-02-06 23:28:57',
'Developer',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
1,
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
[],
'john_user',
'john_user',
'john_user@example.com',
'john_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_user',
],
[
3,
35,
'Chris Deactive',
'2018-02-06 23:28:57',
'Developer (left company)',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
0,
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
[],
'chris_user',
'chris_user',
'chris_user@example.com',
'chris_user@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
],
[
4,
35,
'Tony Maier',
'2018-02-06 23:28:57',
'Head of Development',
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
1,
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
['ROLE_TEAMLEAD'],
'tony_teamlead',
'tony_teamlead',
'tony_teamlead@example.com',
'tony_teamlead@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
],
[
5,
81,
'Anna Smith',
'2018-02-06 23:28:57',
'Administrator',
null,
1,
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
['ROLE_ADMIN'],
'anna_admin',
'anna_admin',
'anna_admin@example.com',
'anna_admin@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_admin',
],
[
6,
46,
null,
'2018-02-06 23:28:57',
'Super Administrator',
'/bundles/avanzuadmintheme/img/avatar.png',
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
['ROLE_SUPER_ADMIN'],
'susan_super',
'susan_super',
'susan_super@example.com',
'susan_super@example.com',
null,
'2020-04-14 09:50:38',
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_super',
],
[
7,
null,
'Test User 1',
null,
'Quality Tester 1',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_1',
'test_user_1',
'test_user_1@example.com',
'test_user_1@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
],
[
8,
null,
'Test User 2',
null,
'Quality Tester 2',
null,
1,
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
[],
'test_user_2',
'test_user_2',
'test_user_2@example.com',
'test_user_2@example.com',
null,
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa2',
],
[$user1, $token1],
[$user2, $token2],
[$user3, $token3],
[$user4, $token4],
[$user5, $token5],
[$user6, $token6],
[$user7, $token7],
[$user8, $token8],
];
$userEntities = [];
foreach ($users as $userConf) {
$user = new User();
foreach ($users as $items) {
$user = $items[0];
$user->setPassword($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_API_TOKEN));
foreach (User::WIZARDS as $wizard) {
$user->setWizardAsSeen($wizard);
}
if ($userConf[1] !== null) {
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
}
if ($userConf[2] !== null) {
$user->setAlias($userConf[2]);
}
if ($userConf[3] !== null) {
$user->setRegisteredAt(new \DateTime($userConf[3]));
}
if ($userConf[4] !== null) {
$user->setTitle($userConf[4]);
}
if ($userConf[5] !== null) {
$user->setAvatar($userConf[5]);
}
if ($userConf[6] !== null) {
$user->setEnabled((bool) $userConf[6]);
}
$user->setPassword($userConf[7]);
if ($userConf[8] !== null && !empty($userConf[8])) {
$user->setRoles($userConf[8]);
} else {
$user->setRoles(['ROLE_USER']);
}
$user->setUserIdentifier($userConf[9]);
if ($userConf[10] !== null) {
// removed field: UsernameCanonical
}
if ($userConf[11] !== null) {
$user->setEmail($userConf[11]);
}
if ($userConf[12] !== null) {
// removed field: EmailCanonical
}
if ($userConf[17] !== null) {
$user->setApiToken($userConf[17]);
}
$this->entityManager->persist($user);
$accessToken = new AccessToken($user, $userConf[18]);
$accessToken->setName('Test fixture');
$accessToken = $items[1];
$this->entityManager->persist($accessToken);
$this->entityManager->persist($user);
$userEntities[] = $user;
}

View File

@@ -36,6 +36,7 @@ final class UserLoginLinkCommand extends Command
parent::__construct();
$this->addArgument('email', InputArgument::REQUIRED, 'The email of the user');
$this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards');
$this->addOption('all-auth', null, InputOption::VALUE_NONE, 'Ignore that the user is using an external authentication system');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -63,7 +64,7 @@ final class UserLoginLinkCommand extends Command
return Command::FAILURE;
}
if (!$user->isInternalUser()) {
if (!$user->isInternalUser() && !$input->getOption('all-auth')) {
$io->error('User does not use internal login');
return Command::FAILURE;

View File

@@ -33,10 +33,7 @@ final class SystemConfiguration
}
/**
* Set an array item to a given value using "dot" notation.
* If no key is given to the method, the entire array will be replaced.
*
* @internal
* Set a new or replace an existing system configuration.
*/
public function set(string $key, mixed $value): void
{
@@ -456,6 +453,11 @@ final class SystemConfiguration
return $this->getIncrement('quick_entry.recent_activities', 5, 0);
}
public function isBreakTimeEnabled(): bool
{
return (bool) $this->find('timesheet.rules.break_time_active');
}
// ========== Company configurations ==========
public function getFinancialYearStart(): ?string

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.23.0';
public const VERSION = '2.24.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22300;
public const VERSION_ID = 22400;
/**
* The software name
*/

View File

@@ -85,6 +85,10 @@ abstract class TimesheetAbstractController extends AbstractController
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']);
}
if ($this->configuration->isBreakTimeEnabled()) {
$table->addColumn('break', ['class' => 'text-end text-nowrap']);
}
$table->addColumn('duration', ['class' => 'text-end text-nowrap']);
if ($canSeeRate) {

View File

@@ -29,8 +29,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
*/
final class UserFixtures extends Fixture implements FixtureGroupInterface
{
public const DEFAULT_PASSWORD = 'kitten';
public const DEFAULT_API_TOKEN = 'api_kitten';
public const DEFAULT_PASSWORD = 'password';
public const DEFAULT_API_TOKEN = 'token';
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
public const USERNAME_USER = 'john_user';
@@ -43,7 +43,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
public const MIN_RATE = 30;
public const MAX_RATE = 120;
public function __construct(private UserPasswordHasherInterface $passwordHasher)
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher)
{
}
@@ -65,32 +65,8 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
{
$allUsers = $this->getUserDefinition();
foreach ($allUsers as $userData) {
$user = new User();
$user->setAlias($userData[0]);
$user->setTitle($userData[1]);
$user->setUserIdentifier($userData[2]);
$user->setEmail($userData[3]);
$user->setRoles([$userData[4]]);
$user->setAvatar($userData[5]);
$user->setEnabled($userData[6]);
$user->setPassword($this->passwordHasher->hashPassword($user, $userData[8]));
$user->setApiToken($this->passwordHasher->hashPassword($user, $userData[9]));
$manager->persist($user);
$prefs = $this->getUserPreferences($user, $userData[7]);
$user->setPreferences($prefs);
// better to be able to test the wizard in demo installations
/*
foreach (User::WIZARDS as $wizard) {
$user->setWizardAsSeen($wizard);
}
*/
$manager->persist($prefs[0]);
$manager->persist($prefs[1]);
$accessToken = new AccessToken($user, $userData[10]);
$accessToken->setName('Test fixture');
$manager->persist($accessToken);
$manager->persist($userData[0]);
$manager->persist($userData[1]);
}
$manager->flush();
@@ -100,7 +76,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
/**
* @param User $user
* @param string|null $timezone
* @return array
* @return array<UserPreference>
*/
private function getUserPreferences(User $user, string $timezone = null): array
{
@@ -162,141 +138,158 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
$manager->clear();
}
/**
* Do NOT set wizard as seen here, because they should be visible in the demo installation.
*
* @return array<int, array{0: User, 1: AccessToken}>
*/
private function getUserDefinition(): array
{
// alias = $userData[0]
// title = $userData[1]
// username = $userData[2]
// email = $userData[3]
// roles = [$userData[4]]
// avatar = $userData[5]
// enabled = $userData[6]
// timezone = $userData[7]
// password = $userData[8]
// api old = $userData[9]
// api new = $userData[10]
$all = [];
return [
[
'John Doe',
'Developer',
self::USERNAME_USER,
'john_user@example.com',
User::ROLE_USER,
self::DEFAULT_AVATAR,
true,
'America/Vancouver',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_john',
],
[
'John Doe',
'Developer',
'user',
'user@example.com',
User::ROLE_USER,
self::DEFAULT_AVATAR,
true,
'America/Vancouver',
'password',
'password',
self::DEFAULT_API_TOKEN . '_user',
],
// inactive user to test login
[
'Chris Deactive',
'Developer (left company)',
'chris_user',
'chris_user@example.com',
User::ROLE_USER,
self::DEFAULT_AVATAR,
false,
'Australia/Sydney',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_inactive',
],
[
'Tony Maier',
'Head of Sales',
self::USERNAME_TEAMLEAD,
'tony_teamlead@example.com',
User::ROLE_TEAMLEAD,
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
true,
'Asia/Bangkok',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_teamlead',
],
[
'Tony Maier',
'Head of Sales',
'teamlead',
'teamlead@example.com',
User::ROLE_TEAMLEAD,
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
true,
'Asia/Bangkok',
'password',
'password',
self::DEFAULT_API_TOKEN . '_tony',
],
// no avatar to test default image macro
[
'Anna Smith',
'Administrator',
self::USERNAME_ADMIN,
'anna_admin@example.com',
User::ROLE_ADMIN,
null,
true,
'Europe/London',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_anna',
],
[
'Anna Smith',
'Administrator',
'administrator',
'administrator@example.com',
User::ROLE_ADMIN,
null,
true,
'Europe/London',
'password',
'password',
self::DEFAULT_API_TOKEN . '_admin',
],
// no alias to test twig username macro
[
null,
'Super Administrator',
self::USERNAME_SUPER_ADMIN,
'susan_super@example.com',
User::ROLE_SUPER_ADMIN,
'/touch-icon-192x192.png',
true,
'Europe/Berlin',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_susan',
],
[
null,
'Super Administrator',
'super_admin',
'super_admin@example.com',
User::ROLE_SUPER_ADMIN,
'/touch-icon-192x192.png',
true,
'Europe/Berlin',
'password',
'password',
self::DEFAULT_API_TOKEN . '_super',
],
];
$user = new User();
$user->setAlias('John Doe');
$user->setTitle('Developer');
$user->setUserIdentifier(self::USERNAME_USER);
$user->setEmail('john_user@example.com');
$user->setRoles([User::ROLE_USER]);
$user->setAvatar(self::DEFAULT_AVATAR);
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'America/Vancouver');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_john');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setAlias('John Doe');
$user->setTitle('Developer');
$user->setUserIdentifier('user');
$user->setEmail('user@example.com');
$user->setRoles([User::ROLE_USER]);
$user->setAvatar(self::DEFAULT_AVATAR);
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'America/Vancouver');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_user');
$token->setName('User fixture');
$all[] = [$user, $token];
// inactive user to test login
$user = new User();
$user->setAlias('Chris Deactive');
$user->setTitle('Developer (left company)');
$user->setUserIdentifier('chris_user');
$user->setEmail('chris_user@example.com');
$user->setRoles([User::ROLE_USER]);
$user->setAvatar(self::DEFAULT_AVATAR);
$user->setEnabled(false);
$prefs = $this->getUserPreferences($user, 'Australia/Sydney');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_inactive');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setAlias('Tony Maier');
$user->setTitle('Head of Sales');
$user->setUserIdentifier(self::USERNAME_TEAMLEAD);
$user->setEmail('tony_teamlead@example.com');
$user->setRoles([User::ROLE_TEAMLEAD]);
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_teamlead');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setAlias('Tony Maier');
$user->setTitle('Head of Sales');
$user->setUserIdentifier('teamlead');
$user->setEmail('teamlead@example.com');
$user->setRoles([User::ROLE_TEAMLEAD]);
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_tony');
$token->setName('User fixture');
$all[] = [$user, $token];
// no avatar to test default image macro
$user = new User();
$user->setAlias('Anna Smith');
$user->setTitle('Administrator');
$user->setUserIdentifier(self::USERNAME_ADMIN);
$user->setEmail('anna_admin@example.com');
$user->setRoles([User::ROLE_ADMIN]);
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Europe/London');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_anna');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setAlias('Anna Smith');
$user->setTitle('Administrator');
$user->setUserIdentifier('administrator');
$user->setEmail('administrator@example.com');
$user->setRoles([User::ROLE_ADMIN]);
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Europe/London');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_admin');
$token->setName('User fixture');
$all[] = [$user, $token];
// no alias to test twig username macro
$user = new User();
$user->setTitle('Super Administrator');
$user->setUserIdentifier(self::USERNAME_SUPER_ADMIN);
$user->setEmail('susan_super@example.com');
$user->setRoles([User::ROLE_SUPER_ADMIN]);
$user->setAvatar('/touch-icon-192x192.png');
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Europe/Berlin');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_susan');
$token->setName('User fixture');
$all[] = [$user, $token];
$user = new User();
$user->setTitle('Super Administrator');
$user->setUserIdentifier('super_admin');
$user->setEmail('super_admin@example.com');
$user->setRoles([User::ROLE_SUPER_ADMIN]);
$user->setAvatar('/touch-icon-192x192.png');
$user->setEnabled(true);
$prefs = $this->getUserPreferences($user, 'Europe/Berlin');
$user->setPreferences($prefs);
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_super');
$token->setName('User fixture');
$all[] = [$user, $token];
return $all;
}
}

View File

@@ -1276,7 +1276,17 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function getWorkStartingDay(): ?\DateTimeInterface
{
$date = $this->getPreferenceValue(UserPreference::WORK_STARTING_DAY);
return $this->getPreferenceDate('work_start_day');
}
public function setWorkStartingDay(?\DateTimeInterface $date): void
{
$this->setPreferenceValue('work_start_day', $date?->format('Y-m-d'));
}
private function getPreferenceDate(string $prefName): ?\DateTimeInterface
{
$date = $this->getPreferenceValue($prefName);
if ($date === null) {
return null;
@@ -1290,9 +1300,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return ($date instanceof \DateTimeInterface) ? $date : null;
}
public function setWorkStartingDay(?\DateTimeInterface $date): void
public function getLastWorkingDay(): ?\DateTimeInterface
{
$this->setPreferenceValue(UserPreference::WORK_STARTING_DAY, $date?->format('Y-m-d'));
return $this->getPreferenceDate('work_last_day');
}
public function setLastWorkingDay(?\DateTimeInterface $date): void
{
$this->setPreferenceValue('work_last_day', $date?->format('Y-m-d'));
}
public function getPublicHolidayGroup(): null|string

View File

@@ -47,7 +47,6 @@ class UserPreference
public const WORK_HOURS_SATURDAY = WorkingTimeCalculatorDay::WORK_HOURS_SATURDAY;
/** @deprecated since 2.22*/
public const WORK_HOURS_SUNDAY = WorkingTimeCalculatorDay::WORK_HOURS_SUNDAY;
public const WORK_STARTING_DAY = 'work_start_day';
public const PUBLIC_HOLIDAY_GROUP = 'public_holiday_group';
public const HOLIDAYS_PER_YEAR = 'holidays';
public const WORK_CONTRACT_TYPE = 'work_contract_type';

View File

@@ -26,7 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class ActivityType extends AbstractType
{
public function __construct(private ActivityHelper $activityHelper, private ProjectHelper $projectHelper)
public function __construct(private readonly ActivityHelper $activityHelper, private readonly ProjectHelper $projectHelper)
{
}
@@ -35,10 +35,10 @@ final class ActivityType extends AbstractType
return $this->activityHelper->getChoiceLabel($activity);
}
public function groupBy(Activity $activity, $key, $index): ?string
public function groupBy(Activity $activity, $key, $index): string
{
if (null === $activity->getProject()) {
return null;
return '';
}
return $this->projectHelper->getChoiceLabel($activity->getProject());

View File

@@ -102,6 +102,7 @@ final class WorkingTimeService
$stats = null;
$firstDay = $user->getWorkStartingDay();
$lastDay = $user->getLastWorkingDay();
$calculator = $this->getContractMode($user)->getCalculator($user);
foreach ($year->getMonths() as $month) {
@@ -119,7 +120,7 @@ final class WorkingTimeService
$dayDate = $day->getDay();
$result = new WorkingTime($user, $dayDate);
if ($firstDay === null || $firstDay <= $dayDate) {
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
}

View File

@@ -269,6 +269,8 @@
.items td.cost,
.items th.column-rate,
.items th.column-internalRate,
.items th.column-hourlyRate,
.items th.column-fixedRate,
.items th.column-duration
{
text-align: right;
@@ -294,7 +296,7 @@
|
<label for="date-format">
{{ 'date'|trans }}:
{% set demo_date = create_date('2020-01-01 20:00:00') %}
{% set demo_date = create_date('2024-01-01 11:00:00') %}
<select id="date-format" name="date-format">
<option value="short">{{ demo_date|date_short }}</option>
<option value="time">{{ demo_date|date_time }}</option>
@@ -303,7 +305,7 @@
|
<label for="begin-format">
{{ 'begin'|trans }}:
{% set demo_date = create_date('2020-01-01 11:00:00') %}
{% set demo_date = create_date('2024-01-01 11:00:00') %}
<select id="begin-format" name="begin-format">
<option value="plain">{{ demo_date|date_format('H:i') }}</option>
<option value="short">{{ demo_date|date_short }}</option>
@@ -313,7 +315,7 @@
|
<label for="end-format">
{{ 'end'|trans }}:
{% set demo_date = create_date('2020-01-01 18:00:00') %}
{% set demo_date = create_date('2024-01-01 18:00:00') %}
<select id="end-format" name="end-format">
<option value="plain">{{ demo_date|date_format('H:i') }}</option>
<option value="short">{{ demo_date|date_short }}</option>
@@ -635,7 +637,7 @@
<tr>
<td class="column-date text-nowrap" {% if not columns.date %}style="display: none"{% endif %}>
<span class="dateformat" data-short="{{ entry.begin|date_short }}" data-full="{{ entry.begin|date_time }}" data-time="{{ entry.begin|date_time }}">
{{ entry.begin|date_time }}
{{ entry.begin|date_short }}
</span>
</td>
<td class="column-begin text-nowrap" {% if not columns.begin %}style="display: none"{% endif %}>
@@ -708,10 +710,10 @@
{% endif %}
</td>
{% endfor %}
<td class="column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}>
<td class="cost column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}>
{{ entry.hourlyRate|money(entry.project.customer.currency) }}
</td>
<td class="column-fixedRate text-nowrap" {% if not columns.fixedRate %}style="display: none"{% endif %}>
<td class="cost column-fixedRate text-nowrap" {% if not columns.fixedRate %}style="display: none"{% endif %}>
{{ entry.fixedRate|money(entry.project.customer.currency) }}
</td>
<td class="duration column-duration text-nowrap" {% if not columns.duration %}style="display: none"{% endif %}>

View File

@@ -110,6 +110,8 @@
{% else %}
<i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}">{{ entry|duration }}</i>
{% endif %}
{% elseif column == 'break' %}
{{ entry.break|duration }}
{% elseif column == 'hourlyRate' %}
{{ entryHourlyRate }}
{% elseif column == 'rate' %}

View File

@@ -7,11 +7,10 @@
* file that was distributed with this source code.
*/
namespace API;
namespace App\Tests\API;
use App\Entity\Invoice;
use App\Entity\User;
use App\Tests\API\APIControllerBaseTest;
use App\Tests\DataFixtures\InvoiceFixtures;
/**

View File

@@ -13,6 +13,7 @@ use App\Command\ResetTestCommand;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* @covers \App\Command\ResetTestCommand
@@ -24,7 +25,11 @@ class ResetTestCommandTest extends KernelTestCase
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$application->add(new ResetTestCommand($this->createMock(EntityManagerInterface::class), 'test'));
$application->add(new ResetTestCommand(
$this->createMock(EntityManagerInterface::class),
$this->createMock(UserPasswordHasherInterface::class),
'test'
));
self::assertTrue($application->has('kimai:reset:test'));
$command = $application->find('kimai:reset:test');
@@ -33,7 +38,11 @@ class ResetTestCommandTest extends KernelTestCase
public function testCommandNameIsNotEnabledInProd(): void
{
$sut = new ResetTestCommand($this->createMock(EntityManagerInterface::class), 'prod');
$sut = new ResetTestCommand(
$this->createMock(EntityManagerInterface::class),
$this->createMock(UserPasswordHasherInterface::class),
'prod'
);
self::assertFalse($sut->isEnabled());
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Controller\Security;
use App\Configuration\SamlConfiguration;
use App\Configuration\SystemConfiguration;
use App\Controller\Security\SecurityController;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Controller\ControllerBaseTest;
@@ -63,8 +64,8 @@ class SecurityControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('body form')->form();
$client->submit($form, [
'_username' => 'susan_super',
'_password' => 'kitten'
'_username' => UserFixtures::USERNAME_SUPER_ADMIN,
'_password' => UserFixtures::DEFAULT_PASSWORD
]);
$this->assertIsRedirect($client); // redirect to root URL

View File

@@ -1798,6 +1798,10 @@
<source>costs</source>
<target>Kosten</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break">
<source>break</source>
<target>Pause</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1798,6 +1798,10 @@
<source>costs</source>
<target>Costs</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break">
<source>break</source>
<target>Break</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -154,6 +154,10 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target>Bitte senden Sie das Formular erneut ab. Sollte das Problem weiterhin bestehen, laden Sie die bitte die Seite neu.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day.">
<source>An absence cannot be booked after the last working day.</source>
<target>Eine Abwesenheit kann nicht nach dem letzten Arbeitstag gebucht werden.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -124,7 +124,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target>An absence cannot be booked before your first working day.</target>
<target>An absence cannot be booked before the first working day.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>
@@ -154,6 +154,10 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target>Please try to resubmit the form. If the problem persists, refresh your browser.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day.">
<source>An absence cannot be booked after the last working day.</source>
<target>An absence cannot be booked after the last working day.</target>
</trans-unit>
</body>
</file>
</xliff>