Only one running timesheet: automatically stop others (#386)

This commit is contained in:
Lukas
2019-01-21 12:49:22 +01:00
committed by Kevin Papst
parent 5539f68a54
commit 47ac50b4d0
18 changed files with 215 additions and 77 deletions

View File

@@ -35,6 +35,18 @@ kimai:
# days: ['saturday','sunday'] # days: ['saturday','sunday']
# factor: 1.5 # 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 management
#invoice: #invoice:
@@ -147,8 +159,6 @@ kimai:
# BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed # 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 # into a searchable and javascript enhanced input type
select_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 # fallback color for all widgets that don't have a dedicated color
# possible options: blue, black, purple, yellow, red, green # possible options: blue, black, purple, yellow, red, green
box_color: '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 } 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 } activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, 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

View File

@@ -14,7 +14,7 @@ services:
# The best practice is to be explicit about your dependencies anyway. # The best practice is to be explicit about your dependencies anyway.
bind: bind:
$projectDirectory: "%kernel.project_dir%" $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 # makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name # this creates a service per class whose id is the fully-qualified class name
@@ -93,6 +93,10 @@ services:
tags: tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType } - { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
App\Form\TimesheetEditForm:
arguments:
$durationOnly: "%kimai.timesheet.duration_only%"
# ================================================================================ # ================================================================================
# THEME # THEME
# ================================================================================ # ================================================================================

View File

@@ -41,14 +41,21 @@ class TimesheetController extends BaseApiController
*/ */
protected $viewHandler; protected $viewHandler;
/**
* @var int
*/
protected $hardLimit;
/** /**
* @param ViewHandlerInterface $viewHandler * @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository * @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->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
$this->hardLimit = $hardLimit;
} }
/** /**
@@ -161,8 +168,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false, 'csrf_protection' => false,
'method' => 'POST', 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'duration_only' => false,
]); ]);
$form->setData($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); 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 = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet); $entityManager->persist($timesheet);
$entityManager->flush(); $entityManager->flush();

View File

@@ -42,15 +42,7 @@ abstract class AbstractController extends Controller
*/ */
protected function flashSuccess($translationKey, $parameter = []) protected function flashSuccess($translationKey, $parameter = [])
{ {
if (!empty($parameter)) { $this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_SUCCESS, $translationKey);
} }
/** /**
@@ -61,15 +53,7 @@ abstract class AbstractController extends Controller
*/ */
protected function flashWarning($translationKey, $parameter = []) protected function flashWarning($translationKey, $parameter = [])
{ {
if (!empty($parameter)) { $this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_WARNING, $translationKey);
} }
/** /**
@@ -79,15 +63,30 @@ abstract class AbstractController extends Controller
* @param array $parameter * @param array $parameter
*/ */
protected function flashError($translationKey, $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)) { if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans( foreach ($parameter as $key => $value) {
$translationKey, $parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
}
$message = $this->getTranslator()->trans(
$message,
$parameter, $parameter,
self::DOMAIN_FLASH self::DOMAIN_FLASH
); );
} }
$this->addFlash(self::FLASH_ERROR, $translationKey); $this->addFlash($type, $message);
} }
} }

View File

@@ -30,15 +30,6 @@ class TimesheetController extends AbstractController
{ {
use TimesheetControllerTrait; 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="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", 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, [ return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create'), 'action' => $this->generateUrl('admin_timesheet_create'),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true, 'include_user' => true,
]); ]);
@@ -198,8 +187,6 @@ class TimesheetController extends AbstractController
'id' => $entry->getId(), 'id' => $entry->getId(),
'page' => $page 'page' => $page
]), ]),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true, 'include_user' => true,
]); ]);

View File

@@ -30,11 +30,11 @@ class TimesheetController extends AbstractController
use TimesheetControllerTrait; 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 { } else {
$entityManager = $this->getDoctrine()->getManager(); $entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry); $entityManager->persist($entry);
$this->stopActiveEntries($user);
$entityManager->flush(); $entityManager->flush();
$this->flashSuccess('timesheet.start.success'); $this->flashSuccess('timesheet.start.success');
} }
@@ -238,9 +240,7 @@ class TimesheetController extends AbstractController
{ {
return $this->createForm(TimesheetEditForm::class, $entry, [ return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_create'), 'action' => $this->generateUrl('timesheet_create'),
'method' => 'POST',
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'duration_only' => $this->isDurationOnlyMode(),
]); ]);
} }
@@ -256,9 +256,7 @@ class TimesheetController extends AbstractController
'id' => $entry->getId(), 'id' => $entry->getId(),
'page' => $page 'page' => $page
]), ]),
'method' => 'POST',
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'duration_only' => $this->isDurationOnlyMode(),
]); ]);
} }

View File

@@ -10,6 +10,7 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -22,24 +23,24 @@ use Symfony\Component\HttpFoundation\Response;
trait TimesheetControllerTrait 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 = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
try {
$this->stopActiveEntries($entry->getUser());
$entityManager->persist($entry);
$entityManager->flush(); $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); 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 * @param Timesheet $entry
* @return \Symfony\Component\Form\FormInterface * @return \Symfony\Component\Form\FormInterface

View File

@@ -118,6 +118,8 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.timesheet.rounding', $config['rounding']); $container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']); $container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['markdown_content']); $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']);
} }
/** /**

View File

@@ -27,7 +27,9 @@ class TwigContextCompilerPass implements CompilerPassInterface
$theme = $container->getParameter('kimai.theme'); $theme = $container->getParameter('kimai.theme');
$durationOnly = $container->getParameter('kimai.timesheet.duration_only'); $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]); $twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]);
if ($container->hasDefinition('twig.loader.native_filesystem')) { if ($container->hasDefinition('twig.loader.native_filesystem')) {

View File

@@ -85,7 +85,6 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->defaultValue([]) ->defaultValue([])
->end() ->end()
->arrayNode('rates') ->arrayNode('rates')
->requiresAtLeastOneElement() ->requiresAtLeastOneElement()
->useAttributeAsKey('key') ->useAttributeAsKey('key')
@@ -112,6 +111,30 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->defaultValue([]) ->defaultValue([])
->end() ->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() ->end()
; ;
@@ -212,6 +235,7 @@ class Configuration implements ConfigurationInterface
->children() ->children()
->integerNode('active_warning') ->integerNode('active_warning')
->defaultValue(3) ->defaultValue(3)
->setDeprecated('The node "%node%" at path "%path%" is deprecated, please use "kimai.timesheet.active_entries.soft_limit" instead.')
->end() ->end()
->scalarNode('box_color') ->scalarNode('box_color')
->defaultValue('green') ->defaultValue('green')

View File

@@ -36,19 +36,27 @@ class TimesheetEditForm extends AbstractType
* @var CustomerRepository * @var CustomerRepository
*/ */
private $customers; private $customers;
/** /**
* @var ProjectRepository * @var ProjectRepository
*/ */
private $projects; private $projects;
/**
* @var bool
*/
private $durationOnly = false;
/** /**
* @param CustomerRepository $customer * @param CustomerRepository $customer
* @param ProjectRepository $project * @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->customers = $customer;
$this->projects = $project; $this->projects = $project;
$this->durationOnly = $durationOnly;
} }
/** /**
@@ -227,10 +235,11 @@ class TimesheetEditForm extends AbstractType
'csrf_protection' => true, 'csrf_protection' => true,
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_edit', 'csrf_token_id' => 'timesheet_edit',
'duration_only' => false, 'duration_only' => $this->durationOnly,
'include_user' => false, 'include_user' => false,
'include_rate' => true, 'include_rate' => true,
'docu_chapter' => 'timesheet', 'docu_chapter' => 'timesheet',
'method' => 'POST',
]); ]);
} }
} }

View File

@@ -260,6 +260,42 @@ class TimesheetRepository extends AbstractRepository
return $qb->getQuery()->execute($params); 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 * @param TimesheetQuery $query
* @return QueryBuilder|Pagerfanta|array * @return QueryBuilder|Pagerfanta|array

View File

@@ -2,7 +2,7 @@
<li class="dropdown messages-menu"> <li class="dropdown messages-menu">
<a href="#" class="dropdown-toggle ddt-large ticktac" data-toggle="dropdown"> <a href="#" class="dropdown-toggle ddt-large ticktac" data-toggle="dropdown">
<i class="{{ 'start'|icon }} fa-2x"></i> <i class="{{ 'start'|icon }} fa-2x"></i>
<span class="label label-{% if entries|length >= kimai_context.active_warning %}danger{% else %}warning{% endif %}">{{ entries|length }}</span> <span class="label label-{% if entries|length > kimai_context.active_warning %}danger{% else %}warning{% endif %}">{{ entries|length }}</span>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li class="header"> <li class="header">

View File

@@ -66,7 +66,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
public function testPostAction() public function testPostAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [ $data = [
'activity' => 1, 'activity' => 1,
'project' => 1, 'project' => 1,

View File

@@ -18,6 +18,10 @@
<source>timesheet.start.error</source> <source>timesheet.start.error</source>
<target>Zeitmessung konnte nicht gestartet werden: %reason%</target> <target>Zeitmessung konnte nicht gestartet werden: %reason%</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>
<target>das Limit aktiver Zeitmessungen wurde erreicht, bitte stoppen Sie zunächst laufen Zeitmessungen</target>
</trans-unit>
<trans-unit id="action.update.success"> <trans-unit id="action.update.success">
<source>action.update.success</source> <source>action.update.success</source>
<target>Änderungen erfolgreich gespeichert</target> <target>Änderungen erfolgreich gespeichert</target>

View File

@@ -4,19 +4,23 @@
<body> <body>
<trans-unit id="timesheet.stop.success"> <trans-unit id="timesheet.stop.success">
<source>timesheet.stop.success</source> <source>timesheet.stop.success</source>
<target>Time-recording was stopped</target> <target>Time recording was stopped</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.stop.error"> <trans-unit id="timesheet.stop.error">
<source>timesheet.stop.error</source> <source>timesheet.stop.error</source>
<target>Time-recording could not be stopped: %reason%</target> <target>Time recording could not be stopped: %reason%</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.start.success"> <trans-unit id="timesheet.start.success">
<source>timesheet.start.success</source> <source>timesheet.start.success</source>
<target>Time-recording was started</target> <target>Time recording was started</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.start.error"> <trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source> <source>timesheet.start.error</source>
<target>Time-recording could not be started: %reason%</target> <target>Time recording could not be started: %reason%</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>
<target>the limit of active time records has been reached, please stop running time measurements first</target>
</trans-unit> </trans-unit>
<trans-unit id="action.update.success"> <trans-unit id="action.update.success">
<source>action.update.success</source> <source>action.update.success</source>

View File

@@ -134,6 +134,26 @@ admin_lte:
## Timesheets (kimai.yaml) ## Timesheets (kimai.yaml)
### Limit active entries
To limit the amount of active entries each user can have, the configuration `active_entries` can be changed:
```yaml
kimai:
timesheet:
active_entries:
soft_limit: 1
hard_limit: 3
```
The `soft_limit` is used as theme setting (formerly "kimai.theme.active_warning") to display a warning 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 3 active time-records are allowed).
If `hard_limit` is 1, the active record is automatically stopped when a new one is started.
When `hard_limit` is greater than 1 and as soon as the limit is reached, the user has to manually stop at least one active
entry (an error message is shown, indicating why it is not possible to start another one).
### Descriptions with Markdown ### Descriptions with Markdown
The description for every timesheet entry can be formatted in two different ways, configured with the `markdown_content` setting. The description for every timesheet entry can be formatted in two different ways, configured with the `markdown_content` setting.

View File

@@ -29,15 +29,9 @@ Therefor your feedback is highly welcome, please post your opinion at GitHub.
## Active entries warning ## Active entries warning
A small colored warning sign will be shown, if a user has more than 3 active timesheet entries. A small colored warning sign will be shown, if a user has more than X active timesheet entries.
You can change this soft limit by setting the config key `kimai.theme.active_warning` in your `local.yaml`: The amount `X` is configured in your `local.yaml` with the setting `timesheet.active_entries.soft_limit` (see [configurations.md](configurations.md)).
```yaml
kimai:
theme:
active_warning: 2
```
## Colors ## Colors
@@ -56,7 +50,7 @@ Possible values are:
### Fallback color ### Fallback color
Whenever a color is required but none is configured, Kimai uses a fallback language from the config key `kimai.theme.box_color`. Whenever a color is required but none is configured, Kimai uses a fallback color from the config key `kimai.theme.box_color`.
You can change the default color `green` to any one from the above in your `local.yaml`: You can change the default color `green` to any one from the above in your `local.yaml`: