added reporting screen (#1805)
This commit is contained in:
@@ -24,3 +24,4 @@
|
|||||||
@import 'sweetalert';
|
@import 'sweetalert';
|
||||||
@import 'autocomplete';
|
@import 'autocomplete';
|
||||||
@import 'selectpicker';
|
@import 'selectpicker';
|
||||||
|
@import 'reporting';
|
||||||
|
|||||||
6
assets/sass/reporting.scss
Normal file
6
assets/sass/reporting.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.dataTable {
|
||||||
|
.weekend {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,16 +108,17 @@ kimai:
|
|||||||
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
|
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
|
||||||
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
||||||
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
||||||
|
REPORTING: ['view_reporting']
|
||||||
# some single default definitions for roles
|
# some single default definitions for roles
|
||||||
SINGLE_USER: ['view_team_member','budget_team_project']
|
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_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_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']
|
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
|
# link above sets to one complete set for each user role
|
||||||
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
|
ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER']
|
||||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
|
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','@SINGLE_ADMIN']
|
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','@SINGLE_SUPER_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"])
|
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
|
||||||
maps:
|
maps:
|
||||||
ROLE_USER: ['ROLE_USER']
|
ROLE_USER: ['ROLE_USER']
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@
|
|||||||
"build/app.6c4a170e.js"
|
"build/app.6c4a170e.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"build/app.a2628675.css"
|
"build/app.f587e075.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"invoice": {
|
"invoice": {
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT",
|
"build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT",
|
||||||
"build/1.c24a9c6f.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
|
"build/1.c24a9c6f.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
|
||||||
"build/app.6c4a170e.js": "sha384-ufY9u+2gN4IWz+QdbXxvILeX77+QL466MoGB0dM8wfXx7rK8j6epLN6Kmfi9I618",
|
"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.50473330.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
|
||||||
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
|
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
|
||||||
"build/invoice-pdf.1db2b744.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",
|
"build/invoice-pdf.1db2b744.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"build/0.f7fae2b9.js": "build/0.f7fae2b9.js",
|
"build/0.f7fae2b9.js": "build/0.f7fae2b9.js",
|
||||||
"build/1.c24a9c6f.js": "build/1.c24a9c6f.js",
|
"build/1.c24a9c6f.js": "build/1.c24a9c6f.js",
|
||||||
"build/2.a47ad919.js": "build/2.a47ad919.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/app.js": "build/app.6c4a170e.js",
|
||||||
"build/calendar.css": "build/calendar.c8f3c1fd.css",
|
"build/calendar.css": "build/calendar.c8f3c1fd.css",
|
||||||
"build/calendar.js": "build/calendar.e07134af.js",
|
"build/calendar.js": "build/calendar.e07134af.js",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ final class PermissionController extends AbstractController
|
|||||||
new PermissionSection('Timesheet', '_timesheet'),
|
new PermissionSection('Timesheet', '_timesheet'),
|
||||||
new PermissionSection('Timesheet (other)', '_other_timesheet'),
|
new PermissionSection('Timesheet (other)', '_other_timesheet'),
|
||||||
new PermissionSection('Timesheet (own)', '_own_timesheet'),
|
new PermissionSection('Timesheet (own)', '_own_timesheet'),
|
||||||
|
new PermissionSection('Reporting', '_reporting'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$event = new PermissionSectionsEvent();
|
$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);
|
$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')) {
|
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 = 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']);
|
$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'] = [];
|
$view->vars['attr'] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$extendedOptions = ['class' => 'selectpicker', 'data-width' => '100%'];
|
$extendedOptions = ['class' => 'selectpicker'];
|
||||||
if (!$options['search']) {
|
|
||||||
|
if (false !== $options['width']) {
|
||||||
|
$extendedOptions['data-width'] = $options['width'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $options['search']) {
|
||||||
$extendedOptions['data-minimum-results-for-search'] = 'Infinity';
|
$extendedOptions['data-minimum-results-for-search'] = 'Infinity';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,10 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
|||||||
$resolver->setAllowedTypes('selectpicker', 'boolean');
|
$resolver->setAllowedTypes('selectpicker', 'boolean');
|
||||||
$resolver->setDefault('selectpicker', true);
|
$resolver->setDefault('selectpicker', true);
|
||||||
|
|
||||||
|
$resolver->setDefined(['width']);
|
||||||
|
$resolver->setAllowedTypes('width', ['string', 'boolean']);
|
||||||
|
$resolver->setDefault('width', '100%');
|
||||||
|
|
||||||
$resolver->setDefined(['search']);
|
$resolver->setDefined(['search']);
|
||||||
$resolver->setAllowedTypes('search', 'boolean');
|
$resolver->setAllowedTypes('search', 'boolean');
|
||||||
$resolver->setDefault('search', true);
|
$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
|
* @var DateTime
|
||||||
*/
|
*/
|
||||||
protected $day;
|
protected $day;
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $details = [];
|
||||||
|
|
||||||
public function __construct(DateTime $day, int $duration, float $rate)
|
public function __construct(DateTime $day, int $duration, float $rate)
|
||||||
{
|
{
|
||||||
@@ -61,4 +65,16 @@ class Day
|
|||||||
|
|
||||||
return $this;
|
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 $begin
|
||||||
* @param DateTime $end
|
* @param DateTime $end
|
||||||
* @param User|null $user
|
* @param User|null $user
|
||||||
@@ -363,30 +366,65 @@ class TimesheetRepository extends EntityRepository
|
|||||||
*/
|
*/
|
||||||
protected function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
|
protected function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
|
||||||
{
|
{
|
||||||
$query = new TimesheetQuery();
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
$query
|
|
||||||
->setBegin($begin)
|
$or = $qb->expr()->orX();
|
||||||
->setEnd($end)
|
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
|
||||||
->setUser($user)
|
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
|
||||||
->setState(TimesheetQuery::STATE_STOPPED)
|
$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 = [];
|
$results = [];
|
||||||
/** @var Timesheet $result */
|
/** @var Timesheet $result */
|
||||||
foreach ($timesheets as $result) {
|
foreach ($timesheets as $result) {
|
||||||
$timezone = new \DateTimeZone($result->getTimezone());
|
|
||||||
/** @var \DateTime $beginTmp */
|
/** @var \DateTime $beginTmp */
|
||||||
$beginTmp = $result->getBegin();
|
$beginTmp = $result->getBegin();
|
||||||
$beginTmp->setTimezone($timezone);
|
|
||||||
/** @var DateTime $endTmp */
|
/** @var DateTime $endTmp */
|
||||||
$endTmp = $result->getEnd();
|
$endTmp = $result->getEnd();
|
||||||
$endTmp->setTimezone($timezone);
|
|
||||||
$dateKeyEnd = $endTmp->format('Ymd');
|
$dateKeyEnd = $endTmp->format('Ymd');
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$dateKey = $beginTmp->format('Ymd');
|
$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])) {
|
if (!isset($results[$dateKey])) {
|
||||||
$results[$dateKey] = [
|
$results[$dateKey] = [
|
||||||
'rate' => 0,
|
'rate' => 0,
|
||||||
@@ -397,15 +435,6 @@ class TimesheetRepository extends EntityRepository
|
|||||||
'details' => []
|
'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();
|
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
|
||||||
$durationPercent = 0;
|
$durationPercent = 0;
|
||||||
if ($result->getDuration() !== null && $result->getDuration() > 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]['duration'] += $duration;
|
||||||
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$beginTmp = $newDateBegin;
|
$beginTmp = $newDateBegin;
|
||||||
|
|
||||||
|
// yes, we only want to compare the day, not the time
|
||||||
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
|
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
|
||||||
break 1;
|
break 1;
|
||||||
}
|
}
|
||||||
@@ -447,13 +478,13 @@ class TimesheetRepository extends EntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param User $user
|
* @param User|null $user
|
||||||
* @param DateTime $begin
|
* @param DateTime $begin
|
||||||
* @param DateTime $end
|
* @param DateTime $end
|
||||||
* @return Day[]
|
* @return Day[]
|
||||||
* @throws \Exception
|
* @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 */
|
/** @var Day[] $days */
|
||||||
$days = [];
|
$days = [];
|
||||||
@@ -474,7 +505,13 @@ class TimesheetRepository extends EntityRepository
|
|||||||
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
|
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
|
||||||
$dateTime->setTime(0, 0, 0);
|
$dateTime->setTime(0, 0, 0);
|
||||||
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
|
$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);
|
ksort($days);
|
||||||
@@ -677,7 +714,7 @@ class TimesheetRepository extends EntityRepository
|
|||||||
if (empty($user) && null !== $query->getCurrentUser()) {
|
if (empty($user) && null !== $query->getCurrentUser()) {
|
||||||
$currentUser = $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
|
// 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
|
// 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;
|
$user[] = $currentUser;
|
||||||
@@ -955,7 +992,7 @@ class TimesheetRepository extends EntityRepository
|
|||||||
$qb->setParameter('end', $timesheet->getEnd());
|
$qb->setParameter('end', $timesheet->getEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
$qb->select('t')
|
$qb->select($qb->expr()->count('t.id'))
|
||||||
->from(Timesheet::class, 't')
|
->from(Timesheet::class, 't')
|
||||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||||
@@ -964,8 +1001,12 @@ class TimesheetRepository extends EntityRepository
|
|||||||
->setParameter('user', $timesheet->getUser())
|
->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,13 +17,23 @@ class UserDateTimeFactory
|
|||||||
/**
|
/**
|
||||||
* @var \DateTimeZone
|
* @var \DateTimeZone
|
||||||
*/
|
*/
|
||||||
protected $timezone;
|
private $timezone;
|
||||||
|
/**
|
||||||
|
* @var CurrentUser
|
||||||
|
*/
|
||||||
|
private $user;
|
||||||
|
|
||||||
public function __construct(CurrentUser $user)
|
public function __construct(CurrentUser $user)
|
||||||
{
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimezone(): \DateTimeZone
|
||||||
|
{
|
||||||
|
if (null === $this->timezone) {
|
||||||
$timezone = date_default_timezone_get();
|
$timezone = date_default_timezone_get();
|
||||||
|
|
||||||
$user = $user->getUser();
|
$user = $this->user->getUser();
|
||||||
if ($user instanceof User) {
|
if ($user instanceof User) {
|
||||||
$timezone = $user->getTimezone();
|
$timezone = $user->getTimezone();
|
||||||
}
|
}
|
||||||
@@ -31,8 +41,6 @@ class UserDateTimeFactory
|
|||||||
$this->timezone = new \DateTimeZone($timezone);
|
$this->timezone = new \DateTimeZone($timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTimezone(): \DateTimeZone
|
|
||||||
{
|
|
||||||
return $this->timezone;
|
return $this->timezone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +62,7 @@ class UserDateTimeFactory
|
|||||||
|
|
||||||
public function createDateTime(string $datetime = 'now'): \DateTime
|
public function createDateTime(string $datetime = 'now'): \DateTime
|
||||||
{
|
{
|
||||||
$date = new \DateTime($datetime, $this->timezone);
|
$date = new \DateTime($datetime, $this->getTimezone());
|
||||||
|
|
||||||
return $date;
|
return $date;
|
||||||
}
|
}
|
||||||
@@ -66,7 +74,7 @@ class UserDateTimeFactory
|
|||||||
*/
|
*/
|
||||||
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now')
|
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now')
|
||||||
{
|
{
|
||||||
$date = \DateTime::createFromFormat($format, $datetime, $this->timezone);
|
$date = \DateTime::createFromFormat($format, $datetime, $this->getTimezone());
|
||||||
|
|
||||||
return $date;
|
return $date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
|||||||
use Twig\Extension\AbstractExtension;
|
use Twig\Extension\AbstractExtension;
|
||||||
use Twig\TwigFilter;
|
use Twig\TwigFilter;
|
||||||
use Twig\TwigFunction;
|
use Twig\TwigFunction;
|
||||||
|
use Twig\TwigTest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date specific twig extensions
|
* 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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -217,6 +232,7 @@ class DateExtensions extends AbstractExtension
|
|||||||
|
|
||||||
public function monthName(\DateTime $dateTime): string
|
public function monthName(\DateTime $dateTime): string
|
||||||
{
|
{
|
||||||
|
// @see http://userguide.icu-project.org/formatparse/datetime
|
||||||
$formatter = new \IntlDateFormatter(
|
$formatter = new \IntlDateFormatter(
|
||||||
$this->locale,
|
$this->locale,
|
||||||
\IntlDateFormatter::FULL,
|
\IntlDateFormatter::FULL,
|
||||||
@@ -229,15 +245,16 @@ class DateExtensions extends AbstractExtension
|
|||||||
return $formatter->format($dateTime);
|
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(
|
$formatter = new \IntlDateFormatter(
|
||||||
$this->locale,
|
$this->locale,
|
||||||
\IntlDateFormatter::FULL,
|
\IntlDateFormatter::FULL,
|
||||||
\IntlDateFormatter::FULL,
|
\IntlDateFormatter::FULL,
|
||||||
$dateTime->getTimezone()->getName(),
|
$dateTime->getTimezone()->getName(),
|
||||||
\IntlDateFormatter::GREGORIAN,
|
\IntlDateFormatter::GREGORIAN,
|
||||||
'EEEE'
|
$short ? 'EE' : 'EEEE'
|
||||||
);
|
);
|
||||||
|
|
||||||
return $formatter->format($dateTime);
|
return $formatter->format($dateTime);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ final class IconExtension extends AbstractExtension
|
|||||||
'profile-stats' => 'far fa-chart-bar',
|
'profile-stats' => 'far fa-chart-bar',
|
||||||
'project' => 'fas fa-briefcase',
|
'project' => 'fas fa-briefcase',
|
||||||
'repeat' => 'fas fa-redo-alt',
|
'repeat' => 'fas fa-redo-alt',
|
||||||
|
'reporting' => 'far fa-chart-bar',
|
||||||
'right' => 'fas fa-chevron-right',
|
'right' => 'fas fa-chevron-right',
|
||||||
'roles' => 'fas fa-user-shield',
|
'roles' => 'fas fa-user-shield',
|
||||||
'search' => 'fas fa-search',
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,3 +45,29 @@
|
|||||||
{{ block('form_widget_simple') }}
|
{{ block('form_widget_simple') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endblock text_widget %}
|
{%- 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 %}
|
||||||
|
|||||||
11
templates/reporting/actions.html.twig
Normal file
11
templates/reporting/actions.html.twig
Normal 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 %}
|
||||||
14
templates/reporting/layout.html.twig
Normal file
14
templates/reporting/layout.html.twig
Normal 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 %}
|
||||||
99
templates/reporting/month_by_user.html.twig
Normal file
99
templates/reporting/month_by_user.html.twig
Normal 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 %}
|
||||||
63
templates/reporting/monthly_user_list.html.twig
Normal file
63
templates/reporting/monthly_user_list.html.twig
Normal 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 %}
|
||||||
@@ -33,7 +33,7 @@ class PermissionControllerTest extends ControllerBaseTest
|
|||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||||
$this->assertAccessIsGranted($client, '/admin/permissions');
|
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||||
$this->assertHasDataTable($client);
|
$this->assertHasDataTable($client);
|
||||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 112);
|
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 113);
|
||||||
$this->assertPageActions($client, [
|
$this->assertPageActions($client, [
|
||||||
'back' => $this->createUrl('/admin/user/'),
|
'back' => $this->createUrl('/admin/user/'),
|
||||||
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||||
|
|||||||
47
tests/Controller/ReportingControllerTest.php
Normal file
47
tests/Controller/ReportingControllerTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,13 +30,15 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
|
|||||||
$resolver = new OptionsResolver();
|
$resolver = new OptionsResolver();
|
||||||
$sut = new EnhancedChoiceTypeExtension();
|
$sut = new EnhancedChoiceTypeExtension();
|
||||||
$sut->configureOptions($resolver);
|
$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('selectpicker'));
|
||||||
|
self::assertTrue($resolver->hasDefault('width'));
|
||||||
self::assertTrue($resolver->hasDefault('search'));
|
self::assertTrue($resolver->hasDefault('search'));
|
||||||
self::assertFalse($resolver->isRequired('selectpicker'));
|
self::assertFalse($resolver->isRequired('selectpicker'));
|
||||||
|
self::assertFalse($resolver->isRequired('width'));
|
||||||
self::assertFalse($resolver->isRequired('search'));
|
self::assertFalse($resolver->isRequired('search'));
|
||||||
|
|
||||||
$result = $resolver->resolve([]);
|
$result = $resolver->resolve([]);
|
||||||
self::assertEquals(['selectpicker' => true, 'search' => true], $result);
|
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class CurrentUserFactory extends AbstractMockFactory
|
|||||||
$repository = $this->getMockBuilder(UserRepository::class)->onlyMethods(['getUserById'])->disableOriginalConstructor()->getMock();
|
$repository = $this->getMockBuilder(UserRepository::class)->onlyMethods(['getUserById'])->disableOriginalConstructor()->getMock();
|
||||||
$repository->expects(TestCase::atMost(1))->method('getUserById')->willReturn($user);
|
$repository->expects(TestCase::atMost(1))->method('getUserById')->willReturn($user);
|
||||||
$token = $this->getMockBuilder(UsernamePasswordToken::class)->onlyMethods(['getUser'])->disableOriginalConstructor()->getMock();
|
$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 = new TokenStorage();
|
||||||
$tokenStorage->setToken($token);
|
$tokenStorage->setToken($token);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class DayTest extends TestCase
|
|||||||
$sut = new Day($date, 12340, 197.25956);
|
$sut = new Day($date, 12340, 197.25956);
|
||||||
|
|
||||||
$this->assertSame($date, $sut->getDay());
|
$this->assertSame($date, $sut->getDay());
|
||||||
|
$this->assertEquals([], $sut->getDetails());
|
||||||
$this->assertEquals(12340, $sut->getTotalDuration());
|
$this->assertEquals(12340, $sut->getTotalDuration());
|
||||||
$this->assertEquals(197.25956, $sut->getTotalRate());
|
$this->assertEquals(197.25956, $sut->getTotalRate());
|
||||||
}
|
}
|
||||||
@@ -39,4 +40,13 @@ class DayTest extends TestCase
|
|||||||
$this->assertEquals(999, $sut->getTotalDuration());
|
$this->assertEquals(999, $sut->getTotalDuration());
|
||||||
$this->assertEquals(0.123456789, $sut->getTotalRate());
|
$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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
tests/Reporting/ReportTest.php
Normal file
29
tests/Reporting/ReportTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,17 +120,19 @@ class DateExtensionsTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* @dataProvider getDayNameTestData
|
* @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, []);
|
$sut = $this->getSut($locale, []);
|
||||||
self::assertEquals($expectedName, $sut->dayName(new \DateTime($date)));
|
self::assertEquals($expectedName, $sut->dayName(new \DateTime($date), $short));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDayNameTestData()
|
public function getDayNameTestData()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['de', '2020-07-09 12:00:00', 'Donnerstag'],
|
['de', '2020-07-09 12:00:00', 'Donnerstag', false],
|
||||||
['en', '2020-07-09 12:00:00', 'Thursday']
|
['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],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class IconExtensionTest extends TestCase
|
|||||||
'delete', 'repeat', 'edit', 'manual', 'help', 'start', 'start-small', 'stop', 'stop-small', 'filter',
|
'delete', 'repeat', 'edit', 'manual', 'help', 'start', 'start-small', 'stop', 'stop-small', 'filter',
|
||||||
'create', 'list', 'print', 'visibility', 'calendar', 'money', 'duration', 'download', 'copy', 'settings',
|
'create', 'list', 'print', 'visibility', 'calendar', 'money', 'duration', 'download', 'copy', 'settings',
|
||||||
'export', 'pdf', 'csv', 'ods', 'xlsx', 'on', 'off', 'audit', 'home', 'shop', 'about', 'debug', 'profile-stats',
|
'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
|
// test pre-defined icons
|
||||||
|
|||||||
62
tests/Twig/ReportingExtensionTest.php
Normal file
62
tests/Twig/ReportingExtensionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/Twig/TimesheetExtensionTest.php
Normal file
48
tests/Twig/TimesheetExtensionTest.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\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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ class DailyWorkingTimeChartTest extends TestCase
|
|||||||
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
|
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
|
||||||
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) {
|
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) {
|
||||||
return [
|
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' => []]
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,10 @@
|
|||||||
<source>menu.export</source>
|
<source>menu.export</source>
|
||||||
<target>Export</target>
|
<target>Export</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="menu.reporting">
|
||||||
|
<source>menu.reporting</source>
|
||||||
|
<target>Reporting</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="menu.admin_timesheet">
|
<trans-unit id="menu.admin_timesheet">
|
||||||
<source>menu.admin_timesheet</source>
|
<source>menu.admin_timesheet</source>
|
||||||
<target>Zeiterfassung</target>
|
<target>Zeiterfassung</target>
|
||||||
|
|||||||
@@ -144,6 +144,10 @@
|
|||||||
<source>menu.export</source>
|
<source>menu.export</source>
|
||||||
<target>Export</target>
|
<target>Export</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="menu.reporting">
|
||||||
|
<source>menu.reporting</source>
|
||||||
|
<target>Reporting</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="menu.admin_timesheet">
|
<trans-unit id="menu.admin_timesheet">
|
||||||
<source>menu.admin_timesheet</source>
|
<source>menu.admin_timesheet</source>
|
||||||
<target>Timesheets</target>
|
<target>Timesheets</target>
|
||||||
|
|||||||
19
translations/reporting.de.xlf
Normal file
19
translations/reporting.de.xlf
Normal 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>
|
||||||
19
translations/reporting.en.xlf
Normal file
19
translations/reporting.en.xlf
Normal 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>
|
||||||
Reference in New Issue
Block a user