Duration only mode #131 (#134)

This commit is contained in:
Kevin Papst
2018-02-10 22:19:49 +01:00
committed by GitHub
parent 93e1cd5095
commit 5a4bb0d081
19 changed files with 671 additions and 135 deletions

View File

@@ -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
]);
}

View File

@@ -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(),
]);
}
/**

View File

@@ -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(),
]);
}
/**

View File

@@ -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

View File

@@ -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'],
],
]
);
}
/**

View File

@@ -29,6 +29,9 @@ class Configuration implements ConfigurationInterface
->children()
->arrayNode('timesheet')
->children()
->booleanNode('duration_only')
->defaultValue(false)
->end()
->arrayNode('rounding')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')

View File

@@ -1,35 +0,0 @@
<?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\Form;
use App\Form\Type\UserType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Defines the form used to administrate Timesheet entries.
*/
class TimesheetAdminForm extends TimesheetEditForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
// User
->add('user', UserType::class, [
'label' => 'label.user',
])
;
}
}

View File

@@ -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,
]);
}
}

View File

@@ -0,0 +1,99 @@
<?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\Form\Type;
use App\Utils\Duration;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Regex;
/**
* Custom form field type to handle a timesheet duration.
*/
class DurationType extends AbstractType
{
/**
* @var string
*/
protected $pattern;
/**
* DurationType constructor.
*/
public function __construct()
{
$patterns = [
'[0-9]{1,}',
'[0-9]{1,}:[0-9]{1,2}:[0-9]{1,2}',
'[0-9]{1,2}:[0-9]{1,2}',
'[0-9]{1,}[hmsHMS]{1}',
'[0-9]{1,}[hmsHMS]{1}[0-9]{1,}[hmsHMS]{1}',
'[0-9]{1,}[hmsHMS]{1}[0-9]{1,}[hmsHMS]{1}[0-9]{1,}[hmsHMS]{1}',
];
$this->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;
}
}

View File

@@ -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() . ')';

View File

@@ -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';
}
/**

117
src/Utils/Duration.php Normal file
View File

@@ -0,0 +1,117 @@
<?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\Utils;
/**
* A simple class to help with timesheet record durations.
*/
class Duration
{
const FORMAT_COLON = 'colon';
const FORMAT_NATURAL = 'natural';
const FORMAT_SECONDS = 'seconds';
/**
* Transforms seconds into a duration string.
*
* @param $seconds
* @param bool $includeSeconds
* @return string
*/
public function format($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;
}
$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;
}
}