added reporting screen (#1805)
This commit is contained in:
@@ -100,6 +100,7 @@ final class PermissionController extends AbstractController
|
||||
new PermissionSection('Timesheet', '_timesheet'),
|
||||
new PermissionSection('Timesheet (other)', '_other_timesheet'),
|
||||
new PermissionSection('Timesheet (own)', '_own_timesheet'),
|
||||
new PermissionSection('Reporting', '_reporting'),
|
||||
];
|
||||
|
||||
$event = new PermissionSectionsEvent();
|
||||
|
||||
218
src/Controller/ReportingController.php
Normal file
218
src/Controller/ReportingController.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?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\Controller;
|
||||
|
||||
use App\Model\Statistic\Day;
|
||||
use App\Reporting\MonthByUser;
|
||||
use App\Reporting\MonthByUserForm;
|
||||
use App\Reporting\MonthlyUserList;
|
||||
use App\Reporting\MonthlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* Controller used to render reports.
|
||||
*
|
||||
* @Route(path="/reporting")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
final class ReportingController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $timesheetRepository;
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $userRepository;
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
private $dateTimeFactory;
|
||||
|
||||
public function __construct(TimesheetRepository $timesheetRepository, UserRepository $userRepository, UserDateTimeFactory $dateTimeFactory)
|
||||
{
|
||||
$this->timesheetRepository = $timesheetRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->dateTimeFactory = $dateTimeFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", name="reporting", methods={"GET"})
|
||||
* @Route(path="/month_by_user", name="report_user_month", methods={"GET","POST"})
|
||||
*/
|
||||
public function monthByUser(Request $request)
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$values = new MonthByUser();
|
||||
$values->setUser($user);
|
||||
$values->setDate($this->dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthByUserForm::class, $values, [
|
||||
'method' => 'POST',
|
||||
'include_user' => $this->isGranted('view_other_timesheet') && $user->hasTeamAssignment(),
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setUser($user);
|
||||
$values->setDate($this->dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
if ($user !== $values->getUser() && !$this->isGranted('view_other_timesheet')) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previousMonth = clone $start;
|
||||
$previousMonth->modify('-1 month');
|
||||
|
||||
$nextMonth = clone $start;
|
||||
$nextMonth->modify('+1 month');
|
||||
|
||||
$data = $this->timesheetRepository->getDailyStats($selectedUser, $start, $end);
|
||||
$rows = $this->prepareMonthlyData($data);
|
||||
|
||||
return $this->render('reporting/month_by_user.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'days' => $data,
|
||||
'rows' => $rows,
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $nextMonth,
|
||||
'previous' => $previousMonth,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"})
|
||||
* @Security("is_granted('view_other_timesheet')")
|
||||
*/
|
||||
public function montlyhUsersList(Request $request)
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $this->userRepository->getUsersForQuery($query);
|
||||
|
||||
$rows = [];
|
||||
|
||||
$values = new MonthlyUserList();
|
||||
$values->setDate($this->dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthlyUserListForm::class, $values, [
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($this->dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$previousMonth = clone $start;
|
||||
$previousMonth->modify('-1 month');
|
||||
|
||||
$nextMonth = clone $start;
|
||||
$nextMonth->modify('+1 month');
|
||||
|
||||
foreach ($allUsers as $user) {
|
||||
$rows[] = [
|
||||
'days' => $this->timesheetRepository->getDailyStats($user, $start, $end),
|
||||
'user' => $user
|
||||
];
|
||||
}
|
||||
|
||||
$days = [];
|
||||
|
||||
if (isset($rows[0])) {
|
||||
/** @var Day $day */
|
||||
foreach ($rows[0]['days'] as $day) {
|
||||
$days[$day->getDay()->format('Ymd')] = $day->getDay();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('reporting/monthly_user_list.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'rows' => $rows,
|
||||
'days' => $days,
|
||||
'current' => $start,
|
||||
'next' => $nextMonth,
|
||||
'previous' => $previousMonth,
|
||||
]);
|
||||
}
|
||||
|
||||
private function prepareMonthlyData(array $data): array
|
||||
{
|
||||
$days = [];
|
||||
|
||||
foreach ($data as $day) {
|
||||
$days[$day->getDay()->format('Ymd')] = ['date' => $day->getDay(), 'duration' => 0];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
/** @var Day $day */
|
||||
foreach ($data as $day) {
|
||||
$dayId = $day->getDay()->format('Ymd');
|
||||
foreach ($day->getDetails() as $id => $detail) {
|
||||
$projectId = $detail['project']->getId();
|
||||
if (!\array_key_exists($projectId, $rows)) {
|
||||
$rows[$projectId] = [
|
||||
'project' => $detail['project'],
|
||||
'duration' => 0,
|
||||
'days' => $days,
|
||||
'activities' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$rows[$projectId]['duration'] += $detail['duration'];
|
||||
$rows[$projectId]['days'][$dayId]['duration'] += $detail['duration'];
|
||||
|
||||
$activityId = $detail['activity']->getId();
|
||||
if (!\array_key_exists($activityId, $rows[$projectId]['activities'])) {
|
||||
$rows[$projectId]['activities'][$activityId] = [
|
||||
'activity' => $detail['activity'],
|
||||
'duration' => 0,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
$rows[$projectId]['activities'][$activityId]['duration'] += $detail['duration'];
|
||||
$rows[$projectId]['activities'][$activityId]['days'][$dayId]['duration'] += $detail['duration'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
51
src/Event/ReportingEvent.php
Normal file
51
src/Event/ReportingEvent.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Reporting\ReportInterface;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class ReportingEvent extends Event
|
||||
{
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
/**
|
||||
* @var ReportInterface[]
|
||||
*/
|
||||
private $reports = [];
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function addReport(ReportInterface $report): ReportingEvent
|
||||
{
|
||||
$this->reports[$report->getId()] = $report;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ReportInterface[]
|
||||
*/
|
||||
public function getReports(): array
|
||||
{
|
||||
return array_values($this->reports);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,12 @@ final class MenuSubscriber implements EventSubscriberInterface
|
||||
$menu->addChild($timesheets);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_reporting')) {
|
||||
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $this->getIcon('reporting'));
|
||||
$reporting->setChildRoutes(['report_user_month', 'report_monthly_users']);
|
||||
$menu->addChild($reporting);
|
||||
}
|
||||
|
||||
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
|
||||
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
|
||||
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
|
||||
|
||||
@@ -52,8 +52,13 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
$view->vars['attr'] = [];
|
||||
}
|
||||
|
||||
$extendedOptions = ['class' => 'selectpicker', 'data-width' => '100%'];
|
||||
if (!$options['search']) {
|
||||
$extendedOptions = ['class' => 'selectpicker'];
|
||||
|
||||
if (false !== $options['width']) {
|
||||
$extendedOptions['data-width'] = $options['width'];
|
||||
}
|
||||
|
||||
if (false === $options['search']) {
|
||||
$extendedOptions['data-minimum-results-for-search'] = 'Infinity';
|
||||
}
|
||||
|
||||
@@ -69,6 +74,10 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
$resolver->setAllowedTypes('selectpicker', 'boolean');
|
||||
$resolver->setDefault('selectpicker', true);
|
||||
|
||||
$resolver->setDefined(['width']);
|
||||
$resolver->setAllowedTypes('width', ['string', 'boolean']);
|
||||
$resolver->setDefault('width', '100%');
|
||||
|
||||
$resolver->setDefined(['search']);
|
||||
$resolver->setAllowedTypes('search', 'boolean');
|
||||
$resolver->setDefault('search', true);
|
||||
|
||||
90
src/Form/Type/MonthPickerType.php
Normal file
90
src/Form/Type/MonthPickerType.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?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\Timesheet\UserDateTimeFactory;
|
||||
use App\Utils\LocaleSettings;
|
||||
use App\Utils\MomentFormatConverter;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Custom form field type to select a month via picker and select previous and next month.
|
||||
*/
|
||||
final class MonthPickerType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* @var LocaleSettings
|
||||
*/
|
||||
private $localeSettings;
|
||||
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
private $dateTime;
|
||||
|
||||
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
|
||||
{
|
||||
$this->localeSettings = $localeSettings;
|
||||
$this->dateTime = $dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$pickerFormat = $this->localeSettings->getDatePickerFormat();
|
||||
$dateFormat = $this->localeSettings->getDateTypeFormat();
|
||||
$timezone = $this->dateTime->getTimezone()->getName();
|
||||
|
||||
$resolver->setDefaults([
|
||||
'widget' => 'single_text',
|
||||
'html5' => false,
|
||||
'format' => $dateFormat,
|
||||
'format_picker' => $pickerFormat,
|
||||
'model_timezone' => $timezone,
|
||||
'view_timezone' => $timezone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options)
|
||||
{
|
||||
/** @var \DateTime|null $date */
|
||||
$date = $form->getData();
|
||||
|
||||
if (null === $date) {
|
||||
$date = $this->dateTime->getStartOfMonth();
|
||||
}
|
||||
|
||||
$view->vars['previousMonth'] = (clone $date)->modify('-1 month');
|
||||
$view->vars['nextMonth'] = (clone $date)->modify('+1 month');
|
||||
$view->vars['momentFormat'] = (new MomentFormatConverter())->convert($this->localeSettings->getDateTypeFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getParent()
|
||||
{
|
||||
return DateType::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getBlockPrefix()
|
||||
{
|
||||
return 'monthpicker';
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ class Day
|
||||
* @var DateTime
|
||||
*/
|
||||
protected $day;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $details = [];
|
||||
|
||||
public function __construct(DateTime $day, int $duration, float $rate)
|
||||
{
|
||||
@@ -61,4 +65,16 @@ class Day
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDetails(array $details): Day
|
||||
{
|
||||
$this->details = $details;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDetails(): array
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
}
|
||||
|
||||
48
src/Reporting/MonthByUser.php
Normal file
48
src/Reporting/MonthByUser.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
final class MonthByUser
|
||||
{
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
private $date;
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(User $user): MonthByUser
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDate(): ?\DateTime
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(\DateTime $date): MonthByUser
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
42
src/Reporting/MonthByUserForm.php
Normal file
42
src/Reporting/MonthByUserForm.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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\Reporting;
|
||||
|
||||
use App\Form\Type\MonthPickerType;
|
||||
use App\Form\Type\UserType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class MonthByUserForm extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('date', MonthPickerType::class);
|
||||
|
||||
if ($options['include_user']) {
|
||||
$builder->add('user', UserType::class, ['width' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => MonthByUser::class,
|
||||
'include_user' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
src/Reporting/MonthlyUserList.php
Normal file
30
src/Reporting/MonthlyUserList.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\Reporting;
|
||||
|
||||
final class MonthlyUserList
|
||||
{
|
||||
/**
|
||||
* @var \DateTime
|
||||
*/
|
||||
private $date;
|
||||
|
||||
public function getDate(): ?\DateTime
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(\DateTime $date): MonthlyUserList
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
36
src/Reporting/MonthlyUserListForm.php
Normal file
36
src/Reporting/MonthlyUserListForm.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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\Reporting;
|
||||
|
||||
use App\Form\Type\MonthPickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class MonthlyUserListForm extends AbstractType
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('date', MonthPickerType::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => MonthlyUserList::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
src/Reporting/Report.php
Normal file
48
src/Reporting/Report.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Reporting;
|
||||
|
||||
final class Report implements ReportInterface
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $id;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $label;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $route;
|
||||
|
||||
public function __construct(string $id, string $route, string $label)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->route = $route;
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getRoute(): string
|
||||
{
|
||||
return $this->route;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
}
|
||||
19
src/Reporting/ReportInterface.php
Normal file
19
src/Reporting/ReportInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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\Reporting;
|
||||
|
||||
interface ReportInterface
|
||||
{
|
||||
public function getId(): string;
|
||||
|
||||
public function getLabel(): string;
|
||||
|
||||
public function getRoute(): string;
|
||||
}
|
||||
@@ -356,6 +356,9 @@ class TimesheetRepository extends EntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* In case this method is called with one timezone and the results are from another timezone,
|
||||
* it might return rows outside the time-range.
|
||||
*
|
||||
* @param DateTime $begin
|
||||
* @param DateTime $end
|
||||
* @param User|null $user
|
||||
@@ -363,73 +366,101 @@ class TimesheetRepository extends EntityRepository
|
||||
*/
|
||||
protected function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
|
||||
{
|
||||
$query = new TimesheetQuery();
|
||||
$query
|
||||
->setBegin($begin)
|
||||
->setEnd($end)
|
||||
->setUser($user)
|
||||
->setState(TimesheetQuery::STATE_STOPPED)
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$or = $qb->expr()->orX();
|
||||
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
|
||||
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
|
||||
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
|
||||
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
|
||||
|
||||
$qb->select('t, p, a, c')
|
||||
->from(Timesheet::class, 't')
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($or)
|
||||
->orderBy('t.begin', 'DESC')
|
||||
->setParameter('begin', $begin)
|
||||
->setParameter('end', $end)
|
||||
->leftJoin('t.activity', 'a')
|
||||
->leftJoin('t.project', 'p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
;
|
||||
$timesheets = $this->getTimesheetsForQuery($query);
|
||||
|
||||
if (null !== $user) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
}
|
||||
|
||||
$timesheets = $qb->getQuery()->getResult();
|
||||
|
||||
$results = [];
|
||||
/** @var Timesheet $result */
|
||||
foreach ($timesheets as $result) {
|
||||
$timezone = new \DateTimeZone($result->getTimezone());
|
||||
/** @var \DateTime $beginTmp */
|
||||
$beginTmp = $result->getBegin();
|
||||
$beginTmp->setTimezone($timezone);
|
||||
/** @var DateTime $endTmp */
|
||||
$endTmp = $result->getEnd();
|
||||
$endTmp->setTimezone($timezone);
|
||||
$dateKeyEnd = $endTmp->format('Ymd');
|
||||
|
||||
do {
|
||||
$dateKey = $beginTmp->format('Ymd');
|
||||
|
||||
if (!isset($results[$dateKey])) {
|
||||
$results[$dateKey] = [
|
||||
'rate' => 0,
|
||||
'duration' => 0,
|
||||
'month' => $beginTmp->format('n'),
|
||||
'year' => $beginTmp->format('Y'),
|
||||
'day' => $beginTmp->format('j'),
|
||||
'details' => []
|
||||
];
|
||||
}
|
||||
|
||||
if ($dateKey !== $dateKeyEnd) {
|
||||
$newDateBegin = clone $beginTmp;
|
||||
$newDateBegin->add(new \DateInterval('P1D'));
|
||||
// overlapping records should always start at midnight
|
||||
$newDateBegin->setTime(0, 0, 0);
|
||||
} else {
|
||||
$newDateBegin = clone $endTmp;
|
||||
}
|
||||
|
||||
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
|
||||
$durationPercent = 0;
|
||||
if ($result->getDuration() !== null && $result->getDuration() > 0) {
|
||||
$durationPercent = $duration / $result->getDuration();
|
||||
}
|
||||
$rate = $result->getRate() * $durationPercent;
|
||||
// make sure to exclude entries that are outside the requested timerange:
|
||||
// these entries can exist if you have long running entries that started before $begin
|
||||
// for statistical reasons we have to include everything between $begin and $end while
|
||||
// excluding everything that is outside of that range
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
|
||||
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
|
||||
// before $begin (which happens thanks to the timezone conversion when querying the database)
|
||||
if ($newDateBegin > $begin && $beginTmp < $end) {
|
||||
if (!isset($results[$dateKey])) {
|
||||
$results[$dateKey] = [
|
||||
'rate' => 0,
|
||||
'duration' => 0,
|
||||
'month' => $beginTmp->format('n'),
|
||||
'year' => $beginTmp->format('Y'),
|
||||
'day' => $beginTmp->format('j'),
|
||||
'details' => []
|
||||
];
|
||||
}
|
||||
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
|
||||
$durationPercent = 0;
|
||||
if ($result->getDuration() !== null && $result->getDuration() > 0) {
|
||||
$durationPercent = $duration / $result->getDuration();
|
||||
}
|
||||
$rate = $result->getRate() * $durationPercent;
|
||||
|
||||
$results[$dateKey]['rate'] += $rate;
|
||||
$results[$dateKey]['duration'] += $duration;
|
||||
$detailsId = $result->getProject()->getCustomer()->getId() . '_' . $result->getProject()->getId();
|
||||
if (!isset($results[$dateKey]['details'][$detailsId])) {
|
||||
$results[$dateKey]['details'][$detailsId] = [
|
||||
'project' => $result->getProject(),
|
||||
'activity' => $result->getActivity(),
|
||||
'duration' => 0,
|
||||
'rate' => 0,
|
||||
];
|
||||
$results[$dateKey]['rate'] += $rate;
|
||||
$results[$dateKey]['duration'] += $duration;
|
||||
$detailsId = $result->getProject()->getCustomer()->getId() . '_' . $result->getProject()->getId();
|
||||
if (!isset($results[$dateKey]['details'][$detailsId])) {
|
||||
$results[$dateKey]['details'][$detailsId] = [
|
||||
'project' => $result->getProject(),
|
||||
'activity' => $result->getActivity(),
|
||||
'duration' => 0,
|
||||
'rate' => 0,
|
||||
];
|
||||
|
||||
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
|
||||
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
||||
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
|
||||
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
||||
}
|
||||
}
|
||||
|
||||
$beginTmp = $newDateBegin;
|
||||
|
||||
// yes, we only want to compare the day, not the time
|
||||
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
|
||||
break 1;
|
||||
}
|
||||
@@ -447,13 +478,13 @@ class TimesheetRepository extends EntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @param User|null $user
|
||||
* @param DateTime $begin
|
||||
* @param DateTime $end
|
||||
* @return Day[]
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getDailyStats(User $user, DateTime $begin, DateTime $end): array
|
||||
public function getDailyStats(?User $user, DateTime $begin, DateTime $end): array
|
||||
{
|
||||
/** @var Day[] $days */
|
||||
$days = [];
|
||||
@@ -474,7 +505,13 @@ class TimesheetRepository extends EntityRepository
|
||||
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
|
||||
$dateTime->setTime(0, 0, 0);
|
||||
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
|
||||
$days[$dateTime->format('Ymd')] = $day;
|
||||
$day->setDetails($statRow['details']);
|
||||
$dateKey = $dateTime->format('Ymd');
|
||||
// make sure entries from other timezones are filtered
|
||||
if (!\array_key_exists($dateKey, $days)) {
|
||||
continue;
|
||||
}
|
||||
$days[$dateKey] = $day;
|
||||
}
|
||||
|
||||
ksort($days);
|
||||
@@ -677,7 +714,7 @@ class TimesheetRepository extends EntityRepository
|
||||
if (empty($user) && null !== $query->getCurrentUser()) {
|
||||
$currentUser = $query->getCurrentUser();
|
||||
|
||||
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) {
|
||||
if (!$currentUser->canSeeAllData()) {
|
||||
// make sure that the user himself is in the list of users, if he is part of a team
|
||||
// if teams are used and the user is not a teamlead, the list of users would be empty and then leading to NOT limit the select by user IDs
|
||||
$user[] = $currentUser;
|
||||
@@ -955,7 +992,7 @@ class TimesheetRepository extends EntityRepository
|
||||
$qb->setParameter('end', $timesheet->getEnd());
|
||||
}
|
||||
|
||||
$qb->select('t')
|
||||
$qb->select($qb->expr()->count('t.id'))
|
||||
->from(Timesheet::class, 't')
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
@@ -964,8 +1001,12 @@ class TimesheetRepository extends EntityRepository
|
||||
->setParameter('user', $timesheet->getUser())
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getResult();
|
||||
try {
|
||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
||||
} catch (\Exception $ex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !empty($result);
|
||||
return $result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,22 +17,30 @@ class UserDateTimeFactory
|
||||
/**
|
||||
* @var \DateTimeZone
|
||||
*/
|
||||
protected $timezone;
|
||||
private $timezone;
|
||||
/**
|
||||
* @var CurrentUser
|
||||
*/
|
||||
private $user;
|
||||
|
||||
public function __construct(CurrentUser $user)
|
||||
{
|
||||
$timezone = date_default_timezone_get();
|
||||
|
||||
$user = $user->getUser();
|
||||
if ($user instanceof User) {
|
||||
$timezone = $user->getTimezone();
|
||||
}
|
||||
|
||||
$this->timezone = new \DateTimeZone($timezone);
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function getTimezone(): \DateTimeZone
|
||||
{
|
||||
if (null === $this->timezone) {
|
||||
$timezone = date_default_timezone_get();
|
||||
|
||||
$user = $this->user->getUser();
|
||||
if ($user instanceof User) {
|
||||
$timezone = $user->getTimezone();
|
||||
}
|
||||
|
||||
$this->timezone = new \DateTimeZone($timezone);
|
||||
}
|
||||
|
||||
return $this->timezone;
|
||||
}
|
||||
|
||||
@@ -54,7 +62,7 @@ class UserDateTimeFactory
|
||||
|
||||
public function createDateTime(string $datetime = 'now'): \DateTime
|
||||
{
|
||||
$date = new \DateTime($datetime, $this->timezone);
|
||||
$date = new \DateTime($datetime, $this->getTimezone());
|
||||
|
||||
return $date;
|
||||
}
|
||||
@@ -66,7 +74,7 @@ class UserDateTimeFactory
|
||||
*/
|
||||
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now')
|
||||
{
|
||||
$date = \DateTime::createFromFormat($format, $datetime, $this->timezone);
|
||||
$date = \DateTime::createFromFormat($format, $datetime, $this->getTimezone());
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* Date specific twig extensions
|
||||
@@ -86,6 +87,20 @@ class DateExtensions extends AbstractExtension
|
||||
];
|
||||
}
|
||||
|
||||
public function getTests()
|
||||
{
|
||||
return [
|
||||
new TwigTest('weekend', function ($dateTime) {
|
||||
if (!$dateTime instanceof \DateTime) {
|
||||
return false;
|
||||
}
|
||||
$day = (int) $dateTime->format('w');
|
||||
|
||||
return ($day === 0 || $day === 6);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -217,6 +232,7 @@ class DateExtensions extends AbstractExtension
|
||||
|
||||
public function monthName(\DateTime $dateTime): string
|
||||
{
|
||||
// @see http://userguide.icu-project.org/formatparse/datetime
|
||||
$formatter = new \IntlDateFormatter(
|
||||
$this->locale,
|
||||
\IntlDateFormatter::FULL,
|
||||
@@ -229,15 +245,16 @@ class DateExtensions extends AbstractExtension
|
||||
return $formatter->format($dateTime);
|
||||
}
|
||||
|
||||
public function dayName(\DateTime $dateTime): string
|
||||
public function dayName(\DateTime $dateTime, bool $short = false): string
|
||||
{
|
||||
// @see http://userguide.icu-project.org/formatparse/datetime
|
||||
$formatter = new \IntlDateFormatter(
|
||||
$this->locale,
|
||||
\IntlDateFormatter::FULL,
|
||||
\IntlDateFormatter::FULL,
|
||||
$dateTime->getTimezone()->getName(),
|
||||
\IntlDateFormatter::GREGORIAN,
|
||||
'EEEE'
|
||||
$short ? 'EE' : 'EEEE'
|
||||
);
|
||||
|
||||
return $formatter->format($dateTime);
|
||||
|
||||
@@ -71,6 +71,7 @@ final class IconExtension extends AbstractExtension
|
||||
'profile-stats' => 'far fa-chart-bar',
|
||||
'project' => 'fas fa-briefcase',
|
||||
'repeat' => 'fas fa-redo-alt',
|
||||
'reporting' => 'far fa-chart-bar',
|
||||
'right' => 'fas fa-chevron-right',
|
||||
'roles' => 'fas fa-user-shield',
|
||||
'search' => 'fas fa-search',
|
||||
|
||||
67
src/Twig/ReportingExtension.php
Normal file
67
src/Twig/ReportingExtension.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?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\Twig;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\ReportingEvent;
|
||||
use App\Reporting\Report;
|
||||
use App\Reporting\ReportInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class ReportingExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var AuthorizationCheckerInterface
|
||||
*/
|
||||
private $security;
|
||||
|
||||
public function __construct(EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $security)
|
||||
{
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('available_reports', [$this, 'getAvailableReports'], []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @return ReportInterface[]
|
||||
*/
|
||||
public function getAvailableReports(User $user): array
|
||||
{
|
||||
$event = new ReportingEvent($user);
|
||||
|
||||
if ($this->security->isGranted('view_reporting')) {
|
||||
$event->addReport(new Report('month_by_user', 'report_user_month', 'report_user_month'));
|
||||
if ($this->security->isGranted('view_other_timesheet')) {
|
||||
$event->addReport(new Report('monthly_users_list', 'report_monthly_users', 'report_monthly_users'));
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getReports();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user