@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,9 @@ class Configuration implements ConfigurationInterface
|
||||
->children()
|
||||
->arrayNode('timesheet')
|
||||
->children()
|
||||
->booleanNode('duration_only')
|
||||
->defaultValue(false)
|
||||
->end()
|
||||
->arrayNode('rounding')
|
||||
->requiresAtLeastOneElement()
|
||||
->useAttributeAsKey('key')
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
99
src/Form/Type/DurationType.php
Normal file
99
src/Form/Type/DurationType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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() . ')';
|
||||
|
||||
@@ -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
117
src/Utils/Duration.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
<tr>
|
||||
<td>{{ entry.begin|date(kimai_context.date_1) }}</td>
|
||||
<td class="hidden-xs">{{ entry.begin|date("H:i") }}</td>
|
||||
|
||||
{% if not duration_only %}
|
||||
<td class="hidden-xs">{{ entry.begin|date("H:i") }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.end %}
|
||||
<td class="hidden-xs">{{ entry.end|date("H:i") }}</td>
|
||||
{% if not duration_only %}
|
||||
<td class="hidden-xs">{{ entry.end|date("H:i") }}</td>
|
||||
{% endif %}
|
||||
<td>{{ entry.duration|duration }}</td>
|
||||
<td>{{ entry.rate|money(entry.activity.project.customer.currency) }}</td>
|
||||
{% else %}
|
||||
<td class="hidden-xs">‐</td>
|
||||
<td>‐</td>
|
||||
{% if not duration_only %}
|
||||
<td class="hidden-xs">‐</td>
|
||||
{% endif %}
|
||||
<td><i>{{ entry.duration|duration }}</i></td>
|
||||
<td>‐</td>
|
||||
{% endif %}
|
||||
|
||||
<td class="hidden-xs hidden-sm">
|
||||
<a href="{{ path('admin_activity_edit', {'id': entry.activity.id}) }}">{{ widgets.label_activity(entry.activity) }}</a>
|
||||
</td>
|
||||
|
||||
@@ -6,38 +6,54 @@
|
||||
{% block page_subtitle %}{{ 'timesheet.subtitle'|trans }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
{% if entries.count == 0 %}
|
||||
{{ widgets.callout('warning', 'error.no_entries_found') }}
|
||||
{% endif %}
|
||||
|
||||
{{ tables.data_table_header({
|
||||
'label.date': '',
|
||||
'label.starttime': '',
|
||||
'label.endtime': '',
|
||||
{% set columns = {'label.date': ''} %}
|
||||
|
||||
{% if not duration_only %}
|
||||
{% set columns = columns|merge({'label.starttime': '', 'label.endtime': ''}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set columns = columns|merge({
|
||||
'label.duration': 'hidden-xs',
|
||||
'label.rate': 'hidden-xs',
|
||||
'label.activity': 'hidden-xs hidden-sm',
|
||||
'label.description': 'hidden-xs hidden-sm',
|
||||
'label.actions': '',
|
||||
}, toolbarForm, {'plus-square': path('timesheet_create')}) }}
|
||||
}) %}
|
||||
|
||||
{{ tables.data_table_header(columns, toolbarForm, {'plus-square': path('timesheet_create')}) }}
|
||||
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ entry.begin|date(kimai_context.date_1) }}</td>
|
||||
<td>{{ entry.begin|date("H:i") }}</td>
|
||||
|
||||
{% if not duration_only %}
|
||||
<td>{{ entry.begin|date("H:i") }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.end %}
|
||||
<td>{{ entry.end|date("H:i") }}</td>
|
||||
{% if not duration_only %}
|
||||
<td>{{ entry.end|date("H:i") }}</td>
|
||||
{% endif %}
|
||||
<td class="hidden-xs">{{ entry.duration|duration }}</td>
|
||||
<td class="hidden-xs">{{ entry.rate|money(entry.activity.project.customer.currency) }}</td>
|
||||
{% else %}
|
||||
<td>‐</td>
|
||||
{% if not duration_only %}
|
||||
<td>‐</td>
|
||||
{% endif %}
|
||||
<td class="hidden-xs"><i>{{ entry.duration|duration }}</i></td>
|
||||
<td class="hidden-xs">‐</td>
|
||||
{% endif %}
|
||||
|
||||
<td class="hidden-xs hidden-sm">{{ widgets.label_activity(entry.activity) }}</td>
|
||||
<td class="hidden-xs hidden-sm">{{ entry.description }}</td>
|
||||
<td>
|
||||
{% set actionButtons = {'edit': path('timesheet_edit', {'id' : entry.id, 'page': page})} %}
|
||||
|
||||
{% if entry.end %}
|
||||
{% if is_granted('start', entry.activity) %}
|
||||
{% set actionButtons = {'repeat': path('timesheet_start', {'id' : entry.activity.id})}|merge(actionButtons) %}
|
||||
@@ -47,6 +63,7 @@
|
||||
{% set actionButtons = {'stop': path('timesheet_stop', {'id' : entry.id})}|merge(actionButtons) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% set actionButtons = actionButtons|merge({'trash': path('timesheet_delete', {'id' : entry.id, 'page': page})}) %}
|
||||
{{ widgets.button_group(actionButtons) }}
|
||||
</td>
|
||||
@@ -54,4 +71,5 @@
|
||||
{% endfor %}
|
||||
|
||||
{{ tables.data_table_footer(entries, 'timesheet_paginated') }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
128
tests/Twig/ExtensionsTest.php
Normal file
128
tests/Twig/ExtensionsTest.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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\Tests\Twig;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Twig\Extensions;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
/**
|
||||
* @covers \App\Twig\Extensions
|
||||
*/
|
||||
class ExtensionsTest extends TestCase
|
||||
{
|
||||
|
||||
public function testGetFilters()
|
||||
{
|
||||
$filters = ['duration', 'durationForEntry', 'money', 'currency', 'country'];
|
||||
$sut = new Extensions('de');
|
||||
$twigFilters = $sut->getFilters();
|
||||
$this->assertCount(count($filters), $twigFilters);
|
||||
$i = 0;
|
||||
foreach ($twigFilters as $filter) {
|
||||
$this->assertInstanceOf(TwigFilter::class, $filter);
|
||||
$this->assertEquals($filters[$i++], $filter->getName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetFunctions()
|
||||
{
|
||||
$functions = ['locales'];
|
||||
$sut = new Extensions('de');
|
||||
$twigFunctions = $sut->getFunctions();
|
||||
$this->assertCount(count($functions), $twigFunctions);
|
||||
$i = 0;
|
||||
foreach ($twigFunctions as $filter) {
|
||||
$this->assertInstanceOf(\Twig_SimpleFunction::class, $filter);
|
||||
$this->assertEquals($functions[$i++], $filter->getName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testLocales()
|
||||
{
|
||||
$locales = [
|
||||
['code' => 'en', 'name' => 'English'],
|
||||
['code' => 'de', 'name' => 'Deutsch'],
|
||||
['code' => 'ru', 'name' => 'русский'],
|
||||
];
|
||||
|
||||
$sut = new Extensions('en|de|ru');
|
||||
$this->assertEquals($locales, $sut->getLocales());
|
||||
}
|
||||
|
||||
public function testCurrency()
|
||||
{
|
||||
$symbols = [
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
'RUB' => 'RUB',
|
||||
];
|
||||
|
||||
$sut = new Extensions('en');
|
||||
foreach ($symbols as $name => $symbol) {
|
||||
$this->assertEquals($symbol, $sut->currency($name));
|
||||
}
|
||||
}
|
||||
|
||||
public function testCountry()
|
||||
{
|
||||
$countries = [
|
||||
'DE' => 'Germany',
|
||||
'RU' => 'Russia',
|
||||
'ES' => 'Spain',
|
||||
];
|
||||
|
||||
$sut = new Extensions('en');
|
||||
foreach ($countries as $locale => $name) {
|
||||
$this->assertEquals($name, $sut->country($locale));
|
||||
}
|
||||
}
|
||||
|
||||
public function testMoney()
|
||||
{
|
||||
$money = [
|
||||
[2222, 'EUR', '2,222.00 €'],
|
||||
[13.75, 'USD', '13.75 $'],
|
||||
];
|
||||
|
||||
$sut = new Extensions('en');
|
||||
foreach ($money as $entry) {
|
||||
$amount = $entry[0];
|
||||
$currency = $entry[1];
|
||||
$expected = $entry[2];
|
||||
$this->assertEquals($expected, $sut->money($amount, $currency));
|
||||
}
|
||||
}
|
||||
|
||||
public function testDuration()
|
||||
{
|
||||
$record = $this->getTimesheet(9437);
|
||||
|
||||
$sut = new Extensions('en');
|
||||
$this->assertEquals('02:37 h', $sut->duration($record->getDuration()));
|
||||
$this->assertEquals('02:37:17 h', $sut->duration($record->getDuration(), true));
|
||||
|
||||
$this->assertEquals('02:37 h', $sut->durationForEntry($record));
|
||||
$this->assertEquals('02:37:17 h', $sut->durationForEntry($record, true));
|
||||
}
|
||||
|
||||
protected function getTimesheet($seconds)
|
||||
{
|
||||
$begin = new \DateTime();
|
||||
$end = clone $begin;
|
||||
$end->setTimestamp($begin->getTimestamp() + $seconds);
|
||||
$record = new Timesheet();
|
||||
$record->setBegin($begin);
|
||||
$record->setEnd($end);
|
||||
$record->setDuration($seconds);
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
94
tests/Utils/DurationTest.php
Normal file
94
tests/Utils/DurationTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?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\Tests\Utils;
|
||||
|
||||
use App\Utils\Duration;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Utils\Duration
|
||||
*/
|
||||
class DurationTest extends TestCase
|
||||
{
|
||||
|
||||
public function testFormat()
|
||||
{
|
||||
$sut = new Duration();
|
||||
$this->assertEquals('02:38', $sut->format(9494));
|
||||
$this->assertEquals('02:38:14', $sut->format(9494, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getParseDurationTestData
|
||||
*/
|
||||
public function testParseDurationString($expected, $duration, $mode)
|
||||
{
|
||||
$sut = new Duration();
|
||||
$this->assertEquals($expected, $sut->parseDurationString($duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getParseDurationTestData
|
||||
*/
|
||||
public function testParseDuration($expected, $duration, $mode)
|
||||
{
|
||||
$sut = new Duration();
|
||||
$this->assertEquals($expected, $sut->parseDuration($duration, $mode));
|
||||
}
|
||||
|
||||
public function getParseDurationTestData()
|
||||
{
|
||||
return [
|
||||
[0, '', Duration::FORMAT_SECONDS],
|
||||
[0, 0, Duration::FORMAT_SECONDS],
|
||||
[0, -12, Duration::FORMAT_SECONDS],
|
||||
[3600, 3600, Duration::FORMAT_SECONDS],
|
||||
|
||||
[0, '', Duration::FORMAT_NATURAL],
|
||||
[0, 0, Duration::FORMAT_NATURAL],
|
||||
[7200, '2h', Duration::FORMAT_NATURAL],
|
||||
[2280, '38m', Duration::FORMAT_NATURAL],
|
||||
[9480, '2h38m', Duration::FORMAT_NATURAL],
|
||||
[9497, '2h38m17s', Duration::FORMAT_NATURAL],
|
||||
[9497, '1h96m137s', Duration::FORMAT_NATURAL],
|
||||
|
||||
[0, '', Duration::FORMAT_COLON],
|
||||
[0, 0, Duration::FORMAT_COLON],
|
||||
[48420, '13:27', Duration::FORMAT_COLON],
|
||||
[48474, '13:27:54', Duration::FORMAT_COLON],
|
||||
[48474, '12:87:54', Duration::FORMAT_COLON],
|
||||
];
|
||||
}
|
||||
|
||||
public function getParseDurationInvalidData()
|
||||
{
|
||||
return [
|
||||
// invalid input
|
||||
['13', Duration::FORMAT_COLON],
|
||||
['13-13', Duration::FORMAT_COLON],
|
||||
['13.13', Duration::FORMAT_COLON],
|
||||
[1111, 1111, Duration::FORMAT_NATURAL],
|
||||
|
||||
// invalid modes
|
||||
[17, 'foo'],
|
||||
[12, ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getParseDurationInvalidData
|
||||
* @expectedException \InvalidArgumentException
|
||||
*/
|
||||
public function testParseDurationThrowsInvalidArgumentException($duration, $mode)
|
||||
{
|
||||
$sut = new Duration();
|
||||
$sut->parseDuration($duration, $mode);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
There are several configurations that can be configured with the yaml files in `config/packages/*.yaml
|
||||
|
||||
## Duration only
|
||||
|
||||
Kimai supports two modes for displaying and recording timesheet entries:
|
||||
|
||||
- `begin` and `end` time (default)
|
||||
- `date` and `duration` (the so called `duration_only` mode)
|
||||
|
||||
When activating the `duration_only` mode all timesheet tables will only display the `date` and `duration` of all records.
|
||||
In addition, the "edit timesheet" forms will be changed and instead of displaying the `end` date you will see a field for `duration`.
|
||||
The `start` date is only visible in these forms when editing an active or starting a new record.
|
||||
|
||||
You can activate the `duration_only` mode by switching the configuration key `kimai.timesheet.duration_only` to `true` in the file [kimai.yaml](../../config/packages/kimai.yaml).
|
||||
|
||||
## Remember me login
|
||||
|
||||
The default period for the `Remember me` option can be changed in the config file [security.yaml](../../config/packages/security.yaml).
|
||||
|
||||
Reference in New Issue
Block a user