Release 2.10 (#4549)

This commit is contained in:
Kevin Papst
2024-01-19 12:05:07 +01:00
committed by GitHub
parent afd60a67f6
commit 46739795ff
52 changed files with 348 additions and 292 deletions

View File

@@ -35,7 +35,7 @@
"friendsofsymfony/rest-bundle": "^3.0", "friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6", "gedmo/doctrine-extensions": "^3.6",
"jms/serializer-bundle": "^5.0", "jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^1.1", "kevinpapst/tabler-bundle": "dev-main",
"league/csv": "^9.4", "league/csv": "^9.4",
"mpdf/mpdf": "^8.0", "mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0", "nelmio/api-doc-bundle": "^4.0",

19
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a307b9a36c22711439283a3e5bc3249c", "content-hash": "fa716c8a640639ed9a9b64089f64a6ba",
"packages": [ "packages": [
{ {
"name": "azuyalabs/yasumi", "name": "azuyalabs/yasumi",
@@ -2447,16 +2447,16 @@
}, },
{ {
"name": "kevinpapst/tabler-bundle", "name": "kevinpapst/tabler-bundle",
"version": "1.1.0", "version": "dev-main",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/kevinpapst/TablerBundle.git", "url": "https://github.com/kevinpapst/TablerBundle.git",
"reference": "57dd328fe98cee4321d0b77d63eebd118180ab61" "reference": "c9a9dbf160094f2b2987f74cc15eade974793f14"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/57dd328fe98cee4321d0b77d63eebd118180ab61", "url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/c9a9dbf160094f2b2987f74cc15eade974793f14",
"reference": "57dd328fe98cee4321d0b77d63eebd118180ab61", "reference": "c9a9dbf160094f2b2987f74cc15eade974793f14",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2485,6 +2485,7 @@
"suggest": { "suggest": {
"knplabs/knp-menu-bundle": "Allows easy menu integration" "knplabs/knp-menu-bundle": "Allows easy menu integration"
}, },
"default-branch": true,
"type": "symfony-bundle", "type": "symfony-bundle",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -2504,7 +2505,7 @@
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io", "description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
"support": { "support": {
"issues": "https://github.com/kevinpapst/TablerBundle/issues", "issues": "https://github.com/kevinpapst/TablerBundle/issues",
"source": "https://github.com/kevinpapst/TablerBundle/tree/1.1.0" "source": "https://github.com/kevinpapst/TablerBundle/tree/main"
}, },
"funding": [ "funding": [
{ {
@@ -2516,7 +2517,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-12-01T15:39:05+00:00" "time": "2024-01-16T17:24:13+00:00"
}, },
{ {
"name": "league/csv", "name": "league/csv",
@@ -13353,7 +13354,9 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {
"kevinpapst/tabler-bundle": 20
},
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {

View File

@@ -1036,11 +1036,6 @@ parameters:
count: 3 count: 3
path: src/Controller/QuickEntryController.php path: src/Controller/QuickEntryController.php
-
message: "#^Cannot call method setTime\\(\\) on DateTime\\|null\\.$#"
count: 3
path: src/Controller/QuickEntryController.php
- -
message: "#^Method App\\\\Controller\\\\Reporting\\\\AbstractUserReportController\\:\\:getStatisticDataRaw\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Controller\\\\Reporting\\\\AbstractUserReportController\\:\\:getStatisticDataRaw\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -3646,11 +3641,6 @@ parameters:
count: 2 count: 2
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 4
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
- -
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#" message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1 count: 1
@@ -5661,11 +5651,6 @@ parameters:
count: 1 count: 1
path: src/Validator/Constraints/TimesheetBudgetUsedValidator.php path: src/Validator/Constraints/TimesheetBudgetUsedValidator.php
-
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
path: src/Validator/Constraints/TimesheetBudgetUsedValidator.php
- -
message: "#^Parameter \\#1 \\$timesheet \\(App\\\\Entity\\\\Timesheet\\) of method App\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidator\\:\\:validate\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Validator\\\\ConstraintValidatorInterface\\:\\:validate\\(\\)$#" message: "#^Parameter \\#1 \\$timesheet \\(App\\\\Entity\\\\Timesheet\\) of method App\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidator\\:\\:validate\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Validator\\\\ConstraintValidatorInterface\\:\\:validate\\(\\)$#"
count: 2 count: 2

View File

@@ -32,9 +32,9 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class ActionsController extends BaseApiController final class ActionsController extends BaseApiController
{ {
public function __construct( public function __construct(
private ViewHandlerInterface $viewHandler, private readonly ViewHandlerInterface $viewHandler,
private EventDispatcherInterface $dispatcher, private readonly EventDispatcherInterface $dispatcher,
private TranslatorInterface $translator private readonly TranslatorInterface $translator
) { ) {
} }

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\Command;
use App\Event\EmailEvent;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
#[AsCommand(name: 'kimai:mail:test', description: 'Send a test email')]
final class MailTestCommand extends Command
{
public function __construct(private readonly EventDispatcherInterface $dispatcher)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'The sender of the message', 'kimai@example.org');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$message = new Email();
$message->to((string) $input->getArgument('to')); // @phpstan-ignore-line
$message->from((string) $input->getOption('from')); // @phpstan-ignore-line
$message->subject('Kimai test email');
$message->text('This is an email for testing the text body.');
$this->dispatcher->dispatch(new EmailEvent($message));
return Command::SUCCESS;
}
}

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.9.0'; public const VERSION = '2.10.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20900; public const VERSION_ID = 21000;
/** /**
* The software name * The software name
*/ */

View File

@@ -95,7 +95,12 @@ final class ProfileController extends AbstractController
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUserIdentifier()]); $locale = $request->getLocale();
if ($this->getUser()->getId() === $profile->getId()) {
$locale = $profile->getPreferenceValue('language', $locale, false);
}
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUserIdentifier(), '_locale' => $locale]);
} }
return $this->render('user/profile.html.twig', [ return $this->render('user/profile.html.twig', [

View File

@@ -10,7 +10,6 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Form\QuickEntryForm; use App\Form\QuickEntryForm;
use App\Model\QuickEntryWeek; use App\Model\QuickEntryWeek;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
@@ -71,7 +70,6 @@ final class QuickEntryController extends AbstractController
$result = $this->repository->getTimesheetResult($query); $result = $this->repository->getTimesheetResult($query);
$rows = []; $rows = [];
/** @var Timesheet $timesheet */
foreach ($result->getResults(true) as $timesheet) { foreach ($result->getResults(true) as $timesheet) {
$i = 0; $i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId(); $id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
@@ -124,7 +122,6 @@ final class QuickEntryController extends AbstractController
$defaultBegin = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime()); $defaultBegin = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime());
$defaultHour = (int) $defaultBegin->format('H'); $defaultHour = (int) $defaultBegin->format('H');
$defaultMinute = (int) $defaultBegin->format('i'); $defaultMinute = (int) $defaultBegin->format('i');
$defaultBegin->setTime($defaultHour, $defaultMinute, 0, 0);
$formModel = new QuickEntryWeek($startWeek); $formModel = new QuickEntryWeek($startWeek);
@@ -136,8 +133,9 @@ final class QuickEntryController extends AbstractController
$tmp = $this->timesheetService->createNewTimesheet($user); $tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setProject($row['project']); $tmp->setProject($row['project']);
$tmp->setActivity($row['activity']); $tmp->setActivity($row['activity']);
$tmp->setBegin(clone $day['day']); $newTime = \DateTime::createFromInterface($day['day']);
$tmp->getBegin()->setTime($defaultHour, $defaultMinute, 0, 0); $newTime = $newTime->setTime($defaultHour, $defaultMinute);
$tmp->setBegin($newTime);
$this->timesheetService->prepareNewTimesheet($tmp); $this->timesheetService->prepareNewTimesheet($tmp);
$model->addTimesheet($tmp); $model->addTimesheet($tmp);
} else { } else {
@@ -151,8 +149,9 @@ final class QuickEntryController extends AbstractController
$empty->markAsPrototype(); $empty->markAsPrototype();
foreach ($week as $dayId => $day) { foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user); $tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setBegin(clone $day['day']); $newTime = \DateTime::createFromInterface($day['day']);
$tmp->getBegin()->setTime($defaultHour, $defaultMinute, 0, 0); $newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
$this->timesheetService->prepareNewTimesheet($tmp); $this->timesheetService->prepareNewTimesheet($tmp);
$empty->addTimesheet($tmp); $empty->addTimesheet($tmp);
} }
@@ -165,8 +164,9 @@ final class QuickEntryController extends AbstractController
$model = $formModel->addRow($user); $model = $formModel->addRow($user);
foreach ($week as $dayId => $day) { foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user); $tmp = $this->timesheetService->createNewTimesheet($user);
$tmp->setBegin(clone $day['day']); $newTime = \DateTime::createFromInterface($day['day']);
$tmp->getBegin()->setTime($defaultHour, $defaultMinute, 0, 0); $newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0);
$tmp->setBegin($newTime);
$this->timesheetService->prepareNewTimesheet($tmp); $this->timesheetService->prepareNewTimesheet($tmp);
$model->addTimesheet($tmp); $model->addTimesheet($tmp);
} }
@@ -193,7 +193,7 @@ final class QuickEntryController extends AbstractController
foreach ($tmpModel->getTimesheets() as $timesheet) { foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) { if ($timesheet->getId() !== null) {
$duration = $timesheet->getDuration(false); $duration = $timesheet->getDuration(false);
if ($duration === null || $timesheet->getEnd() === null) { if ($duration === null || $timesheet->isRunning()) {
$deleteTimesheets[] = $timesheet; $deleteTimesheets[] = $timesheet;
} else { } else {
$saveTimesheets[] = $timesheet; $saveTimesheets[] = $timesheet;

View File

@@ -19,7 +19,6 @@ use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\Toolbar\UserToolbarForm; use App\Form\Toolbar\UserToolbarForm;
use App\Form\Type\UserType; use App\Form\Type\UserType;
use App\Form\UserCreateType; use App\Form\UserCreateType;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
@@ -79,6 +78,7 @@ final class UserController extends AbstractController
$table->addColumn('email', ['class' => 'd-none', 'orderBy' => false]); $table->addColumn('email', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('lastLogin', ['class' => 'd-none', 'orderBy' => false]); $table->addColumn('lastLogin', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('roles', ['class' => 'd-none', 'orderBy' => false]); $table->addColumn('roles', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('system_account', ['class' => 'd-none', 'orderBy' => 'systemAccount']);
foreach ($event->getPreferences() as $userPreference) { foreach ($event->getPreferences() as $userPreference) {
$table->addColumn('mf_' . $userPreference->getName(), ['title' => $userPreference->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'translation_domain' => 'messages', 'data' => $userPreference]); $table->addColumn('mf_' . $userPreference->getName(), ['title' => $userPreference->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'translation_domain' => 'messages', 'data' => $userPreference]);
@@ -163,13 +163,7 @@ final class UserController extends AbstractController
] ]
]) ])
->add('user', UserType::class, [ ->add('user', UserType::class, [
'query_builder' => function (UserRepository $repo) use ($userToDelete) { 'ignore_users' => [$userToDelete],
$query = new UserFormTypeQuery();
$query->addUserToIgnore($userToDelete);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
'required' => false, 'required' => false,
]) ])
->setAction($this->generateUrl('admin_user_delete', ['id' => $userToDelete->getId()])) ->setAction($this->generateUrl('admin_user_delete', ['id' => $userToDelete->getId()]))

View File

@@ -87,9 +87,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
* Reflects the date in the user timezone (not in UTC). * Reflects the date in the user timezone (not in UTC).
* This value is automatically set through the begin column and ONLY used in statistic queries. * This value is automatically set through the begin column and ONLY used in statistic queries.
*/ */
#[ORM\Column(name: 'date_tz', type: 'date', nullable: false)] #[ORM\Column(name: 'date_tz', type: 'date_immutable', nullable: false)]
#[Assert\NotNull] #[Assert\NotNull]
private ?DateTime $date = null; private ?\DateTimeImmutable $date = null;
/** /**
* Time records start date-time. * Time records start date-time.
* *
@@ -265,7 +265,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
$this->begin = $begin; $this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName(); $this->timezone = $begin->getTimezone()->getName();
// make sure that the original date is always kept in UTC // make sure that the original date is always kept in UTC
$this->date = new DateTime($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC')); $this->date = new \DateTimeImmutable($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC'));
return $this; return $this;
} }

View File

@@ -18,7 +18,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
*/ */
final class EmailSubscriber implements EventSubscriberInterface final class EmailSubscriber implements EventSubscriberInterface
{ {
public function __construct(private KimaiMailer $mailer) public function __construct(private readonly KimaiMailer $mailer)
{ {
} }

View File

@@ -0,0 +1,28 @@
<?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\EventSubscriber;
use KevinPapst\TablerBundle\Event\NotificationEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class NotificationsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
NotificationEvent::class => ['onNotificationEvent', 100],
];
}
public function onNotificationEvent(NotificationEvent $event): void
{
$event->setShowBadgeTotal(false);
}
}

View File

@@ -315,7 +315,7 @@ class TimesheetEditForm extends AbstractType
function (FormEvent $event) { function (FormEvent $event) {
/** @var Timesheet|null $timesheet */ /** @var Timesheet|null $timesheet */
$timesheet = $event->getData(); $timesheet = $event->getData();
if (null === $timesheet || null === $timesheet->getEnd()) { if (null === $timesheet || $timesheet->isRunning()) {
$event->getForm()->get('duration')->setData(null); $event->getForm()->get('duration')->setData(null);
} }
} }
@@ -340,7 +340,7 @@ class TimesheetEditForm extends AbstractType
// only apply the duration, if the end is not yet set // only apply the duration, if the end is not yet set
// without that check, the end would be overwritten and the real end time would be lost // without that check, the end would be overwritten and the real end time would be lost
if (($forceApply && $duration > 0) || ($duration > 0 && null === $timesheet->getEnd())) { if (($forceApply && $duration > 0) || ($duration > 0 && $timesheet->isRunning())) {
$end = clone $timesheet->getBegin(); $end = clone $timesheet->getBegin();
$end->modify('+ ' . $duration . 'seconds'); $end->modify('+ ' . $duration . 'seconds');
$timesheet->setEnd($end); $timesheet->setEnd($end);

View File

@@ -39,13 +39,16 @@ class DatePickerType extends AbstractType
return null; return null;
} }
if ($reverseTransform instanceof \DateTimeInterface && $options['force_time']) { if ($reverseTransform instanceof \DateTimeInterface && $options['force_time'] !== null) {
if ($options['force_time'] === 'start') { if ($options['force_time'] === 'start') {
$reverseTransform = \DateTime::createFromInterface($reverseTransform); $reverseTransform = \DateTime::createFromInterface($reverseTransform);
$reverseTransform->setTime(0, 0, 0); $reverseTransform = $reverseTransform->setTime(0, 0, 0);
} elseif ($options['force_time'] === 'end') { } elseif ($options['force_time'] === 'end') {
$reverseTransform = \DateTime::createFromInterface($reverseTransform); $reverseTransform = \DateTime::createFromInterface($reverseTransform);
$reverseTransform->setTime(23, 59, 59); $reverseTransform = $reverseTransform->setTime(23, 59, 59);
} elseif (\is_string($options['force_time'])) {
$reverseTransform = \DateTime::createFromInterface($reverseTransform);
$reverseTransform = $reverseTransform->modify($options['force_time']);
} }
} }
@@ -81,7 +84,7 @@ class DatePickerType extends AbstractType
'format' => $formFormat, 'format' => $formFormat,
'model_timezone' => date_default_timezone_get(), 'model_timezone' => date_default_timezone_get(),
'view_timezone' => date_default_timezone_get(), 'view_timezone' => date_default_timezone_get(),
'force_time' => null, 'force_time' => null, // one of: string (start, end) or a string to as argument for DateTime->modify() or null
'min_day' => null, 'min_day' => null,
'max_day' => null, 'max_day' => null,
]); ]);

View File

@@ -56,7 +56,7 @@ final class QuickEntryTimesheetType extends AbstractType
function (FormEvent $event) use ($durationOptions) { function (FormEvent $event) use ($durationOptions) {
/** @var Timesheet|null $data */ /** @var Timesheet|null $data */
$data = $event->getData(); $data = $event->getData();
if (null === $data || null === $data->getEnd()) { if (null === $data || $data->isRunning()) {
$event->getForm()->get('duration')->setData(null); $event->getForm()->get('duration')->setData(null);
} }

View File

@@ -27,6 +27,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
final class UserType extends AbstractType final class UserType extends AbstractType
{ {
public function __construct(private readonly UserRepository $userRepository)
{
}
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
@@ -63,31 +67,66 @@ final class UserType extends AbstractType
// e.g. when editing a team that has disabled users, these users would be removed silently // e.g. when editing a team that has disabled users, these users would be removed silently
// see https://github.com/kimai/kimai/pull/1841 // see https://github.com/kimai/kimai/pull/1841
'include_users' => [], 'include_users' => [],
// includes the current user if it is a system-account, which is especially useful for forms pages,
// which have a user switcher and display the logged-in user by default
'include_current_user_if_system_account' => false,
'documentation' => [ 'documentation' => [
'type' => 'integer', 'type' => 'integer',
'description' => 'User ID', 'description' => 'User ID',
], ],
]); ]);
$resolver->setDefault('query_builder', function (Options $options) { $resolver->setDefault('choices', function (Options $options) {
return function (UserRepository $repo) use ($options) { $query = new UserFormTypeQuery();
$query = new UserFormTypeQuery(); $query->setUser($options['user']);
$query->setUser($options['user']);
if ($options['include_disabled'] === true) { if ($options['include_disabled'] === true) {
$query->setVisibility(VisibilityInterface::SHOW_BOTH); $query->setVisibility(VisibilityInterface::SHOW_BOTH);
}
$qb = $this->userRepository->getQueryBuilderForFormType($query);
$users = $qb->getQuery()->getResult();
$ignoreIds = [];
/** @var User $user */
foreach ($options['ignore_users'] as $user) {
$ignoreIds[] = $user->getId();
}
$users = array_filter($users, function (User $user) use ($ignoreIds) {
if ($user->getId() === null) {
return false;
} }
foreach ($options['ignore_users'] as $userToIgnore) { return !\in_array($user->getId(), $ignoreIds, true);
$query->addUserToIgnore($userToIgnore); });
}
if (!empty($options['include_users'])) { /** @var array<int, User> $userById */
$query->setUsersAlwaysIncluded($options['include_users']); $userById = [];
} /** @var User $user */
foreach ($users as $user) {
$userById[$user->getId()] = $user;
}
return $repo->getQueryBuilderForFormType($query); $includeUsers = $options['include_users'];
}; if ($options['include_current_user_if_system_account'] === true) {
if ($options['user'] instanceof User && $options['user']->isSystemAccount()) {
$includeUsers[] = $options['user'];
}
}
/** @var User $user */
foreach ($includeUsers as $user) {
if ($user->getId() !== null && !\array_key_exists($user->getId(), $userById)) {
$userById[$user->getId()] = $user;
}
}
usort($userById, function (User $a, User $b) {
return $a->getDisplayName() <=> $b->getDisplayName();
});
return array_values($userById);
}); });
} }

View File

@@ -27,7 +27,10 @@ final class YearByUserForm extends AbstractType
]); ]);
if ($options['include_user']) { if ($options['include_user']) {
$builder->add('user', UserType::class, ['width' => false]); $builder->add('user', UserType::class, [
'width' => false,
'include_current_user_if_system_account' => true
]);
} }
} }

View File

@@ -133,11 +133,11 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$max = null; $max = null;
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($min === null || $min->getBegin()->getTimestamp() > $entry->getBegin()->getTimestamp()) { if ($min === null || $min->getBegin() > $entry->getBegin()) {
$min = $entry; $min = $entry;
} }
if ($max === null || $max->getBegin()->getTimestamp() < $entry->getBegin()->getTimestamp()) { if ($max === null || $max->getBegin() < $entry->getBegin()) {
$max = $entry; $max = $entry;
} }
} }

View File

@@ -17,13 +17,13 @@ use Symfony\Component\Mime\RawMessage;
final class KimaiMailer implements MailerInterface final class KimaiMailer implements MailerInterface
{ {
public function __construct(private MailConfiguration $configuration, private MailerInterface $mailer) public function __construct(private readonly MailConfiguration $configuration, private readonly MailerInterface $mailer)
{ {
} }
public function send(RawMessage $message, Envelope $envelope = null): void public function send(RawMessage $message, Envelope $envelope = null): void
{ {
if ($message instanceof Email) { if ($message instanceof Email && \count($message->getFrom()) === 0) {
$message->from($this->configuration->getFromAddress()); $message->from($this->configuration->getFromAddress());
} }

View File

@@ -30,7 +30,10 @@ final class MonthByUserForm extends AbstractType
]); ]);
if ($options['include_user']) { if ($options['include_user']) {
$builder->add('user', UserType::class, ['width' => false]); $builder->add('user', UserType::class, [
'width' => false,
'include_current_user_if_system_account' => true,
]);
} }
$builder->add('sumType', ReportSumType::class); $builder->add('sumType', ReportSumType::class);
} }

View File

@@ -30,7 +30,10 @@ final class WeekByUserForm extends AbstractType
]); ]);
if ($options['include_user']) { if ($options['include_user']) {
$builder->add('user', UserType::class, ['width' => false]); $builder->add('user', UserType::class, [
'width' => false,
'include_current_user_if_system_account' => true,
]);
} }
$builder->add('sumType', ReportSumType::class); $builder->add('sumType', ReportSumType::class);
} }

View File

@@ -30,7 +30,10 @@ final class YearByUserForm extends AbstractType
]); ]);
if ($options['include_user']) { if ($options['include_user']) {
$builder->add('user', UserType::class, ['width' => false]); $builder->add('user', UserType::class, [
'width' => false,
'include_current_user_if_system_account' => true,
]);
} }
$builder->add('sumType', ReportSumType::class); $builder->add('sumType', ReportSumType::class);
} }

View File

@@ -9,64 +9,10 @@
namespace App\Repository\Query; namespace App\Repository\Query;
use App\Entity\User;
/** /**
* Can be used to pre-fill form types with: UserRepository::getQueryBuilderForFormType() * Can be used to pre-fill form types with: UserRepository::getQueryBuilderForFormType()
*/ */
final class UserFormTypeQuery extends BaseFormTypeQuery final class UserFormTypeQuery extends BaseFormTypeQuery
{ {
use VisibilityTrait; use VisibilityTrait;
/**
* @var User[]
*/
private array $includeUsers = [];
/**
* @var User[]
*/
private array $ignoredUsers = [];
/**
* Sets a list of users which must be included in the result always.
*
* @param array<User> $users
*/
public function setUsersAlwaysIncluded(array $users): void
{
$this->includeUsers = $users;
}
/**
* Get the list of users which should always be included in the result.
*
* @return User[]
*/
public function getUsersAlwaysIncluded(): array
{
return $this->includeUsers;
}
/**
* Given user will be excluded from the result set.
*
* @param User $user
* @return $this
*/
public function addUserToIgnore(User $user): UserFormTypeQuery
{
$this->ignoredUsers[] = $user;
return $this;
}
/**
* Returns the list of users that should not be loaded.
*
* @return User[]
*/
public function getUsersToIgnore(): array
{
return $this->ignoredUsers;
}
} }

View File

@@ -18,7 +18,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{ {
use VisibilityTrait; use VisibilityTrait;
public const USER_ORDER_ALLOWED = ['alias', 'user', 'username', 'title', 'email']; public const USER_ORDER_ALLOWED = ['username', 'alias', 'title', 'email', 'systemAccount'];
private ?string $role = null; private ?string $role = null;
/** /**
@@ -30,7 +30,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
public function __construct() public function __construct()
{ {
$this->setDefaults([ $this->setDefaults([
'orderBy' => 'user', 'orderBy' => 'username',
'searchTeams' => [], 'searchTeams' => [],
'visibility' => VisibilityInterface::SHOW_VISIBLE, 'visibility' => VisibilityInterface::SHOW_VISIBLE,
'systemAccount' => null, 'systemAccount' => null,

View File

@@ -180,35 +180,15 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
{ {
$qb = $this->createQueryBuilder('u'); $qb = $this->createQueryBuilder('u');
$or = $qb->expr()->orX();
if ($query->isShowVisible()) { if ($query->isShowVisible()) {
$or->add($qb->expr()->eq('u.enabled', ':enabled')); $qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, ParameterType::BOOLEAN); $qb->setParameter('enabled', true, ParameterType::BOOLEAN);
} }
$includeAlways = $query->getUsersAlwaysIncluded();
if (!empty($includeAlways)) {
$or->add($qb->expr()->in('u', ':users'));
$qb->setParameter('users', $includeAlways);
}
if ($or->count() > 0) {
$qb->andWhere($or);
}
if (\count($query->getUsersToIgnore()) > 0) {
$ids = array_map(function (User $user) {
return $user->getId();
}, $query->getUsersToIgnore());
$qb->andWhere($qb->expr()->notIn('u.id', $ids));
}
$qb->andWhere($qb->expr()->eq('u.systemAccount', ':system')); $qb->andWhere($qb->expr()->eq('u.systemAccount', ':system'));
$qb->setParameter('system', false, Types::BOOLEAN); $qb->setParameter('system', false, Types::BOOLEAN);
$qb->addSelect("COALESCE(NULLIF(u.alias, ''), u.username) as HIDDEN userOrder");
$qb->orderBy('u.username', 'ASC'); $qb->orderBy('userOrder', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams()); $this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
@@ -307,10 +287,6 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
foreach ($query->getOrderGroups() as $orderBy => $order) { foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) { switch ($orderBy) {
case 'user':
$qb->addSelect('COALESCE(u.alias, u.username) as HIDDEN userOrder');
$orderBy = 'userOrder';
break;
default: default:
$orderBy = 'u.' . $orderBy; $orderBy = 'u.' . $orderBy;
break; break;

View File

@@ -135,6 +135,11 @@ final class DateTimeFactory
return new DateTime($datetime, $this->getTimezone()); return new DateTime($datetime, $this->getTimezone());
} }
public function create(string $datetime = 'now'): \DateTimeImmutable
{
return new \DateTimeImmutable($datetime, $this->getTimezone());
}
/** /**
* @param string $format * @param string $format
* @param null|string $datetime * @param null|string $datetime

View File

@@ -25,7 +25,7 @@ final class RateService implements RateServiceInterface
public function calculate(Timesheet $record): Rate public function calculate(Timesheet $record): Rate
{ {
if (null === $record->getEnd()) { if ($record->isRunning()) {
return new Rate(0.00, 0.00); return new Rate(0.00, 0.00);
} }

View File

@@ -65,7 +65,7 @@ final class DefaultMode extends AbstractTrackingMode
$this->rounding->roundBegin($timesheet); $this->rounding->roundBegin($timesheet);
if (null !== $timesheet->getEnd()) { if (!$timesheet->isRunning()) {
$this->rounding->roundEnd($timesheet); $this->rounding->roundEnd($timesheet);
if (null !== $timesheet->getDuration()) { if (null !== $timesheet->getDuration()) {

View File

@@ -140,13 +140,13 @@ final class TimesheetBasicValidator extends ConstraintValidator
} }
if (null !== $timesheetEnd) { if (null !== $timesheetEnd) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) { if (null !== $projectEnd && $timesheetEnd > $projectEnd) {
$context->buildViolation(TimesheetBasic::getErrorName(TimesheetBasic::PROJECT_ALREADY_ENDED)) $context->buildViolation(TimesheetBasic::getErrorName(TimesheetBasic::PROJECT_ALREADY_ENDED))
->atPath($pathEnd) ->atPath($pathEnd)
->setTranslationDomain('validators') ->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED) ->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
->addViolation(); ->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) { } elseif (null !== $projectBegin && $timesheetEnd < $projectBegin) {
$context->buildViolation(TimesheetBasic::getErrorName(TimesheetBasic::PROJECT_NOT_STARTED)) $context->buildViolation(TimesheetBasic::getErrorName(TimesheetBasic::PROJECT_NOT_STARTED))
->atPath($pathEnd) ->atPath($pathEnd)
->setTranslationDomain('validators') ->setTranslationDomain('validators')

View File

@@ -206,7 +206,7 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
private function addBudgetViolation(TimesheetBudgetUsed $constraint, Timesheet $timesheet, string $field, float $budget, float $rate): void private function addBudgetViolation(TimesheetBudgetUsed $constraint, Timesheet $timesheet, string $field, float $budget, float $rate): void
{ {
// using the locale of the assigned user is not the best solution, but allows to be independent of the request stack // using the locale of the assigned user is not the best solution, but allows to be independent of the request stack
$helper = new LocaleFormatter($this->localeService, $timesheet->getUser()->getLanguage()); $helper = new LocaleFormatter($this->localeService, $timesheet->getUser()?->getLocale() ?? 'en');
$currency = $timesheet->getProject()->getCustomer()->getCurrency(); $currency = $timesheet->getProject()->getCustomer()->getCurrency();
$free = $budget - $rate; $free = $budget - $rate;

View File

@@ -33,14 +33,9 @@ final class TimesheetDeactivatedValidator extends ConstraintValidator
$this->validateActivityAndProject($value, $this->context); $this->validateActivityAndProject($value, $this->context);
} }
/** private function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context): void
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context): void
{ {
$timesheetEnd = $timesheet->getEnd(); $newOrStarted = $timesheet->isRunning() || $timesheet->getId() === null;
$newOrStarted = null === $timesheetEnd || $timesheet->getId() === null;
if (!$newOrStarted) { if (!$newOrStarted) {
return; return;

View File

@@ -102,6 +102,10 @@ final class UserVoter extends Voter
return $subject->getId() === $user->getId() || $user->isSuperAdmin(); return $subject->getId() === $user->getId() || $user->isSuperAdmin();
} }
if ($attribute === 'supervisor' && $subject->getId() === $user->getId()) {
return $user->isSuperAdmin();
}
$permission = $attribute; $permission = $attribute;
// extend me for "team" support later on // extend me for "team" support later on

View File

@@ -63,7 +63,7 @@
{{ event.content|raw }} {{ event.content|raw }}
{% if page_setup is defined and page_setup.help is not null %} {% if page_setup is defined and page_setup.help is not null %}
<div class="float-help"> <div class="float-help">
<a href="{% if '://' in page_setup.help %}{{ page_setup.help }}{% else %}{{ page_setup.help|docu_link }}{% endif %}" target="_blank" accesskey="h"> <a href="{% if '://' in page_setup.help %}{{ page_setup.help }}{% else %}{{ page_setup.help|docu_link }}{% endif %}" target="_blank" accesskey="h" title="{{ 'help'|trans }}">
<i class="fas fa-question"></i> <i class="fas fa-question"></i>
</a> </a>
</div> </div>

View File

@@ -1,6 +1,11 @@
{% extends 'page_setup.html.twig' %} {% extends 'page_setup.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% block form_addon %}
{% set event = actions(app.user, 'contract_links', 'index', {year: year, user: user}) %}
{{ widgets.actions(event.actions, {button_class: '', large: false}) }}
{% endblock %}
{% block main %} {% block main %}
{% set withWorkHourConfiguration = user.hasWorkHourConfiguration() %} {% set withWorkHourConfiguration = user.hasWorkHourConfiguration() %}

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"> <meta content="width=device-width, initial-scale=1" name="viewport">
<link rel="stylesheet" href="{{ asset('bundles/tabler/tabler.css') }}"> <link rel="stylesheet" href="{{ asset('bundles/tabler/tabler.css') }}">
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }} {{ encore_entry_script_tags('app') }}

View File

@@ -25,7 +25,7 @@
{% endif %} {% endif %}
<div class="btn-list"> <div class="btn-list">
{% if dataTable.hasConfiguration() %} {% if dataTable.hasConfiguration() %}
<a class="btn btn-icon {{ btnClass }}" href="#" data-bs-toggle="modal" data-bs-target="#modal_{{ dataTable.getTableName() }}"> <a class="btn btn-icon {{ btnClass }}" href="#" data-bs-toggle="modal" data-bs-target="#modal_{{ dataTable.getTableName() }}" title="{{ 'modal.columns.title'|trans }}">
{{ icon('columns', true) }} {{ icon('columns', true) }}
</a> </a>
{% endif %} {% endif %}
@@ -40,7 +40,7 @@
{% set order = form_widget(form.order) %} {% set order = form_widget(form.order) %}
{% set filterCount = dataTable.getQuery().countFilter() %} {% set filterCount = dataTable.getQuery().countFilter() %}
<div class="input-group inline-search position-static"> <div class="input-group inline-search position-static">
<button type="button" class="btn {{ btnClass }} dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="false" aria-haspopup="true" aria-expanded="false" id="search-dropdown-btn"> <button type="button" class="btn {{ btnClass }} dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="false" aria-haspopup="true" aria-expanded="false" id="search-dropdown-btn" title="{{ 'search_filter'|trans }}">
{{ icon('filter', true) }} {{ icon('filter', true) }}
{% if filterCount > 0 %}<span class="badge badge-pill bg-blue ms-1 d-none d-md-inline">{{ filterCount }}</span>{% endif %} {% if filterCount > 0 %}<span class="badge badge-pill bg-blue ms-1 d-none d-md-inline">{{ filterCount }}</span>{% endif %}
</button> </button>
@@ -230,7 +230,7 @@
{% else %} {% else %}
{% if headerOptions.batchUpdate is defined %} {% if headerOptions.batchUpdate is defined %}
<input type="checkbox" id="multi_update_all" class="multiupdater form-check-input m-0 align-middle"> <input type="checkbox" id="multi_update_all" class="multiupdater form-check-input m-0 align-middle" title="{{ 'batch_table_checkbox_all'|trans }}">
{% endif %} {% endif %}
{{ headerTitle }} {{ headerTitle }}
{% if headerOptions.html_after is defined %} {% if headerOptions.html_after is defined %}
@@ -285,5 +285,5 @@
{% endmacro %} {% endmacro %}
{% macro datatable_multiupdate_row(id) %} {% macro datatable_multiupdate_row(id) %}
<input type="checkbox" name="id" value="{{ id }}" class="multi_update_single multiupdater form-check-input m-0 align-middle"> <input type="checkbox" name="id" value="{{ id }}" class="multi_update_single multiupdater form-check-input m-0 align-middle" title="{{ 'batch_table_checkbox'|trans }}">
{% endmacro %} {% endmacro %}

View File

@@ -1,5 +1,4 @@
{%- macro page_actions(actions) -%} {%- macro page_actions(actions) -%}
{% set large = true %}
{% set btnClasses = 'btn-primary' %} {% set btnClasses = 'btn-primary' %}
{% set helpClasses = '' %} {% set helpClasses = '' %}
{% if tabler_bundle.isNavbarOverlapping() %} {% if tabler_bundle.isNavbarOverlapping() %}
@@ -11,30 +10,7 @@
{% set help = null %} {% set help = null %}
<div class="page-actions"> <div class="page-actions">
<div class="pa-desktop d-none d-sm-inline-flex btn-list"> <div class="pa-desktop d-none d-sm-inline-flex btn-list">
{%- for icon, values in actions %} {{ _self.actions(actions, {large: true, button_class: btnClasses}) }}
{% if 'help' in icon %}
{% set help = values %}
{% elseif 'divider' in icon and values is null %}
{# what to do here ? #}
{% else %}
{% if values.children is defined and values.children|length > 0 %}
<div class="dropdown">
<button type="button" class="btn {{ btnClasses}} dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon(icon, true) }}{% if large %} {{ values.title is defined ? values.title|trans : icon|trans({}, 'actions') }}{% endif %}
</button>
<div class="dropdown-menu">
{%- for icon, values in values.children %}
{% set values = values|merge({class: 'dropdown-item action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{{ _self.action_button(false, values, false) }}
{% endfor %}
</div>
</div>
{% else %}
{% set values = values|merge({combined: large, class: btnClasses ~ ' action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{{ _self.action_button(icon, values) }}
{% endif %}
{% endif %}
{% endfor -%}
{% if help is not null %} {% if help is not null %}
{% set help = help|merge({class: helpClasses ~ ' action-help ' ~ (help.class|default(''))}) %} {% set help = help|merge({class: helpClasses ~ ' action-help ' ~ (help.class|default(''))}) %}
{{ _self.action_button('help', help) }} {{ _self.action_button('help', help) }}
@@ -52,6 +28,35 @@
</div> </div>
{%- endmacro -%} {%- endmacro -%}
{%- macro actions(actions, options) -%}
{% set large = options.large ?? true %}
{% set btnClasses = options.button_class ?? 'btn-primary' %}
{%- for icon, values in actions %}
{% if 'help' in icon %}
{% set help = values %}
{% elseif 'divider' in icon and values is null %}
{# what to do here ? #}
{% else %}
{% if values.children is defined and values.children|length > 0 %}
<div class="dropdown">
<button type="button" class="btn {{ btnClasses }} dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon(icon, true) }}{% if large %} {{ values.title is defined ? values.title|trans : icon|trans({}, 'actions') }}{% endif %}
</button>
<div class="dropdown-menu">
{%- for icon, values in values.children %}
{% set values = values|merge({class: 'dropdown-item action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{{ _self.action_button(false, values, false) }}
{% endfor %}
</div>
</div>
{% else %}
{% set values = values|merge({combined: large, class: btnClasses ~ ' action-' ~ icon ~ ' ' ~ (values.class|default(''))}) %}
{{ _self.action_button(icon, values) }}
{% endif %}
{% endif %}
{% endfor -%}
{%- endmacro -%}
{% macro page_header(title) %} {% macro page_header(title) %}
<h2 class="page-header">{{ title|trans }}</h2> <h2 class="page-header">{{ title|trans }}</h2>
{% endmacro %} {% endmacro %}

View File

@@ -21,6 +21,7 @@
{{ form_widget(field) }} {{ form_widget(field) }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% block form_addon %}{% endblock %}
</div> </div>
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>

View File

@@ -1,6 +1,6 @@
{% if is_granted('start_own_timesheet') %} {% if is_granted('start_own_timesheet') %}
<div class="nav-item d-flex me-1 recent-activities"> <div class="nav-item d-flex me-1 recent-activities">
<a href="{{ path('favorites_timesheets') }}" class="nav-link px-0 remote-modal-load recent-activities-btn" data-modal-title="{{ 'recent.activities'|trans }}" tabindex="-1" aria-label="{{ 'recent.activities'|trans }}"> <a href="{{ path('favorites_timesheets') }}" class="nav-link px-0 remote-modal-load recent-activities-btn" data-modal-title="{{ 'recent.activities'|trans }}" tabindex="-1" title="{{ 'recent.activities'|trans }}">
{{ icon('repeat', true) }} {{ icon('repeat', true) }}
</a> </a>
</div> </div>

View File

@@ -12,13 +12,13 @@
{% endif %} {% endif %}
{% set entry = active_timesheets[0] %} {% set entry = active_timesheets[0] %}
<div class="ticktac-menu" data-api="{{ path('active_timesheet') }}" data-href="{{ path('stop_timesheet', {'id' : '000'}) }}" style="{% if not hasActiveRecords %}display:none{% endif %}"> <div class="ticktac-menu" data-api="{{ path('active_timesheet') }}" data-href="{{ path('stop_timesheet', {'id' : '000'}) }}" style="{% if not hasActiveRecords %}display:none{% endif %}">
<a data-replacer="url" class="api-link ticktac-running ticktac-stop btn {{ class }} btn-icon px-sm-2" href="#" data-href="{{ path('stop_timesheet', {'id' : entry.id}) }}" data-event="kimai.timesheetStop kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.stop.error" data-msg-success="timesheet.stop.success"{% if hasActiveRecords %} accesskey="s"{% endif %}> <a title="{{ 'timesheet.stop'|trans }}" data-replacer="url" class="api-link ticktac-running ticktac-stop btn {{ class }} btn-icon px-sm-2" href="#" data-href="{{ path('stop_timesheet', {'id' : entry.id}) }}" data-event="kimai.timesheetStop kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.stop.error" data-msg-success="timesheet.stop.success"{% if hasActiveRecords %} accesskey="s"{% endif %}>
<i class="text-red {{ 'stop-small'|icon(false) }} me-0 me-sm-1"></i> <i class="text-red {{ 'stop-small'|icon(false) }} me-0 me-sm-1"></i>
<span class="d-none d-sm-block" data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry is iterable ? 0|duration : entry|duration }}</span> <span class="d-none d-sm-block" data-replacer="duration" data-title="true" data-since="{{ entry.begin is null ? '' : entry.begin|date_format(constant('DATE_ISO8601')) }}">{{ entry is iterable ? 0|duration : entry|duration }}</span>
</a> </a>
</div> </div>
<div class="ticktac-menu-empty" style="{% if hasActiveRecords %}display:none{% endif %}"> <div class="ticktac-menu-empty" style="{% if hasActiveRecords %}display:none{% endif %}">
<a href="{{ path('timesheet_create') }}" class="modal-ajax-form ticktac-start btn {{ class }} btn-icon px-sm-2" accesskey="n"> <a title="{{ 'timesheet.start'|trans }}" href="{{ path('timesheet_create') }}" class="modal-ajax-form ticktac-start btn {{ class }} btn-icon px-sm-2" accesskey="n">
<i class="text-green {{ 'start'|icon(false) }} me-0 me-sm-1"></i> <i class="text-green {{ 'start'|icon(false) }} me-0 me-sm-1"></i>
<span class="d-none d-sm-block">{{ 0|duration }}</span> <span class="d-none d-sm-block">{{ 0|duration }}</span>
</a> </a>

View File

@@ -1,7 +1,7 @@
{% set user_shortcuts = user_shortcuts(app.user) %} {% set user_shortcuts = user_shortcuts(app.user) %}
{% if user_shortcuts|length > 0 %} {% if user_shortcuts|length > 0 %}
<div class="nav-item dropdown d-flex me-1 user-shortcuts"> <div class="nav-item dropdown d-flex me-1 user-shortcuts">
<a class="nav-link px-0 user-shortcuts-toggle" href="javascript:void(0);" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="true"> <a class="nav-link px-0 user-shortcuts-toggle" href="javascript:void(0);" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="true" title="{{ 'favorite_routes'|trans }}">
{{ icon('far fa-bookmark', true) }} {{ icon('far fa-bookmark', true) }}
</a> </a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow dropdown-menu-card" data-bs-popper="static"> <div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow dropdown-menu-card" data-bs-popper="static">

View File

@@ -33,6 +33,8 @@
{{ widgets.badge_team_access(entry.teams) }} {{ widgets.badge_team_access(entry.teams) }}
{% elseif column == 'active' %} {% elseif column == 'active' %}
{{ widgets.label_visible(entry.enabled) }} {{ widgets.label_visible(entry.enabled) }}
{% elseif column == 'system_account' %}
{{ widgets.label_boolean(entry.systemAccount) }}
{% elseif column == 'actions' %} {% elseif column == 'actions' %}
{{ actions.user(entry, 'index') }} {{ actions.user(entry, 'index') }}
{% elseif column starts with 'mf_' %} {% elseif column starts with 'mf_' %}

View File

@@ -1,5 +1,5 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %} {% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% import "macros/widgets.html.twig" as widgets %} {% from '@theme/components/buttons.html.twig' import action_cardtoolbutton %}
{% block box_title %} {% block box_title %}
{% if not title is empty %}{{ title|trans }}{% endif %} {% if not title is empty %}{{ title|trans }}{% endif %}
{% endblock %} {% endblock %}
@@ -23,8 +23,8 @@
{% else %} {% else %}
{% set nextWeek = nextWeek + 1 %} {% set nextWeek = nextWeek + 1 %}
{% endif %} {% endif %}
{{ widgets.card_tool_button('left', {'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': prevYear, 'week': prevWeek})}) }} {{ action_cardtoolbutton('left', {'title': 'stats.workingTimeWeekShort'|trans({'%week%': (prevWeek ~ '/' ~ prevYear)}), 'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': prevYear, 'week': prevWeek})}) }}
{{ widgets.card_tool_button('right', {'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': nextYear, 'week': nextWeek})}) }} {{ action_cardtoolbutton('right', {'title': 'stats.workingTimeWeekShort'|trans({'%week%': (nextWeek ~ '/' ~ nextYear)}), 'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': nextYear, 'week': nextWeek})}) }}
{% endblock %} {% endblock %}
{% block box_body %} {% block box_body %}
{{ render_widget('DailyWorkingTimeChart', options|merge({'begin': data.begin, 'end': data.end})) }} {{ render_widget('DailyWorkingTimeChart', options|merge({'begin': data.begin, 'end': data.end})) }}

View File

@@ -17,7 +17,7 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
*/ */
class LayoutControllerTest extends ControllerBaseTest class LayoutControllerTest extends ControllerBaseTest
{ {
public function testNavigationMenus() public function testNavigationMenus(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -30,18 +30,18 @@ class LayoutControllerTest extends ControllerBaseTest
$this->assertHasNavigation($client); $this->assertHasNavigation($client);
} }
protected function assertHasMainHeader(HttpKernelBrowser $client, User $user) protected function assertHasMainHeader(HttpKernelBrowser $client, User $user): void
{ {
$content = $client->getResponse()->getContent(); $content = $client->getResponse()->getContent();
$this->assertStringContainsString('data-bs-toggle="dropdown" aria-label="Open user menu"', $content); $this->assertStringContainsString('data-bs-toggle="dropdown" aria-label="Open personal menu"', $content);
$this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '"', $content); $this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '"', $content);
$this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '/edit"', $content); $this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '/edit"', $content);
$this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '/prefs"', $content); $this->assertStringContainsString('href="/en/profile/' . $user->getUserIdentifier() . '/prefs"', $content);
$this->assertStringContainsString('href="/en/logout?_csrf_token=', $content); $this->assertStringContainsString('href="/en/logout?_csrf_token=', $content);
} }
protected function assertHasNavigation(HttpKernelBrowser $client) protected function assertHasNavigation(HttpKernelBrowser $client): void
{ {
$content = $client->getResponse()->getContent(); $content = $client->getResponse()->getContent();
@@ -52,7 +52,7 @@ class LayoutControllerTest extends ControllerBaseTest
$this->assertStringContainsString('Calendar', $content); $this->assertStringContainsString('Calendar', $content);
} }
public function testActiveEntries() public function testActiveEntries(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -61,6 +61,6 @@ class LayoutControllerTest extends ControllerBaseTest
$content = $client->getResponse()->getContent(); $content = $client->getResponse()->getContent();
$this->assertStringContainsString('<a href="/en/timesheet/create" class="modal-ajax-form ticktac-start btn', $content); $this->assertStringContainsString('<a title="Start time-tracking" href="/en/timesheet/create" class="modal-ajax-form ticktac-start btn', $content);
} }
} }

View File

@@ -34,7 +34,7 @@ class ProjectQueryTest extends BaseQueryTest
self::assertNull($sut->getGlobalActivities()); self::assertNull($sut->getGlobalActivities());
} }
public function testSetter() public function testSetter(): void
{ {
$sut = new ProjectQuery(); $sut = new ProjectQuery();

View File

@@ -9,7 +9,6 @@
namespace App\Tests\Repository\Query; namespace App\Tests\Repository\Query;
use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery; use App\Repository\Query\UserFormTypeQuery;
/** /**
@@ -24,22 +23,4 @@ class UserFormTypeQueryTest extends BaseFormTypeQueryTest
$this->assertBaseQuery($sut); $this->assertBaseQuery($sut);
} }
public function testUsersAreAlwaysIncluded()
{
$sut = new UserFormTypeQuery();
$user = new User();
$user->setUserIdentifier('foo');
$users = [$user, new User(), new User()];
self::assertEquals([], $sut->getUsersAlwaysIncluded());
$sut->setUsersAlwaysIncluded($users);
self::assertSame($users, $sut->getUsersAlwaysIncluded());
self::assertEquals([], $sut->getUsersToIgnore());
self::assertInstanceOf(UserFormTypeQuery::class, $sut->addUserToIgnore($users[0]));
self::assertSame([$users[0]], $sut->getUsersToIgnore());
}
} }

View File

@@ -21,22 +21,22 @@ class UserQueryTest extends BaseQueryTest
public function testQuery(): void public function testQuery(): void
{ {
$sut = new UserQuery(); $sut = new UserQuery();
$this->assertBaseQuery($sut, 'user'); $this->assertBaseQuery($sut, 'username');
$this->assertInstanceOf(VisibilityInterface::class, $sut); $this->assertInstanceOf(VisibilityInterface::class, $sut);
$this->assertRole($sut); $this->assertRole($sut);
$this->assertSearchTeam($sut); $this->assertSearchTeam($sut);
$this->assertResetByFormError(new UserQuery(), 'user'); $this->assertResetByFormError(new UserQuery(), 'username');
} }
protected function assertRole(UserQuery $sut) protected function assertRole(UserQuery $sut): void
{ {
$this->assertNull($sut->getRole()); $this->assertNull($sut->getRole());
$sut->setRole('ROLE_USER'); $sut->setRole('ROLE_USER');
$this->assertEquals('ROLE_USER', $sut->getRole()); $this->assertEquals('ROLE_USER', $sut->getRole());
} }
protected function assertSearchTeam(UserQuery $sut) protected function assertSearchTeam(UserQuery $sut): void
{ {
$team = new Team('foo'); $team = new Team('foo');
$this->assertIsArray($sut->getSearchTeams()); $this->assertIsArray($sut->getSearchTeams());
@@ -46,7 +46,7 @@ class UserQueryTest extends BaseQueryTest
$this->assertSame($team, $sut->getSearchTeams()[0]); $this->assertSame($team, $sut->getSearchTeams()[0]);
} }
public function testSystemAccount() public function testSystemAccount(): void
{ {
$sut = new UserQuery(); $sut = new UserQuery();
self::assertNull($sut->getSystemAccount()); self::assertNull($sut->getSystemAccount());

View File

@@ -2262,26 +2262,6 @@ parameters:
count: 1 count: 1
path: Controller/InvoiceControllerTest.php path: Controller/InvoiceControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\LayoutControllerTest\\:\\:assertHasMainHeader\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/LayoutControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\LayoutControllerTest\\:\\:assertHasNavigation\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/LayoutControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\LayoutControllerTest\\:\\:testActiveEntries\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/LayoutControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\LayoutControllerTest\\:\\:testNavigationMenus\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/LayoutControllerTest.php
- -
message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#" message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 11 count: 11
@@ -6912,36 +6892,11 @@ parameters:
count: 1 count: 1
path: Repository/Query/ExportQueryTest.php path: Repository/Query/ExportQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\ProjectQueryTest\\:\\:testSetter\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/Query/ProjectQueryTest.php
- -
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\TeamQueryTest\\:\\:assertUsers\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\TeamQueryTest\\:\\:assertUsers\\(\\) has no return type specified\\.$#"
count: 1 count: 1
path: Repository/Query/TeamQueryTest.php path: Repository/Query/TeamQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\UserFormTypeQueryTest\\:\\:testUsersAreAlwaysIncluded\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/Query/UserFormTypeQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\UserQueryTest\\:\\:assertRole\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/Query/UserQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\UserQueryTest\\:\\:assertSearchTeam\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/Query/UserQueryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\Query\\\\UserQueryTest\\:\\:testSystemAccount\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/Query/UserQueryTest.php
- -
message: "#^Method App\\\\Tests\\\\Repository\\\\Result\\\\TimesheetResultStatisticTest\\:\\:testConstruct\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Repository\\\\Result\\\\TimesheetResultStatisticTest\\:\\:testConstruct\\(\\) has no return type specified\\.$#"
count: 1 count: 1

View File

@@ -42,6 +42,22 @@
<source>reset.intro</source> <source>reset.intro</source>
<target>… Du hast also dein Passwort vergessen? Keine Sorge: Kimai wird dir dabei helfen, ein neues zu erstellen:</target> <target>… Du hast also dein Passwort vergessen? Keine Sorge: Kimai wird dir dabei helfen, ein neues zu erstellen:</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source>
<target>Neue Abwesenheit | %absence_user% | %absence_type%</target>
</trans-unit>
<trans-unit id="wwLx._J" resname="absence_created_supervisor_message" xml:space="preserve">
<source>absence_created_supervisor_message</source>
<target>
Für %absence_user% wurde eine neue Abwesenheit angelegt.
%absence_type% - %absence_comment%
%absence_list%
Bitte überprüfen Sie diese unter: %approval_url%
</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -42,6 +42,22 @@
<source>reset.intro</source> <source>reset.intro</source>
<target>… so you forgot your password? Don't worry: Kimai will help you to create a new one:</target> <target>… so you forgot your password? Don't worry: Kimai will help you to create a new one:</target>
</trans-unit> </trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source>
<target>New absence | %absence_user% | %absence_type%</target>
</trans-unit>
<trans-unit id="wwLx._J" resname="absence_created_supervisor_message" xml:space="preserve">
<source>absence_created_supervisor_message</source>
<target>
A new absence was created for %absence_user%.
%absence_type% - %absence_comment%
%absence_list%
Please review them at: %approval_url%
</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -58,6 +58,10 @@
<source>search</source> <source>search</source>
<target>Suchen</target> <target>Suchen</target>
</trans-unit> </trans-unit>
<trans-unit id="op76XPd" resname="search_filter">
<source>Search filter</source>
<target>Suchfilter</target>
</trans-unit>
<trans-unit id="8W_TZI0" resname="searchTerm"> <trans-unit id="8W_TZI0" resname="searchTerm">
<source>searchTerm</source> <source>searchTerm</source>
<target>Suchbegriff</target> <target>Suchbegriff</target>
@@ -1328,7 +1332,11 @@
</trans-unit> </trans-unit>
<trans-unit id="dTHY7Mw" resname="timesheet.start"> <trans-unit id="dTHY7Mw" resname="timesheet.start">
<source>timesheet.start</source> <source>timesheet.start</source>
<target>Neue Zeitmessung starten</target> <target>Zeiterfassung starten</target>
</trans-unit>
<trans-unit id="mR_Y5Mv" resname="timesheet.stop">
<source>timesheet.stop</source>
<target>Zeiterfassung stoppen</target>
</trans-unit> </trans-unit>
<!-- TOOLBARS --> <!-- TOOLBARS -->
<trans-unit id="igCkqAP" resname="pageSize"> <trans-unit id="igCkqAP" resname="pageSize">
@@ -1395,6 +1403,14 @@
<source>help.batch_meta_fields</source> <source>help.batch_meta_fields</source>
<target>Felder die aktualisiert werden sollen, müssen zunächst durch einen Klick auf die zugehörige Checkbox freigeschaltet werden.</target> <target>Felder die aktualisiert werden sollen, müssen zunächst durch einen Klick auf die zugehörige Checkbox freigeschaltet werden.</target>
</trans-unit> </trans-unit>
<trans-unit id="f1Cgptu" resname="batch_table_checkbox">
<source>batch_table_checkbox</source>
<target>Eintrag zur Batch-Update-Liste hinzufügen</target>
</trans-unit>
<trans-unit id="ekN4GLW" resname="batch_table_checkbox_all">
<source>batch_table_checkbox_all</source>
<target>Alle Einträge für Batch-Update-Liste auswählen</target>
</trans-unit>
<trans-unit id="L27KvC9" resname="includeNoWork"> <trans-unit id="L27KvC9" resname="includeNoWork">
<source>includeNoWork</source> <source>includeNoWork</source>
<target>Einträge ohne Buchungen anzeigen</target> <target>Einträge ohne Buchungen anzeigen</target>

View File

@@ -58,6 +58,10 @@
<source>search</source> <source>search</source>
<target>Search</target> <target>Search</target>
</trans-unit> </trans-unit>
<trans-unit id="op76XPd" resname="search_filter">
<source>Search filter</source>
<target>Search filter</target>
</trans-unit>
<trans-unit id="8W_TZI0" resname="searchTerm"> <trans-unit id="8W_TZI0" resname="searchTerm">
<source>searchTerm</source> <source>searchTerm</source>
<target>Search term</target> <target>Search term</target>
@@ -1328,7 +1332,11 @@
</trans-unit> </trans-unit>
<trans-unit id="dTHY7Mw" resname="timesheet.start"> <trans-unit id="dTHY7Mw" resname="timesheet.start">
<source>timesheet.start</source> <source>timesheet.start</source>
<target>Create new time-record</target> <target>Start time-tracking</target>
</trans-unit>
<trans-unit id="mR_Y5Mv" resname="timesheet.stop">
<source>timesheet.stop</source>
<target>Stop time-tracking</target>
</trans-unit> </trans-unit>
<!-- TOOLBARS --> <!-- TOOLBARS -->
<trans-unit id="igCkqAP" resname="pageSize"> <trans-unit id="igCkqAP" resname="pageSize">
@@ -1395,6 +1403,14 @@
<source>help.batch_meta_fields</source> <source>help.batch_meta_fields</source>
<target>Fields that are to be updated must first be activated by clicking on the associated checkbox.</target> <target>Fields that are to be updated must first be activated by clicking on the associated checkbox.</target>
</trans-unit> </trans-unit>
<trans-unit id="f1Cgptu" resname="batch_table_checkbox">
<source>batch_table_checkbox</source>
<target>Add entry to batch update list</target>
</trans-unit>
<trans-unit id="ekN4GLW" resname="batch_table_checkbox_all">
<source>batch_table_checkbox_all</source>
<target>Select all entries for batch update list</target>
</trans-unit>
<trans-unit id="L27KvC9" resname="includeNoWork"> <trans-unit id="L27KvC9" resname="includeNoWork">
<source>includeNoWork</source> <source>includeNoWork</source>
<target>Show entries without bookings</target> <target>Show entries without bookings</target>