diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 3dcfa003..633be1e1 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -1,6 +1,11 @@ kimai: + # All configs related to timesheet and record management timesheet: + # Whether we display start and end time columns (false) or durations only (true). + # Setting this to true will also change the "edit timesheet" forms, more infos available in the confiugrations docu. + duration_only: false + # Rounding rules are used to round the begin & end dates and the duration for timesheet records. # The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded. # Please read var/docs/configurations.md to find out more about rounding rules @@ -19,6 +24,7 @@ kimai: # days: ['saturday','sunday'] # factor: 1.5 + # All configs related to the invoice administration invoice: renderer: default: 'App\Controller\InvoiceController::invoiceAction' diff --git a/config/services.yaml b/config/services.yaml index 201a65a1..3001d2f3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,6 +14,7 @@ services: # The best practice is to be explicit about your dependencies anyway. bind: $projectDirectory: "%kernel.project_dir%" + $durationOnly: "%kimai.timesheet.duration_only%" # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/Admin/TimesheetController.php index 0f22a524..f55babd6 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/Admin/TimesheetController.php @@ -10,17 +10,16 @@ namespace App\Controller\Admin; use App\Controller\AbstractController; +use App\Controller\TimesheetControllerTrait; +use App\Entity\Timesheet; +use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetAdminToolbarForm; use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\Request; -use App\Controller\TimesheetControllerTrait; -use App\Entity\Customer; -use App\Entity\Timesheet; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; -use App\Form\TimesheetAdminForm; /** * Controller used for manage timesheet entries in the admin part of the site. @@ -33,6 +32,15 @@ class TimesheetController extends AbstractController { use TimesheetControllerTrait; + /** + * TimesheetController constructor. + * @param bool $durationOnly + */ + public function __construct(bool $durationOnly) + { + $this->setDurationMode($durationOnly); + } + /** * This route shows all users timesheet entries. * @@ -138,10 +146,11 @@ class TimesheetController extends AbstractController */ protected function getCreateForm(Timesheet $entry) { - return $this->createForm(TimesheetAdminForm::class, $entry, [ + return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('admin_timesheet_create'), 'method' => 'POST', - 'currency' => Customer::DEFAULT_CURRENCY, + 'duration_only' => $this->isDurationOnlyMode(), + 'include_user' => true ]); } @@ -152,13 +161,14 @@ class TimesheetController extends AbstractController */ protected function getEditForm(Timesheet $entry, $page) { - return $this->createForm(TimesheetAdminForm::class, $entry, [ + return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('admin_timesheet_edit', [ 'id' => $entry->getId(), 'page' => $page ]), 'method' => 'POST', - 'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(), + 'duration_only' => $this->isDurationOnlyMode(), + 'include_user' => true ]); } diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index e7345127..85f859ab 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -9,18 +9,17 @@ namespace App\Controller; +use App\Entity\Activity; +use App\Entity\Timesheet; +use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; -use App\Entity\Activity; -use App\Entity\Customer; -use App\Entity\Timesheet; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; use Symfony\Component\HttpFoundation\Request; -use App\Form\TimesheetEditForm; /** * Controller used to manage timesheet contents in the public part of the site. @@ -32,6 +31,15 @@ class TimesheetController extends AbstractController { use TimesheetControllerTrait; + /** + * TimesheetController constructor. + * @param bool $durationOnly + */ + public function __construct(bool $durationOnly) + { + $this->setDurationMode($durationOnly); + } + /** * @Route("/", defaults={"page": 1}, name="timesheet") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated") @@ -175,15 +183,11 @@ class TimesheetController extends AbstractController */ protected function getCreateForm(Timesheet $entry) { - return $this->createForm( - TimesheetEditForm::class, - $entry, - [ - 'action' => $this->generateUrl('timesheet_create'), - 'method' => 'POST', - 'currency' => Customer::DEFAULT_CURRENCY, - ] - ); + return $this->createForm(TimesheetEditForm::class, $entry, [ + 'action' => $this->generateUrl('timesheet_create'), + 'method' => 'POST', + 'duration_only' => $this->isDurationOnlyMode(), + ]); } /** @@ -193,18 +197,14 @@ class TimesheetController extends AbstractController */ protected function getEditForm(Timesheet $entry, $page) { - return $this->createForm( - TimesheetEditForm::class, - $entry, - [ - 'action' => $this->generateUrl('timesheet_edit', [ - 'id' => $entry->getId(), - 'page' => $page - ]), - 'method' => 'POST', - 'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(), - ] - ); + return $this->createForm(TimesheetEditForm::class, $entry, [ + 'action' => $this->generateUrl('timesheet_edit', [ + 'id' => $entry->getId(), + 'page' => $page + ]), + 'method' => 'POST', + 'duration_only' => $this->isDurationOnlyMode(), + ]); } /** diff --git a/src/Controller/TimesheetControllerTrait.php b/src/Controller/TimesheetControllerTrait.php index 18dbc825..f621cb56 100644 --- a/src/Controller/TimesheetControllerTrait.php +++ b/src/Controller/TimesheetControllerTrait.php @@ -18,6 +18,28 @@ use App\Repository\TimesheetRepository; */ trait TimesheetControllerTrait { + + /** + * @var bool + */ + private $durationOnly = false; + + /** + * @param bool $durationOnly + */ + protected function setDurationMode(bool $durationOnly) + { + $this->durationOnly = $durationOnly; + } + + /** + * @return bool + */ + protected function isDurationOnlyMode() + { + return $this->durationOnly; + } + /** * @return TimesheetRepository */ @@ -56,6 +78,18 @@ trait TimesheetControllerTrait $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { + if ($editForm->has('duration')) { + /** @var Timesheet $record */ + $record = $editForm->getData(); + $duration = $editForm->get('duration')->getData(); + $end = null; + if ($duration > 0) { + $end = clone $record->getBegin(); + $end->modify('+ ' . $duration . 'seconds'); + } + $record->setEnd($end); + } + $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($entry); $entityManager->flush(); @@ -65,13 +99,10 @@ trait TimesheetControllerTrait return $this->redirectToRoute($redirectRoute, ['page' => $request->get('page')]); } - return $this->render( - $renderTemplate, - [ - 'entry' => $entry, - 'form' => $editForm->createView(), - ] - ); + return $this->render($renderTemplate, [ + 'entry' => $entry, + 'form' => $editForm->createView(), + ]); } /** @@ -87,10 +118,20 @@ trait TimesheetControllerTrait $entry->setBegin(new \DateTime()); $createForm = $this->getCreateForm($entry); - $createForm->handleRequest($request); if ($createForm->isSubmitted() && $createForm->isValid()) { + if ($createForm->has('duration')) { + $duration = $createForm->get('duration')->getData(); + if ($duration > 0) { + /** @var Timesheet $record */ + $record = $createForm->getData(); + $end = clone $record->getBegin(); + $end->modify('+ ' . $duration . 'seconds'); + $record->setEnd($end); + } + } + $entityManager = $this->getDoctrine()->getManager(); $entityManager->persist($entry); @@ -101,13 +142,10 @@ trait TimesheetControllerTrait return $this->redirectToRoute($redirectRoute); } - return $this->render( - $renderTemplate, - [ - 'entry' => $entry, - 'form' => $createForm->createView(), - ] - ); + return $this->render($renderTemplate, [ + 'entry' => $entry, + 'form' => $createForm->createView(), + ]); } /** diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index e4b7f2da..b51dd300 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -106,7 +106,7 @@ class AppFixtures extends Fixture 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false ], [ - 'Tony Maier', 'Head of Development', self::USERNAME_TEAMLEAD, 'tony_teamlead@example.com', 'ROLE_TEAMLEAD', + 'Tony Maier', 'Head of Sales', self::USERNAME_TEAMLEAD, 'tony_teamlead@example.com', 'ROLE_TEAMLEAD', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true ], // no avatar to test default image macro diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index d573f6ea..192f86df 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -12,12 +12,13 @@ namespace App\DependencyInjection; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Exception\InvalidDefinitionException; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; /** * This is the class that loads and manages your bundle configuration */ -class AppExtension extends Extension +class AppExtension extends Extension implements PrependExtensionInterface { /** * {@inheritdoc} @@ -32,9 +33,17 @@ class AppExtension extends Extension $config = []; } + $this->createTimesheetParameter($config, $container); $this->createInvoiceParameter($config, $container); } + public function createTimesheetParameter(array $config, ContainerBuilder $container) + { + $container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']); + $container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']); + $container->setParameter('kimai.timesheet.duration_only', $config['timesheet']['duration_only']); + } + /** * @param array $config * @param ContainerBuilder $container @@ -52,8 +61,30 @@ class AppExtension extends Extension } $container->setParameter('kimai.invoice', $config['invoice']); - $container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']); - $container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']); + } + + /** + * @param ContainerBuilder $container + */ + public function prepend(ContainerBuilder $container) + { + $configuration = new Configuration(); + $configs = $container->getExtensionConfig($this->getAlias()); + try { + $config = $this->processConfiguration($configuration, $configs); + } catch (InvalidConfigurationException $e) { + trigger_error('Found invalid "kimai" configuration: ' . $e->getMessage()); + $config = []; + } + + $container->prependExtensionConfig( + 'twig', + [ + 'globals' => [ + 'duration_only' => $config['timesheet']['duration_only'], + ], + ] + ); } /** diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 4fd26774..12a25b83 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -29,6 +29,9 @@ class Configuration implements ConfigurationInterface ->children() ->arrayNode('timesheet') ->children() + ->booleanNode('duration_only') + ->defaultValue(false) + ->end() ->arrayNode('rounding') ->requiresAtLeastOneElement() ->useAttributeAsKey('key') diff --git a/src/Form/TimesheetAdminForm.php b/src/Form/TimesheetAdminForm.php deleted file mode 100644 index 092c4446..00000000 --- a/src/Form/TimesheetAdminForm.php +++ /dev/null @@ -1,35 +0,0 @@ -add('user', UserType::class, [ - 'label' => 'label.user', - ]) - ; - } -} diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 49e75310..69d41e0b 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -9,13 +9,13 @@ namespace App\Form; +use App\Form\Type\DurationType; +use App\Form\Type\UserType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; -use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use App\Entity\Customer; use App\Entity\Timesheet; use App\Form\Type\ActivityGroupedWithCustomerNameType; use App\Repository\ActivityRepository; @@ -39,36 +39,39 @@ class TimesheetEditForm extends AbstractType $activity = $entry->getActivity(); } - $builder - // datetime - ->add('begin', DateTimeType::class, [ + if ($entry->getEnd() === null || !$options['duration_only']) { + $builder->add('begin', DateTimeType::class, [ 'label' => 'label.begin', 'date_widget' => 'single_text', - ]) - // datetime - ->add('end', DateTimeType::class, [ + ]); + } + + if ($options['duration_only']) { + $builder->add('duration', DurationType::class); + } else { + $builder->add('end', DateTimeType::class, [ 'label' => 'label.end', 'date_widget' => 'single_text', 'required' => false, - ]) - // Activity + ]); + } + + $builder ->add('activity', ActivityGroupedWithCustomerNameType::class, [ 'label' => 'label.activity', 'query_builder' => function (ActivityRepository $repo) use ($activity) { return $repo->builderForEntityType($activity); }, ]) - // customer ->add('description', TextareaType::class, [ 'label' => 'label.description', 'required' => false, ]) - // string - ->add('rate', MoneyType::class, [ - 'label' => 'label.rate', - 'currency' => $builder->getOption('currency'), - ]) ; + + if ($options['include_user']) { + $builder->add('user', UserType::class); + } } /** @@ -81,7 +84,8 @@ class TimesheetEditForm extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'timesheet_edit', - 'currency' => Customer::DEFAULT_CURRENCY, + 'duration_only' => false, + 'include_user' => false, ]); } } diff --git a/src/Form/Type/DurationType.php b/src/Form/Type/DurationType.php new file mode 100644 index 00000000..58eb8d74 --- /dev/null +++ b/src/Form/Type/DurationType.php @@ -0,0 +1,99 @@ +pattern = '/^' . implode('$|^', $patterns) . '$/'; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + + $resolver->setDefaults([ + 'label' => 'label.duration', + 'constraints' => [new Regex(['pattern' => $this->pattern])], + ]); + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $formatter = new Duration(); + $pattern = $this->pattern; + + $builder->addModelTransformer(new CallbackTransformer( + function ($intToFormat) use ($formatter) { + try { + return $formatter->format($intToFormat, true); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage()); + } + }, + function ($formatToInt) use ($formatter, $pattern) { + if (empty($formatToInt)) { + return 0; + } + if (!preg_match($pattern, $formatToInt)) { + throw new TransformationFailedException('Invalid duration format given'); + } + try { + return $formatter->parseDurationString($formatToInt); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage()); + } + } + )); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return TextType::class; + } +} diff --git a/src/Form/Type/UserType.php b/src/Form/Type/UserType.php index 7e124439..a938ec56 100644 --- a/src/Form/Type/UserType.php +++ b/src/Form/Type/UserType.php @@ -27,6 +27,7 @@ class UserType extends AbstractType { $resolver->setDefaults([ 'class' => User::class, + 'label' => 'label.user', 'choice_label' => function (User $user) { if (!empty($user->getAlias())) { return $user->getAlias() . ' (' . $user->getUsername() . ')'; diff --git a/src/Twig/Extensions.php b/src/Twig/Extensions.php index 3724a0e2..c7a42b8d 100644 --- a/src/Twig/Extensions.php +++ b/src/Twig/Extensions.php @@ -9,6 +9,7 @@ namespace App\Twig; +use App\Utils\Duration; use Symfony\Component\Intl\Intl; use App\Entity\Timesheet; use Twig\TwigFilter; @@ -23,6 +24,11 @@ class Extensions extends \Twig_Extension */ private $locales; + /** + * @var Duration + */ + protected $durationFormatter; + /** * Extensions constructor. * @param string $locales @@ -30,6 +36,7 @@ class Extensions extends \Twig_Extension public function __construct($locales) { $this->locales = explode('|', $locales); + $this->durationFormatter = new Duration(); } /** @@ -77,20 +84,7 @@ class Extensions extends \Twig_Extension */ public function duration($seconds, $includeSeconds = false) { - $hour = floor($seconds / 3600); - $minute = floor(($seconds / 60) % 60); - - $hour = $hour > 9 ? $hour : '0' . $hour; - $minute = $minute > 9 ? $minute : '0' . $minute; - - if (!$includeSeconds) { - return $hour . ':' . $minute . ' h'; - } - - $second = $seconds % 60; - $second = $second > 9 ? $second : '0' . $second; - - return $hour . ':' . $minute . ':' . $second . ' h'; + return $this->durationFormatter->format($seconds, $includeSeconds) . ' h'; } /** diff --git a/src/Utils/Duration.php b/src/Utils/Duration.php new file mode 100644 index 00000000..c2b1f49d --- /dev/null +++ b/src/Utils/Duration.php @@ -0,0 +1,117 @@ + 9 ? $hour : '0' . $hour; + $minute = $minute > 9 ? $minute : '0' . $minute; + + if (!$includeSeconds) { + return $hour . ':' . $minute; + } + + $second = $seconds % 60; + $second = $second > 9 ? $second : '0' . $second; + + return $hour . ':' . $minute . ':' . $second; + } + + /** + * @param string $duration + * @return string + */ + public function parseDurationString($duration) + { + if (stripos($duration, ':') !== false) { + return $this->parseDuration($duration, self::FORMAT_COLON); + } + + if (is_numeric($duration) && $duration == (int)$duration) { + return $this->parseDuration($duration, self::FORMAT_SECONDS); + } + + return $this->parseDuration($duration, self::FORMAT_NATURAL); + } + + /** + * @param string $duration + * @param string $mode + * @return int + * @throws \InvalidArgumentException + */ + public function parseDuration(string $duration, string $mode) + { + if (empty($duration)) { + return 0; + } + + $seconds = 0; + + switch ($mode) { + case self::FORMAT_COLON: + $parts = explode(':', $duration); + if (count($parts) < 2) { + throw new \InvalidArgumentException('Colon format cannot parse: ' . $duration); + } + $seconds = 0; + if (count($parts) == 3) { + $seconds += array_pop($parts); + } + $seconds += $parts[1] * 60; + $seconds += $parts[0] * 3600; + break; + + case self::FORMAT_NATURAL: + try { + $interval = new \DateInterval('PT' . strtoupper($duration)); + $reference = new \DateTimeImmutable(); + $endTime = $reference->add($interval); + $seconds = $endTime->getTimestamp() - $reference->getTimestamp(); + } catch (\Exception $e) { + throw new \InvalidArgumentException('Invalid input for natural format: ' . $duration); + } + break; + + case self::FORMAT_SECONDS: + $seconds = $duration; + break; + + default: + throw new \InvalidArgumentException('Invalid duration format: ' . $mode); + } + + if ($seconds < 0) { + return 0; + } + + return $seconds; + } +} diff --git a/templates/admin/timesheet.html.twig b/templates/admin/timesheet.html.twig index 40d85dfb..54bdd0f9 100644 --- a/templates/admin/timesheet.html.twig +++ b/templates/admin/timesheet.html.twig @@ -10,31 +10,45 @@ {{ widgets.callout('warning', 'error.no_entries_found') }} {% endif %} - {{ tables.data_table_header({ - 'label.date': '', - 'label.starttime': 'hidden-xs', - 'label.endtime': 'hidden-xs', + {% set columns = {'label.date': ''} %} + + {% if not duration_only %} + {% set columns = columns|merge({'label.starttime': 'hidden-xs', 'label.endtime': 'hidden-xs'}) %} + {% endif %} + + {% set columns = columns|merge({ 'label.duration': '', 'label.rate': '', 'label.activity': 'hidden-xs hidden-sm', 'label.username': 'hidden-xs', 'label.description': 'hidden-xs hidden-sm', 'label.actions': '', - }, toolbarForm, {'plus-square': path('admin_timesheet_create')}) }} + }) %} + + {{ tables.data_table_header(columns, toolbarForm, {'plus-square': path('admin_timesheet_create')}) }} {% for entry in entries %}