diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index c713587e..ac458eb8 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -35,6 +35,18 @@ kimai: # days: ['saturday','sunday'] # factor: 1.5 + # If you want to limit the max. active entries per user, you can do it here. + # + # The soft_limit is used as theme setting (formerly "kimai.theme.active_warning"): + # display a warning color if the user has at least X active recordings + # + # The hard_limit is used to detect how many active records are allowed per user: + # By default a user can only have one active time-record. The first one is stopped when the new one starts. + # When hard_limit is > 1: as soon as the limit is reached and warning is shown and the user has to stop one active entry. + active_entries: + soft_limit: 1 + hard_limit: 3 + # -------------------------------------------------------------------------------- # Invoice management #invoice: @@ -147,8 +159,6 @@ kimai: # BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed # into a searchable and javascript enhanced input type select_type: ~ - # display a warning color if the user has at least X active recordings - active_warning: 3 # fallback color for all widgets that don't have a dedicated color # possible options: blue, black, purple, yellow, red, green box_color: 'green' @@ -211,3 +221,10 @@ kimai: activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow } activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red } activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red } + + # Default settings used to populate forms + #defaults: + # customer: + # timezone: Europe/Berlin + # country: DE + # currency: EUR \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 2c3a2e2f..ed697fc8 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,7 +14,7 @@ services: # The best practice is to be explicit about your dependencies anyway. bind: $projectDirectory: "%kernel.project_dir%" - $durationOnly: "%kimai.timesheet.duration_only%" + $hardLimit: "%kimai.timesheet.active_entries.hard_limit%" # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -93,6 +93,10 @@ services: tags: - { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType } + App\Form\TimesheetEditForm: + arguments: + $durationOnly: "%kimai.timesheet.duration_only%" + # ================================================================================ # THEME # ================================================================================ diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index f782e905..7b7396af 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -41,14 +41,21 @@ class TimesheetController extends BaseApiController */ protected $viewHandler; + /** + * @var int + */ + protected $hardLimit; + /** * @param ViewHandlerInterface $viewHandler * @param TimesheetRepository $repository + * @param int $hardLimit */ - public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository) + public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, int $hardLimit) { $this->viewHandler = $viewHandler; $this->repository = $repository; + $this->hardLimit = $hardLimit; } /** @@ -161,8 +168,7 @@ class TimesheetController extends BaseApiController $form = $this->createForm(TimesheetEditForm::class, $timesheet, [ 'csrf_protection' => false, - 'method' => 'POST', - 'duration_only' => false, + 'include_rate' => $this->isGranted('edit_rate', $timesheet), ]); $form->setData($timesheet); @@ -177,6 +183,21 @@ class TimesheetController extends BaseApiController return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST); } + if ($form->has('duration')) { + $duration = $form->get('duration')->getData(); + if ($duration > 0) { + /** @var Timesheet $record */ + $record = $form->getData(); + $end = clone $record->getBegin(); + $end->modify('+ ' . $duration . 'seconds'); + $record->setEnd($end); + } + } + + $this->repository->stopActiveEntries( + $timesheet->getUser(), + $this->hardLimit + ); $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($timesheet); $entityManager->flush(); diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index 8f2f9b9f..51f53e6d 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -42,15 +42,7 @@ abstract class AbstractController extends Controller */ protected function flashSuccess($translationKey, $parameter = []) { - if (!empty($parameter)) { - $translationKey = $this->getTranslator()->trans( - $translationKey, - $parameter, - self::DOMAIN_FLASH - ); - } - - $this->addFlash(self::FLASH_SUCCESS, $translationKey); + $this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter); } /** @@ -61,15 +53,7 @@ abstract class AbstractController extends Controller */ protected function flashWarning($translationKey, $parameter = []) { - if (!empty($parameter)) { - $translationKey = $this->getTranslator()->trans( - $translationKey, - $parameter, - self::DOMAIN_FLASH - ); - } - - $this->addFlash(self::FLASH_WARNING, $translationKey); + $this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter); } /** @@ -79,15 +63,30 @@ abstract class AbstractController extends Controller * @param array $parameter */ protected function flashError($translationKey, $parameter = []) + { + $this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter); + } + + /** + * Adds a fully translated (both $message and all keys in $parameter) flash message to the stack. + * + * @param string $type + * @param string $message + * @param array $parameter + */ + protected function addFlashTranslated(string $type, string $message, array $parameter = []) { if (!empty($parameter)) { - $translationKey = $this->getTranslator()->trans( - $translationKey, + foreach ($parameter as $key => $value) { + $parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH); + } + $message = $this->getTranslator()->trans( + $message, $parameter, self::DOMAIN_FLASH ); } - $this->addFlash(self::FLASH_ERROR, $translationKey); + $this->addFlash($type, $message); } } diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/Admin/TimesheetController.php index 3e48ccca..f3bcd2c8 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/Admin/TimesheetController.php @@ -30,15 +30,6 @@ class TimesheetController extends AbstractController { use TimesheetControllerTrait; - /** - * TimesheetController constructor. - * @param bool $durationOnly - */ - public function __construct(bool $durationOnly) - { - $this->setDurationMode($durationOnly); - } - /** * @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"}) @@ -179,8 +170,6 @@ class TimesheetController extends AbstractController { return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('admin_timesheet_create'), - 'method' => 'POST', - 'duration_only' => $this->isDurationOnlyMode(), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_user' => true, ]); @@ -198,8 +187,6 @@ class TimesheetController extends AbstractController 'id' => $entry->getId(), 'page' => $page ]), - 'method' => 'POST', - 'duration_only' => $this->isDurationOnlyMode(), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_user' => true, ]); diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index 2a13a62b..4ed8996f 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -30,11 +30,11 @@ class TimesheetController extends AbstractController use TimesheetControllerTrait; /** - * @param bool $durationOnly + * @param int $hardLimit */ - public function __construct(bool $durationOnly) + public function __construct(int $hardLimit) { - $this->setDurationMode($durationOnly); + $this->setHardLimit($hardLimit); } /** @@ -168,6 +168,8 @@ class TimesheetController extends AbstractController } else { $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($entry); + $this->stopActiveEntries($user); + $entityManager->flush(); $this->flashSuccess('timesheet.start.success'); } @@ -238,9 +240,7 @@ class TimesheetController extends AbstractController { return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('timesheet_create'), - 'method' => 'POST', 'include_rate' => $this->isGranted('edit_rate', $entry), - 'duration_only' => $this->isDurationOnlyMode(), ]); } @@ -256,9 +256,7 @@ class TimesheetController extends AbstractController 'id' => $entry->getId(), 'page' => $page ]), - 'method' => 'POST', 'include_rate' => $this->isGranted('edit_rate', $entry), - 'duration_only' => $this->isDurationOnlyMode(), ]); } diff --git a/src/Controller/TimesheetControllerTrait.php b/src/Controller/TimesheetControllerTrait.php index b6251c1d..8390aeee 100644 --- a/src/Controller/TimesheetControllerTrait.php +++ b/src/Controller/TimesheetControllerTrait.php @@ -10,6 +10,7 @@ namespace App\Controller; use App\Entity\Timesheet; +use App\Entity\User; use App\Repository\TimesheetRepository; use Doctrine\Common\Persistence\ManagerRegistry; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -22,24 +23,24 @@ use Symfony\Component\HttpFoundation\Response; trait TimesheetControllerTrait { /** - * @var bool + * @var int */ - private $durationOnly = false; + private $hardLimit = 1; /** - * @param bool $durationOnly + * @param int $hardLimit */ - protected function setDurationMode(bool $durationOnly) + protected function setHardLimit(int $hardLimit) { - $this->durationOnly = $durationOnly; + $this->hardLimit = $hardLimit; } /** - * @return bool + * @return int */ - protected function isDurationOnlyMode() + protected function getHardLimit() { - return $this->durationOnly; + return $this->hardLimit; } /** @@ -169,11 +170,16 @@ trait TimesheetControllerTrait } $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($entry); - $entityManager->flush(); + try { + $this->stopActiveEntries($entry->getUser()); + $entityManager->persist($entry); + $entityManager->flush(); - $this->flashSuccess('action.update.success'); + $this->flashSuccess('action.update.success'); + } catch (\Exception $ex) { + $this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]); + } return $this->redirectToRoute($redirectRoute); } @@ -184,6 +190,17 @@ trait TimesheetControllerTrait ]); } + /** + * @param User $user + * @throws \App\Repository\RepositoryException + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + protected function stopActiveEntries(User $user) + { + $this->getRepository()->stopActiveEntries($user, $this->getHardLimit()); + } + /** * @param Timesheet $entry * @return \Symfony\Component\Form\FormInterface diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index 1ed31399..04f1c644 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -118,6 +118,8 @@ class AppExtension extends Extension implements PrependExtensionInterface $container->setParameter('kimai.timesheet.rounding', $config['rounding']); $container->setParameter('kimai.timesheet.duration_only', $config['duration_only']); $container->setParameter('kimai.timesheet.markdown', $config['markdown_content']); + $container->setParameter('kimai.timesheet.active_entries.soft_limit', $config['active_entries']['soft_limit']); + $container->setParameter('kimai.timesheet.active_entries.hard_limit', $config['active_entries']['hard_limit']); } /** diff --git a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php index 4bfb8ec2..3ee5bbf5 100644 --- a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php +++ b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php @@ -27,7 +27,9 @@ class TwigContextCompilerPass implements CompilerPassInterface $theme = $container->getParameter('kimai.theme'); $durationOnly = $container->getParameter('kimai.timesheet.duration_only'); - $twig->addMethodCall('addGlobal', ['kimai_context', $theme]); + $twig->addMethodCall('addGlobal', ['kimai_context', array_merge($theme, [ + 'active_warning' => $container->getParameter('kimai.timesheet.active_entries.soft_limit') + ])]); $twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]); if ($container->hasDefinition('twig.loader.native_filesystem')) { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f9e4bc8f..ab12c6a2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -85,7 +85,6 @@ class Configuration implements ConfigurationInterface ->end() ->defaultValue([]) ->end() - ->arrayNode('rates') ->requiresAtLeastOneElement() ->useAttributeAsKey('key') @@ -112,6 +111,30 @@ class Configuration implements ConfigurationInterface ->end() ->defaultValue([]) ->end() + ->arrayNode('active_entries') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('soft_limit') + ->defaultValue(1) + ->validate() + ->ifTrue(function ($value) { + return $value <= 0; + }) + ->thenInvalid('The soft_limit must be at least 1') + ->end() + ->end() + ->integerNode('hard_limit') + ->defaultValue(1) + ->validate() + ->ifTrue(function ($value) { + return $value <= 0; + }) + ->thenInvalid('The hard_limit must be at least 1') + ->end() + ->end() + ->end() + ->end() + ->end() ->end() ; @@ -212,6 +235,7 @@ class Configuration implements ConfigurationInterface ->children() ->integerNode('active_warning') ->defaultValue(3) + ->setDeprecated('The node "%node%" at path "%path%" is deprecated, please use "kimai.timesheet.active_entries.soft_limit" instead.') ->end() ->scalarNode('box_color') ->defaultValue('green') diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 800ea386..93e1549d 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -36,19 +36,27 @@ class TimesheetEditForm extends AbstractType * @var CustomerRepository */ private $customers; + /** * @var ProjectRepository */ private $projects; + /** + * @var bool + */ + private $durationOnly = false; + /** * @param CustomerRepository $customer * @param ProjectRepository $project + * @param bool $durationOnly */ - public function __construct(CustomerRepository $customer, ProjectRepository $project) + public function __construct(CustomerRepository $customer, ProjectRepository $project, bool $durationOnly) { $this->customers = $customer; $this->projects = $project; + $this->durationOnly = $durationOnly; } /** @@ -227,10 +235,11 @@ class TimesheetEditForm extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'timesheet_edit', - 'duration_only' => false, + 'duration_only' => $this->durationOnly, 'include_user' => false, 'include_rate' => true, 'docu_chapter' => 'timesheet', + 'method' => 'POST', ]); } } diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index b4cf727d..6df80c47 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -260,6 +260,42 @@ class TimesheetRepository extends AbstractRepository return $qb->getQuery()->execute($params); } + /** + * @param User $user + * @param int $limit + * @return int + * @throws RepositoryException + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function stopActiveEntries(User $user, int $hardLimit) + { + $counter = 0; + $activeEntries = $this->getActiveEntries($user); + + // reduce limit by one: + // this method is only called when a new entry is started + // -> all entries, including the new one must not exceed the $limit + $limit = $hardLimit - 1; + + if (count($activeEntries) > $limit) { + $i = 1; + foreach ($activeEntries as $activeEntry) { + if ($i > $limit) { + if ($hardLimit > 1) { + throw new \Exception('timesheet.start.exceeded_limit'); + } + + $this->stopRecording($activeEntry); + $counter++; + } + $i++; + } + } + + return $counter; + } + /** * @param TimesheetQuery $query * @return QueryBuilder|Pagerfanta|array diff --git a/templates/navbar/active-entries.html.twig b/templates/navbar/active-entries.html.twig index eeab67ff..660a648d 100644 --- a/templates/navbar/active-entries.html.twig +++ b/templates/navbar/active-entries.html.twig @@ -2,7 +2,7 @@