added reporting screen (#1805)

This commit is contained in:
Kevin Papst
2020-07-12 14:05:14 +02:00
committed by GitHub
parent b97d9f692d
commit 164af7ae02
44 changed files with 1294 additions and 79 deletions

View File

@@ -24,3 +24,4 @@
@import 'sweetalert';
@import 'autocomplete';
@import 'selectpicker';
@import 'reporting';

View File

@@ -0,0 +1,6 @@
.dataTable {
.weekend {
background-color: #f9f9f9;
opacity: 0.6;
}
}

View File

@@ -108,16 +108,17 @@ kimai:
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
REPORTING: ['view_reporting']
# some single default definitions for roles
SINGLE_USER: ['view_team_member','budget_team_project']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data']
# link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@SINGLE_SUPER_ADMIN']
ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@
"build/app.6c4a170e.js"
],
"css": [
"build/app.a2628675.css"
"build/app.f587e075.css"
]
},
"invoice": {
@@ -54,7 +54,7 @@
"build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT",
"build/1.c24a9c6f.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.6c4a170e.js": "sha384-ufY9u+2gN4IWz+QdbXxvILeX77+QL466MoGB0dM8wfXx7rK8j6epLN6Kmfi9I618",
"build/app.a2628675.css": "sha384-0MsxtEEys+bTKR6Qi9LvMRg21TPh2x7JxTdVFTmySyjct4BpB9xuffwyk1BDsZDe",
"build/app.f587e075.css": "sha384-0mHNUhjotTlRm9f3UTcbnM7KOB9m7f6Gl8Afio8/sr+bv5DLXD8dxPAXkQ0JJB02",
"build/invoice.50473330.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
"build/invoice-pdf.1db2b744.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",

View File

@@ -2,7 +2,7 @@
"build/0.f7fae2b9.js": "build/0.f7fae2b9.js",
"build/1.c24a9c6f.js": "build/1.c24a9c6f.js",
"build/2.a47ad919.js": "build/2.a47ad919.js",
"build/app.css": "build/app.a2628675.css",
"build/app.css": "build/app.f587e075.css",
"build/app.js": "build/app.6c4a170e.js",
"build/calendar.css": "build/calendar.c8f3c1fd.css",
"build/calendar.js": "build/calendar.e07134af.js",

View File

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

View 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;
}
}

View 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);
}
}

View File

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

View File

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

View 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';
}
}

View File

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

View 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;
}
}

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

View 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;
}
}

View 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
View 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;
}
}

View 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;
}

View File

@@ -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,30 +366,65 @@ 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 ($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;
}
// 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,
@@ -397,15 +435,6 @@ class TimesheetRepository extends EntityRepository
'details' => []
];
}
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new \DateInterval('P1D'));
$newDateBegin->setTime(0, 0, 0);
} else {
$newDateBegin = clone $endTmp;
}
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
@@ -427,9 +456,11 @@ class TimesheetRepository extends EntityRepository
$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;
}
}

View File

@@ -17,13 +17,23 @@ class UserDateTimeFactory
/**
* @var \DateTimeZone
*/
protected $timezone;
private $timezone;
/**
* @var CurrentUser
*/
private $user;
public function __construct(CurrentUser $user)
{
$this->user = $user;
}
public function getTimezone(): \DateTimeZone
{
if (null === $this->timezone) {
$timezone = date_default_timezone_get();
$user = $user->getUser();
$user = $this->user->getUser();
if ($user instanceof User) {
$timezone = $user->getTimezone();
}
@@ -31,8 +41,6 @@ class UserDateTimeFactory
$this->timezone = new \DateTimeZone($timezone);
}
public function getTimezone(): \DateTimeZone
{
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;
}

View File

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

View File

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

View 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();
}
}

View File

@@ -45,3 +45,29 @@
{{ block('form_widget_simple') }}
{% endif %}
{%- endblock text_widget %}
{% block monthpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{previousMonth|date_short}}').change()">
<i class="{{ 'left'|icon }}"></i>
</a>
<a class="btn btn-default" href="#" onclick="return false;">
<span id="{{ form.vars.id }}_month_name">{{ data|month_name|trans ~ ' ' ~ data|date_format('Y') }}</span>
</a>
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{nextMonth|date_short}}').change()">
<i class="{{ 'right'|icon }}"></i>
</a>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
$('#{{ form.vars.id }}').on('change', function(ev) {
var newDate = moment($(this).val(), '{{ momentFormat }}').format('MMMM YYYY');
$('#{{ form.vars.id }}_month_name').html(
newDate[0].toUpperCase() + newDate.slice(1)
);
});
});
</script>
{% set type = 'hidden' %}
{{ block('form_widget_simple') }}
{%- endblock monthpicker_widget %}

View File

@@ -0,0 +1,11 @@
{% macro reporting() %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set children = {} %}
{% for id, report in available_reports(app.user) %}
{% set children = children|merge({(report.id): {'title': report.label|trans({}, 'reporting'), 'url': path(report.route), 'class': 'toolbar-action report-' ~ report.id}}) %}
{% endfor %}
{% set actions = actions|merge({'reporting': {'children': children}}) %}
{% set event = trigger('actions.reporting', {'actions': actions}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% import "reporting/actions.html.twig" as actions %}
{% block page_title %}{{ 'reporting.title'|trans({}, 'reporting') }}{% endblock %}
{% block page_subtitle %}{% block report_title %}{% endblock %}{% endblock %}
{% block page_actions %}{{ actions.reporting() }}{% endblock %}
{% block main %}
<div class="row">
<div class="col-md-12">
{% block report %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends 'reporting/layout.html.twig' %}
{% block report_title %}{{ 'report_user_month'|trans({}, 'reporting') }}{% endblock %}
{% block report %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_before %}
{{ form_start(form, {'action': path('report_user_month'), 'attr': {'class': 'form-inline'}}) }}
{% endblock %}
{% block box_after %}
{{ form_end(form) }}
{% endblock %}
{% block box_title %}
{% if form.user is defined %}
{{ form_widget(form.user) }}
{% else %}
{{ widgets.username(user) }}
{% endif %}
{{ form_widget(form.date) }}
{% endblock %}
{% block box_body_class %}user-month-reporting-box no-padding table-responsive{% endblock %}
{% block box_body %}
<table class="table table-bordered table-hover dataTable">
<tr>
<th></th>
{% for day in days %}
<th class="text-center text-nowrap{% if day.day is weekend %} weekend{% endif %}">
{{ day.day|day_name(true) }}<br>
{{ day.day|date_format('d.m') }}
</th>
{% endfor %}
<th></th>
</tr>
{% for project in rows %}
<tr>
<td class="text-nowrap">
<strong>{{ widgets.label_project(project.project) }}</strong>
</td>
{% for day in project.days %}
<td class="text-nowrap{% if day.date is weekend %} weekend{% endif %}">
{% if day.duration > 0 %}
{{ day.duration|duration }}
{% endif %}
</td>
{% endfor %}
<th class="text-nowrap">{{ project.duration|duration }}</th>
</tr>
{% for activity in project.activities %}
<tr>
<td class="text-nowrap">
{{ widgets.label_activity(activity.activity) }}
</td>
{% for day in activity.days %}
<td class="text-nowrap{% if day.date is weekend %} weekend{% endif %}">
{% if day.duration > 0 %}
{{ day.duration|duration }}
{% endif %}
</td>
{% endfor %}
<th class="text-nowrap">{{ activity.duration|duration }}</th>
</tr>
{% endfor %}
{% endfor %}
{% set total = 0 %}
<tr>
<th></th>
{% for day in days %}
<th class="text-nowrap{% if day.day is weekend %} weekend{% endif %}">
{% if day.totalDuration > 0 %}
{{ day.totalDuration|duration }}
{% set total = total + day.totalDuration %}
{% endif %}
</th>
{% endfor %}
<th class="text-nowrap">{{ total|duration }}</th>
</tr>
</table>
{% endblock %}
{% endembed %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
{% if form.user is defined %}
$('#{{ form.user.vars.id }}').on('change', function(ev) {
$(this).closest('form').submit();
});
{% endif %}
$('#{{ form.date.vars.id }}').on('change', function(ev) {
$(this).closest('form').submit();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends 'reporting/layout.html.twig' %}
{% block report_title %}{{ 'report_monthly_users'|trans({}, 'reporting') }}{% endblock %}
{% block report %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_before %}
{{ form_start(form, {'action': path('report_monthly_users'), 'attr': {'class': 'form-inline'}}) }}
{% endblock %}
{% block box_after %}
{{ form_end(form) }}
{% endblock %}
{% block box_title %}
{{ form_widget(form.date) }}
{% endblock %}
{% block box_body_class %}monthly-user-list-reporting-box no-padding table-responsive{% endblock %}
{% block box_body %}
<table class="table table-bordered table-hover dataTable">
<tr>
<th></th>
{% for day in days %}
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}">
{{ day|day_name(true) }}<br>
{{ day|date_format('d.m') }}
</th>
{% endfor %}
<th></th>
</tr>
{% for userDay in rows %}
{% set usersMonthDuration = 0 %}
<tr>
<td class="text-nowrap">
<strong>{{ widgets.username(userDay.user) }}</strong>
</td>
{% for day in userDay.days %}
<td class="text-nowrap{% if day.day is weekend %} weekend{% endif %}">
{% if day.totalDuration > 0 %}
{{ day.totalDuration|duration }}
{% set usersMonthDuration = usersMonthDuration + day.totalDuration %}
{% endif %}
</td>
{% endfor %}
<th class="text-nowrap">{{ usersMonthDuration|duration }}</th>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
$('#{{ form.date.vars.id }}').on('change', function(ev) {
$(this).closest('form').submit();
});
});
</script>
{% endblock %}

View File

@@ -33,7 +33,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 112);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 113);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -0,0 +1,47 @@
<?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\Controller;
use App\Entity\User;
/**
* @group integration
*/
class ReportingControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/reporting');
}
public function testMonthlyListIsSecure()
{
$this->assertUrlIsSecured('/reporting/monthly_users_list');
}
public function testMonthlyUsersListIsSecureForUserRole()
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/reporting/monthly_users_list');
}
public function testDefaultUsersMonthReport()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/reporting/');
self::assertStringContainsString('<div class="box-body user-month-reporting-box', $client->getResponse()->getContent());
}
public function testMonthlyUsersReport()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/reporting/monthly_users_list');
self::assertStringContainsString('<div class="box-body monthly-user-list-reporting-box', $client->getResponse()->getContent());
}
}

View File

@@ -30,13 +30,15 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
$resolver = new OptionsResolver();
$sut = new EnhancedChoiceTypeExtension();
$sut->configureOptions($resolver);
self::assertEquals(['selectpicker', 'search'], $resolver->getDefinedOptions());
self::assertEquals(['selectpicker', 'width', 'search'], $resolver->getDefinedOptions());
self::assertTrue($resolver->hasDefault('selectpicker'));
self::assertTrue($resolver->hasDefault('width'));
self::assertTrue($resolver->hasDefault('search'));
self::assertFalse($resolver->isRequired('selectpicker'));
self::assertFalse($resolver->isRequired('width'));
self::assertFalse($resolver->isRequired('search'));
$result = $resolver->resolve([]);
self::assertEquals(['selectpicker' => true, 'search' => true], $result);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result);
}
}

View File

@@ -37,7 +37,7 @@ class CurrentUserFactory extends AbstractMockFactory
$repository = $this->getMockBuilder(UserRepository::class)->onlyMethods(['getUserById'])->disableOriginalConstructor()->getMock();
$repository->expects(TestCase::atMost(1))->method('getUserById')->willReturn($user);
$token = $this->getMockBuilder(UsernamePasswordToken::class)->onlyMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects(TestCase::atLeast(1))->method('getUser')->willReturn($user);
$token->method('getUser')->willReturn($user);
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);

View File

@@ -24,6 +24,7 @@ class DayTest extends TestCase
$sut = new Day($date, 12340, 197.25956);
$this->assertSame($date, $sut->getDay());
$this->assertEquals([], $sut->getDetails());
$this->assertEquals(12340, $sut->getTotalDuration());
$this->assertEquals(197.25956, $sut->getTotalRate());
}
@@ -39,4 +40,13 @@ class DayTest extends TestCase
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
}
public function testSetDetails()
{
$sut = new Day(new DateTime(), 12340, 197.25956);
$sut->setDetails(['foo' => ['bar' => '1212e'], 'hello' => 'world']);
$this->assertEquals(['foo' => ['bar' => '1212e'], 'hello' => 'world'], $sut->getDetails());
}
}

View File

@@ -0,0 +1,29 @@
<?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\Reporting;
use App\Reporting\Report;
use App\Reporting\ReportInterface;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Reporting\Report
*/
class ReportTest extends TestCase
{
public function testEmptyObject()
{
$report = new Report('id', 'route', 'label');
self::assertInstanceOf(ReportInterface::class, $report);
self::assertEquals('id', $report->getId());
self::assertEquals('route', $report->getRoute());
self::assertEquals('label', $report->getLabel());
}
}

View File

@@ -120,17 +120,19 @@ class DateExtensionsTest extends TestCase
/**
* @dataProvider getDayNameTestData
*/
public function testDayName(string $locale, string $date, string $expectedName)
public function testDayName(string $locale, string $date, string $expectedName, bool $short)
{
$sut = $this->getSut($locale, []);
self::assertEquals($expectedName, $sut->dayName(new \DateTime($date)));
self::assertEquals($expectedName, $sut->dayName(new \DateTime($date), $short));
}
public function getDayNameTestData()
{
return [
['de', '2020-07-09 12:00:00', 'Donnerstag'],
['en', '2020-07-09 12:00:00', 'Thursday']
['de', '2020-07-09 12:00:00', 'Donnerstag', false],
['en', '2020-07-09 12:00:00', 'Thursday', false],
['de', '2020-07-09 12:00:00', 'Do.', true],
['en', '2020-07-09 12:00:00', 'Thu', true],
];
}

View File

@@ -39,7 +39,7 @@ class IconExtensionTest extends TestCase
'delete', 'repeat', 'edit', 'manual', 'help', 'start', 'start-small', 'stop', 'stop-small', 'filter',
'create', 'list', 'print', 'visibility', 'calendar', 'money', 'duration', 'download', 'copy', 'settings',
'export', 'pdf', 'csv', 'ods', 'xlsx', 'on', 'off', 'audit', 'home', 'shop', 'about', 'debug', 'profile-stats',
'profile', 'warning', 'permissions', 'back', 'tag', 'avatar', 'timesheet-team', 'plugin', 'configuration'
'profile', 'warning', 'permissions', 'back', 'tag', 'avatar', 'timesheet-team', 'plugin', 'configuration', 'reporting'
];
// test pre-defined icons

View File

@@ -0,0 +1,62 @@
<?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\User;
use App\Twig\ReportingExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Twig\TwigFunction;
/**
* @covers \App\Twig\ReportingExtension
*/
class ReportingExtensionTest extends TestCase
{
protected function getSut(bool $isGranted = false): ReportingExtension
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->method('isGranted')->willReturn($isGranted);
return new ReportingExtension($dispatcher, $security);
}
public function testGetFunctions()
{
$sut = $this->getSut();
$functions = ['available_reports'];
$twigFunctions = $sut->getFunctions();
$this->assertCount(\count($functions), $twigFunctions);
$i = 0;
/** @var TwigFunction $function */
foreach ($twigFunctions as $function) {
$this->assertInstanceOf(TwigFunction::class, $function);
$this->assertEquals($functions[$i++], $function->getName());
}
}
public function testGetAvailableReports()
{
$sut = $this->getSut();
$reports = $sut->getAvailableReports(new User());
self::assertIsArray($reports);
self::assertEmpty($reports);
}
public function testGetAvailableReportsWithPermission()
{
$sut = $this->getSut(true);
$reports = $sut->getAvailableReports(new User());
self::assertIsArray($reports);
self::assertCount(2, $reports);
}
}

View 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\Tests\Twig;
use App\Export\ServiceExport;
use App\Twig\TimesheetExtension;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
/**
* @covers \App\Twig\TimesheetExtension
*/
class TimesheetExtensionTest extends TestCase
{
protected function getSut(): TimesheetExtension
{
$service = new ServiceExport();
return new TimesheetExtension($service);
}
public function testGetFunctions()
{
$sut = $this->getSut();
$functions = ['timesheet_exporter'];
$twigFunctions = $sut->getFunctions();
$this->assertCount(\count($functions), $twigFunctions);
$i = 0;
/** @var TwigFunction $function */
foreach ($twigFunctions as $function) {
$this->assertInstanceOf(TwigFunction::class, $function);
$this->assertEquals($functions[$i++], $function->getName());
}
}
public function testGetExporter()
{
$sut = $this->getSut();
self::assertEquals([], $sut->getTimesheetExporter());
}
}

View File

@@ -103,7 +103,7 @@ class DailyWorkingTimeChartTest extends TestCase
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) {
return [
['year' => $begin->format('Y'), 'month' => $begin->format('n'), 'day' => $begin->format('j'), 'rate' => 13.75, 'duration' => 1234]
['year' => $begin->format('Y'), 'month' => $begin->format('n'), 'day' => $begin->format('j'), 'rate' => 13.75, 'duration' => 1234, 'details' => []]
];
});

View File

@@ -144,6 +144,10 @@
<source>menu.export</source>
<target>Export</target>
</trans-unit>
<trans-unit id="menu.reporting">
<source>menu.reporting</source>
<target>Reporting</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source>
<target>Zeiterfassung</target>

View File

@@ -144,6 +144,10 @@
<source>menu.export</source>
<target>Export</target>
</trans-unit>
<trans-unit id="menu.reporting">
<source>menu.reporting</source>
<target>Reporting</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source>
<target>Timesheets</target>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="reporting.en.xlf">
<body>
<trans-unit id="reporting.title">
<source>reporting.title</source>
<target>Reporting</target>
</trans-unit>
<trans-unit id="report_user_month">
<source>report_user_month</source>
<target>Monatsansicht für einen Benutzer</target>
</trans-unit>
<trans-unit id="report_monthly_users">
<source>report_monthly_users</source>
<target>Monatsansicht für alle Benutzer</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="none">
<body>
<trans-unit id="reporting.title">
<source>reporting.title</source>
<target>Reporting</target>
</trans-unit>
<trans-unit id="report_user_month">
<source>report_user_month</source>
<target>Monthly view for one user</target>
</trans-unit>
<trans-unit id="report_monthly_users">
<source>report_monthly_users</source>
<target>Monthly view for all users</target>
</trans-unit>
</body>
</file>
</xliff>