dashboard widgets are configurable via config (#269)

This commit is contained in:
Kevin Papst
2018-08-17 23:17:02 +02:00
committed by GitHub
parent 7804ef83ce
commit dbbb434723
49 changed files with 2011 additions and 938 deletions

View File

@@ -10,6 +10,8 @@
namespace App\Controller;
use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Repository\WidgetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -28,13 +30,25 @@ class DashboardController extends Controller
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var WidgetRepository
*/
protected $repository;
/**
* @var array
*/
protected $dashboard;
/**
* @param EventDispatcherInterface $dispatcher
* @param WidgetRepository $repository
* @param array $dashboard
*/
public function __construct(EventDispatcherInterface $dispatcher)
public function __construct(EventDispatcherInterface $dispatcher, WidgetRepository $repository, array $dashboard)
{
$this->eventDispatcher = $dispatcher;
$this->repository = $repository;
$this->dashboard = $dashboard;
}
/**
@@ -45,13 +59,52 @@ class DashboardController extends Controller
{
$event = new DashboardEvent($this->getUser());
foreach ($this->dashboard as $widgetRow) {
if (empty($widgetRow['widgets'])) {
continue;
}
if (!$this->isGranted($widgetRow['permission'])) {
continue;
}
$row = new DashboardSection($widgetRow['title'] ?? null);
$row
->setOrder($widgetRow['order'])
->setType($widgetRow['type'])
;
foreach ($widgetRow['widgets'] as $widgetName) {
if (!$this->repository->has($widgetName)) {
throw new \Exception('Unknwon widget: ' . $widgetName);
}
$row->addWidget($this->repository->get($widgetName, $event->getUser()));
}
$event->addSection($row);
}
$this->eventDispatcher->dispatch(
DashboardEvent::DASHBOARD,
$event
);
$sections = $event->getSections();
uasort(
$sections,
function (DashboardSection $a, DashboardSection $b) {
if ($a->getOrder() == $b->getOrder()) {
return 0;
}
return ($a->getOrder() < $b->getOrder()) ? -1 : 1;
}
);
return $this->render('dashboard/index.html.twig', [
'widget_rows' => $event->getWidgetRows()
'widget_rows' => $sections
]);
}
}

View File

@@ -32,6 +32,9 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
public const MAX_TIMESHEETS_TOTAL = 5000;
public const MIN_RUNNING_TIMESHEETS_PER_USER = 0;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 3;
public const TIMERANGE_DAYS = 1095; // 3 years
public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
public const BATCH_SIZE = 100;
@@ -77,9 +80,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$description,
round($i / 2),
true
$description
);
$manager->persist($entry);
@@ -97,7 +98,8 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
null
null,
false
);
$manager->persist($entry);
}
@@ -140,32 +142,29 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
private function createTimesheetEntry(User $user, Activity $activity, $description, $startDay = 0, $setEndDate = false)
private function createTimesheetEntry(User $user, Activity $activity, $description, $setEndDate = true)
{
$start = new \DateTime();
if ($startDay > 0) {
$start = $start->modify('- ' . (rand(1, $startDay)) . ' days');
}
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
$end = clone $start;
$end = $end->modify('+ ' . (rand(1, 43200)) . ' seconds');
//$duration = $end->modify('- ' . $start->getTimestamp() . ' seconds')->getTimestamp();
$duration = $end->getTimestamp() - $start->getTimestamp();
$rate = $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$entry = new Timesheet();
$entry
->setActivity($activity)
->setDescription($description)
->setUser($user)
->setRate(round(($duration / 3600) * $rate))
->setBegin($start);
if ($setEndDate) {
$end = clone $start;
$end = $end->modify('+ ' . (rand(self::MIN_MINUTES_PER_ENTRY, self::MAX_MINUTES_PER_ENTRY)) . ' minutes');
$duration = $end->getTimestamp() - $start->getTimestamp();
$rate = $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$entry
->setEnd($end)
->setRate(round(($duration / 3600) * $rate))
->setDuration($duration);
}

View File

@@ -37,6 +37,8 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.languages', $config['languages']);
$container->setParameter('kimai.calendar', $config['calendar']);
$container->setParameter('kimai.theme', $config['theme']);
$container->setParameter('kimai.dashboard', $config['dashboard']);
$container->setParameter('kimai.widgets', $config['widgets']);
$this->createUserParameter($config, $container);
$this->createTimesheetParameter($config, $container);

View File

@@ -9,6 +9,8 @@
namespace App\DependencyInjection;
use App\Model\DashboardSection;
use App\Model\Widget;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
@@ -29,156 +31,282 @@ class Configuration implements ConfigurationInterface
$rootNode
->children()
->arrayNode('theme')
->addDefaultsIfNotSet()
->children()
->integerNode('active_warning')
->defaultValue(3)
->end()
->scalarNode('box_color')
->defaultValue('green')
->end()
->end()
->end()
->arrayNode('user')
->addDefaultsIfNotSet()
->children()
->booleanNode('registration')
->defaultTrue()
->end()
->booleanNode('password_reset')
->defaultTrue()
->end()
->end()
->end()
->arrayNode('timesheet')
->children()
->booleanNode('duration_only')
->defaultValue(false)
->end()
->arrayNode('rounding')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->integerNode('begin')
->defaultValue(0)
->end()
->integerNode('end')
->defaultValue(0)
->end()
->integerNode('duration')
->defaultValue(0)
->end()
->end()
->end()
->defaultValue([])
->end()
->arrayNode('rates')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->floatNode('factor')
->isRequired()
->defaultValue(1)
->end()
->end()
->end()
->defaultValue([])
->end()
->end()
->end()
->arrayNode('invoice')
->children()
->arrayNode('renderer')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Controller\InvoiceController::invoiceAction',
])
->end()
->arrayNode('calculator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DefaultCalculator',
])
->end()
->arrayNode('number_generator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DateNumberGenerator',
])
->end()
->end()
->end()
->arrayNode('languages')
->arrayPrototype()
->children()
->scalarNode('date_short')->end()
->end()
->end()
->end()
->arrayNode('calendar')
->children()
->booleanNode('week_numbers')->defaultTrue()->end()
->integerNode('day_limit')->defaultValue(4)->end()
->arrayNode('businessHours')
->addDefaultsIfNotSet()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->prototype('integer')->end()
->defaultValue([1, 2, 3, 4, 5])
->end()
->scalarNode('begin')->defaultValue('08:00')->end()
->scalarNode('end')->defaultValue('20:00')->end()
->end()
->end()
->arrayNode('google')
->addDefaultsIfNotSet()
->children()
->scalarNode('api_key')->defaultNull()->end()
->arrayNode('sources')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->scalarNode('id')->isRequired()->end()
->scalarNode('color')->defaultValue('#ccc')->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->append($this->getUserNode())
->append($this->getTimesheetNode())
->append($this->getInvoiceNode())
->append($this->getLanguagesNode())
->append($this->getCalendarNode())
->append($this->getThemeNode())
->append($this->getDashboardNode())
->append($this->getWidgetsNode())
->end()
->end();
return $treeBuilder;
}
protected function getTimesheetNode()
{
$builder = new TreeBuilder();
$node = $builder->root('timesheet');
$node
->children()
->booleanNode('duration_only')
->defaultValue(false)
->end()
->arrayNode('rounding')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->integerNode('begin')
->defaultValue(0)
->end()
->integerNode('end')
->defaultValue(0)
->end()
->integerNode('duration')
->defaultValue(0)
->end()
->end()
->end()
->defaultValue([])
->end()
->arrayNode('rates')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->floatNode('factor')
->isRequired()
->defaultValue(1)
->end()
->end()
->end()
->defaultValue([])
->end()
->end()
;
return $node;
}
protected function getInvoiceNode()
{
$builder = new TreeBuilder();
$node = $builder->root('invoice');
$node
->children()
->arrayNode('renderer')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Controller\InvoiceController::invoiceAction',
])
->end()
->arrayNode('calculator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DefaultCalculator',
])
->end()
->arrayNode('number_generator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DateNumberGenerator',
])
->end()
->end()
;
return $node;
}
protected function getLanguagesNode()
{
$builder = new TreeBuilder();
$node = $builder->root('languages');
$node
->arrayPrototype()
->children()
->scalarNode('date_short')->end()
->end()
->end()
;
return $node;
}
protected function getCalendarNode()
{
$builder = new TreeBuilder();
$node = $builder->root('calendar');
$node
->children()
->booleanNode('week_numbers')->defaultTrue()->end()
->integerNode('day_limit')->defaultValue(4)->end()
->arrayNode('businessHours')
->addDefaultsIfNotSet()
->children()
->arrayNode('days')
->requiresAtLeastOneElement()
->prototype('integer')->end()
->defaultValue([1, 2, 3, 4, 5])
->end()
->scalarNode('begin')->defaultValue('08:00')->end()
->scalarNode('end')->defaultValue('20:00')->end()
->end()
->end()
->arrayNode('google')
->addDefaultsIfNotSet()
->children()
->scalarNode('api_key')->defaultNull()->end()
->arrayNode('sources')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->children()
->scalarNode('id')->isRequired()->end()
->scalarNode('color')->defaultValue('#ccc')->end()
->end()
->end()
->end()
->end()
->end()
->end()
;
return $node;
}
protected function getThemeNode()
{
$builder = new TreeBuilder();
$node = $builder->root('theme');
$node
->addDefaultsIfNotSet()
->children()
->integerNode('active_warning')
->defaultValue(3)
->end()
->scalarNode('box_color')
->defaultValue('green')
->end()
->end()
;
return $node;
}
protected function getUserNode()
{
$builder = new TreeBuilder();
$node = $builder->root('user');
$node
->addDefaultsIfNotSet()
->children()
->booleanNode('registration')
->defaultTrue()
->end()
->booleanNode('password_reset')
->defaultTrue()
->end()
->end()
;
return $node;
}
protected function getWidgetsNode()
{
$builder = new TreeBuilder();
$node = $builder->root('widgets');
$node
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->addDefaultsIfNotSet()
->children()
->scalarNode('title')->isRequired()->end()
->scalarNode('query')->isRequired()->end()
->booleanNode('user')->defaultFalse()->end()
->scalarNode('begin')->end()
->scalarNode('end')->end()
->scalarNode('icon')->defaultValue('')->end()
->scalarNode('color')->defaultValue('')->end()
->scalarNode('type')
->validate()
->ifNotInArray([Widget::TYPE_COUNTER, Widget::TYPE_MORE])->thenInvalid('Unknown widget type')
->end()
->defaultValue(Widget::TYPE_COUNTER)
->end()
->end()
->end()
;
return $node;
}
protected function getDashboardNode()
{
$builder = new TreeBuilder();
$node = $builder->root('dashboard');
$node
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->arrayPrototype()
->addDefaultsIfNotSet()
->children()
->scalarNode('type')
->validate()
->ifNotInArray([DashboardSection::TYPE_SIMPLE, DashboardSection::TYPE_CHART])->thenInvalid('Unknown section type')
->end()
->defaultValue(DashboardSection::TYPE_SIMPLE)
->end()
->integerNode('order')->defaultValue(0)->end()
->scalarNode('title')->end()
->scalarNode('permission')->isRequired()->end()
->arrayNode('widgets')
->isRequired()
->performNoDeepMerging()
->scalarPrototype()
->end()
->end()
->end()
->end()
;
return $node;
}
}

View File

@@ -10,8 +10,7 @@
namespace App\Event;
use App\Entity\User;
use App\Model\TimesheetGlobalStatistic;
use App\Model\WidgetRow;
use App\Model\DashboardSection;
use Symfony\Component\EventDispatcher\Event;
class DashboardEvent extends Event
@@ -23,13 +22,12 @@ class DashboardEvent extends Event
*/
protected $user;
/**
* @var WidgetRow[]
* @var DashboardSection[]
*/
protected $widgetRows = [];
/**
* @param User $user
* @param TimesheetGlobalStatistic $timesheetStatistic
*/
public function __construct(User $user)
{
@@ -44,15 +42,21 @@ class DashboardEvent extends Event
return $this->user;
}
public function addWidgetRow(WidgetRow $row)
/**
* @param DashboardSection $row
* @return DashboardEvent
*/
public function addSection(DashboardSection $row)
{
$this->widgetRows[] = $row;
return $this;
}
/**
* @return WidgetRow[]
* @return DashboardSection[]
*/
public function getWidgetRows()
public function getSections()
{
return $this->widgetRows;
}

View File

@@ -9,23 +9,19 @@
namespace App\EventSubscriber;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\DashboardEvent;
use App\Model\TimesheetGlobalStatistic;
use App\Model\TimesheetStatistic;
use App\Model\UserStatistic;
use App\Model\WidgetRow;
use App\Repository\Query\TimesheetQuery;
use Doctrine\Common\Persistence\ManagerRegistry;
use App\Model\DashboardSection;
use App\Model\Widget;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\UserRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Used to add Dashboard widgets for a user.
* Used to add Dashboard widgets for users with ROLE_ADMIN.
*/
class DashboardSubscriber implements EventSubscriberInterface
{
@@ -34,18 +30,41 @@ class DashboardSubscriber implements EventSubscriberInterface
*/
protected $security;
/**
* @var ManagerRegistry
* @var UserRepository
*/
protected $registry;
protected $user;
/**
* @var ActivityRepository
*/
protected $activity;
/**
* @var ProjectRepository
*/
protected $project;
/**
* @var CustomerRepository
*/
protected $customer;
/**
* MenuSubscriber constructor.
* @param AuthorizationCheckerInterface $security
* @param UserRepository $user
* @param ActivityRepository $activity
* @param ProjectRepository $project
* @param CustomerRepository $customer
*/
public function __construct(AuthorizationCheckerInterface $security, ManagerRegistry $registry)
{
public function __construct(
AuthorizationCheckerInterface $security,
UserRepository $user,
ActivityRepository $activity,
ProjectRepository $project,
CustomerRepository $customer
) {
$this->security = $security;
$this->registry = $registry;
$this->user = $user;
$this->activity = $activity;
$this->project = $project;
$this->customer = $customer;
}
/**
@@ -64,106 +83,57 @@ class DashboardSubscriber implements EventSubscriberInterface
*/
public function onDashboardEvent(DashboardEvent $event)
{
$timesheetRepo = $this->registry->getRepository(Timesheet::class);
$timesheetGlobal = $timesheetRepo->getGlobalStatistics();
$timesheetUser = $timesheetRepo->getUserStatistics($event->getUser());
$userStats = $this->registry->getRepository(User::class)->getGlobalStatistics();
$this->addUserWidgets($event, $timesheetUser);
if (!$this->security->isGranted('ROLE_TEAMLEAD')) {
if (!$this->security->isGranted(User::ROLE_ADMIN)) {
return;
}
$this->addTeamleadWidgets($event, $timesheetGlobal, $userStats);
if (!$this->security->isGranted('ROLE_ADMIN')) {
return;
}
$this->addAdminWidgets($event, $timesheetGlobal, $userStats);
$this->addAdminWidgets($event);
}
/**
* @param DashboardEvent $event
* @param TimesheetStatistic $timesheet
*/
protected function addUserWidgets(DashboardEvent $event, TimesheetStatistic $timesheet)
{
/*
$row = new WidgetRow('dashboard.you');
$widgets = [
[
'widgets' => [
"{{ widgets.info_box_progress('Bewilligte Stunden', 'Stunden zur Abrechnung bewilligt', 120, 10, 'star') }}",
"{{ widgets.info_box_progress('Umsatz / Monat', '70% Increase in 30 Days', 6830, 30, 'credit-card', 'black') }}",
"{{ widgets.info_box_progress('Stunden persönlich', 'Das ist noch nicht genug', 135, 60, 'hourglass') }}",
"{{ widgets.info_box_progress('Anzahl Benutzer', 'Mehr ist besser!', 5, 90, 'user') }}",
],
],
$event->addWidgetRow($row);
*/
$row = new WidgetRow('profile.stats', 'dashboard.you');
$row
->add("{{ widgets.info_box_counter('stats.durationThisMonth', " . $timesheet->getDurationThisMonth() . "|duration(true), 'far fa-hourglass', 'green') }}")
//->add("{{ widgets.info_box_counter('stats.amountThisMonth', ".$timesheet->getAmountThisMonth()."|money, 'money', 'blue') }}")
->add("{{ widgets.info_box_counter('stats.durationTotal', " . $timesheet->getDurationTotal() . "|duration(true), 'far fa-hourglass', 'red') }}")
//->add("{{ widgets.info_box_counter('stats.amountTotal', ".$timesheet->getAmountTotal()."|money, 'money', 'yellow') }}")
;
$event->addWidgetRow($row);
}
/**
* @param DashboardEvent $event
* @param TimesheetGlobalStatistic $timesheet
* @param UserStatistic $userStats
*/
protected function addTeamleadWidgets(DashboardEvent $event, TimesheetGlobalStatistic $timesheet, UserStatistic $userStats)
{
$row = new WidgetRow('alluser.stats', 'dashboard.all');
$row
->add("{{ widgets.info_box_counter('stats.durationThisMonth', " . $timesheet->getDurationThisMonth() . "|duration(true), 'far fa-hourglass', 'blue') }}")
->add("{{ widgets.info_box_counter('stats.durationTotal', " . $timesheet->getDurationTotal() . "|duration(true), 'far fa-hourglass', 'yellow') }}")
->add("{{ widgets.info_box_counter('stats.activeRecordings', " . $timesheet->getActiveCurrently() . ", 'far fa-hourglass', 'red', path('admin_timesheet', {'state': " . TimesheetQuery::STATE_RUNNING . '})) }}')
;
$event->addWidgetRow($row);
$row = new WidgetRow('user.stats');
$row
->add("{{ widgets.info_box_counter('stats.userTotal', " . $userStats->getTotalAmount() . ", 'user', 'red') }}")
->add("{{ widgets.info_box_counter('stats.userActiveThisMoth', " . $timesheet->getActiveThisMonth() . ", 'user', 'yellow') }}")
->add("{{ widgets.info_box_counter('stats.userActiveEver', " . $timesheet->getActiveTotal() . ", 'user', 'blue') }}")
;
$event->addWidgetRow($row);
}
/**
* @param DashboardEvent $event
* @param TimesheetGlobalStatistic $timesheet
* @param UserStatistic $user
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function addAdminWidgets(DashboardEvent $event, TimesheetGlobalStatistic $timesheet, UserStatistic $user)
protected function addAdminWidgets(DashboardEvent $event)
{
$row = new WidgetRow('alluser.money_stats');
$row
->add("{{ widgets.info_box_counter('stats.amountThisMonth', " . $timesheet->getAmountThisMonth() . "|money, 'far fa-money-bill-alt', 'green') }}")
->add("{{ widgets.info_box_counter('stats.amountTotal', " . $timesheet->getAmountTotal() . "|money, 'far fa-money-bill-alt', 'red') }}")
;
$event->addWidgetRow($row);
$section = new DashboardSection('dashboard.admin');
$section->setOrder(100);
$activity = $this->registry->getRepository(Activity::class)->getGlobalStatistics();
$project = $this->registry->getRepository(Project::class)->getGlobalStatistics();
$customer = $this->registry->getRepository(Customer::class)->getGlobalStatistics();
$row = new WidgetRow('admin.stats', 'dashboard.admin');
$row
->add("{{ widgets.info_box_more('stats.userTotal', " . $user->getTotalAmount() . ", ' ', path('admin_user'), 'user') }}")
->add("{{ widgets.info_box_more('stats.customerTotal', " . $customer->getCount() . ", '', path('admin_customer'), 'customer', 'blue') }}")
->add("{{ widgets.info_box_more('stats.projectsTotal', " . $project->getCount() . ", '', path('admin_project'), 'project', 'yellow') }}")
->add("{{ widgets.info_box_more('stats.activitiesTotal', " . $activity->getCount() . ", '', path('admin_activity'), 'activity', 'purple') }}")
$widget = new Widget('stats.userTotal', $this->user->countUser());
$widget
->setRoute('admin_user')
->setIcon('user')
->setType(Widget::TYPE_MORE)
;
$event->addWidgetRow($row);
$section->addWidget($widget);
$widget = new Widget('stats.customerTotal', $this->customer->countCustomer());
$widget
->setRoute('admin_customer')
->setIcon('customer')
->setColor('blue')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
$widget = new Widget('stats.projectTotal', $this->project->countProject());
$widget
->setRoute('admin_project')
->setIcon('project')
->setColor('yellow')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
$widget = new Widget('stats.activityTotal', $this->activity->countActivity());
$widget
->setRoute('admin_activity')
->setIcon('activity')
->setColor('purple')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
$event->addSection($section);
}
}

View File

@@ -0,0 +1,105 @@
<?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\Model;
class DashboardSection
{
const TYPE_SIMPLE = 'simple';
const TYPE_CHART = 'chart';
/**
* @var null|string
*/
protected $title;
/**
* @var Widget[]
*/
protected $widgets = [];
/**
* @var int
*/
protected $order = 0;
/**
* @var string
*/
protected $type = self::TYPE_SIMPLE;
/**
* @param null|string $title
*/
public function __construct(?string $title)
{
$this->title = $title;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @param string $type
* @return DashboardSection
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
/**
* @return int
*/
public function getOrder(): int
{
return $this->order;
}
/**
* @param int $order
* @return DashboardSection
*/
public function setOrder(int $order)
{
$this->order = $order;
return $this;
}
/**
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @return Widget[]
*/
public function getWidgets(): array
{
return $this->widgets;
}
/**
* @param Widget $widget
* @return DashboardSection
*/
public function addWidget(Widget $widget)
{
$this->widgets[] = $widget;
return $this;
}
}

View File

@@ -1,77 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
/**
* Timesheet statistics for all user.
*/
class TimesheetGlobalStatistic extends TimesheetStatistic
{
/**
* @var int
*/
protected $activeThisMonth = 0;
/**
* @var int
*/
protected $activeTotal = 0;
/**
* @var int
*/
protected $activeCurrently = 0;
/**
* @return int
*/
public function getActiveCurrently()
{
return $this->activeCurrently;
}
/**
* @param int $activeCurrently
*/
public function setActiveCurrently($activeCurrently)
{
$this->activeCurrently = (int) $activeCurrently;
}
/**
* @return int
*/
public function getActiveThisMonth()
{
return $this->activeThisMonth;
}
/**
* @param int $activeThisMonth
*/
public function setActiveThisMonth($activeThisMonth)
{
$this->activeThisMonth = (int) $activeThisMonth;
}
/**
* @return int
*/
public function getActiveTotal()
{
return $this->activeTotal;
}
/**
* @param int $activeTotal
*/
public function setActiveTotal($activeTotal)
{
$this->activeTotal = (int) $activeTotal;
}
}

View File

@@ -1,37 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
/**
* User statistics
*/
class UserStatistic
{
/**
* @var int
*/
protected $totalAmount = 0;
/**
* @return int
*/
public function getTotalAmount()
{
return $this->totalAmount;
}
/**
* @param int $totalAmount
*/
public function setTotalAmount($totalAmount)
{
$this->totalAmount = (int) $totalAmount;
}
}

219
src/Model/Widget.php Normal file
View File

@@ -0,0 +1,219 @@
<?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\Model;
class Widget
{
public const TYPE_COUNTER = 'counter';
public const TYPE_MORE = 'more';
public const DATA_TYPE_INT = 'int';
public const DATA_TYPE_MONEY = 'money';
public const DATA_TYPE_DURATION = 'duration';
/**
* @var string|null
*/
protected $title;
/**
* @var string
*/
protected $icon = '';
/**
* @var string
*/
protected $color = '';
/**
* @var string
*/
protected $type = self::TYPE_COUNTER;
/**
* @var string
*/
protected $range;
/**
* @var string|null
*/
protected $route;
/**
* @var array
*/
protected $routeOptions = [];
/**
* @var mixed
*/
protected $data;
/**
* @var string
*/
protected $dataType = self::DATA_TYPE_INT;
/**
* @param string $title
* @param mixed $data
*/
public function __construct(string $title, $data)
{
$this->title = $title;
$this->data = $data;
}
/**
* @return string
*/
public function getDataType(): string
{
return $this->dataType;
}
/**
* @param string $dataType
* @return Widget
*/
public function setDataType(string $dataType)
{
$this->dataType = $dataType;
return $this;
}
/**
* @return array
*/
public function getRouteOptions(): array
{
return $this->routeOptions;
}
/**
* @param array $routeOptions
* @return Widget
*/
public function setRouteOptions(array $routeOptions)
{
$this->routeOptions = $routeOptions;
return $this;
}
/**
* @return string|null
*/
public function getRoute(): ?string
{
return $this->route;
}
/**
* @param string $route
* @return Widget
*/
public function setRoute(string $route)
{
$this->route = $route;
return $this;
}
/**
* @return mixed
*/
public function getData()
{
return $this->data;
}
/**
* @param mixed $data
* @return Widget
*/
public function setData($data)
{
$this->data = $data;
return $this;
}
/**
* @return string|null
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return Widget
*/
public function setTitle(string $title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getIcon(): string
{
return $this->icon;
}
/**
* @param string $icon
* @return Widget
*/
public function setIcon(string $icon)
{
$this->icon = $icon;
return $this;
}
/**
* @return string
*/
public function getColor(): string
{
return $this->color;
}
/**
* @param string $color
* @return Widget
*/
public function setColor(string $color)
{
$this->color = $color;
return $this;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @param string $type
* @return Widget
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
}

View File

@@ -1,71 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
class WidgetRow
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $title;
/**
* @var string[]
*/
protected $widgets = [];
/**
* @param string $id
* @param string $title
*/
public function __construct(string $id, string $title = '')
{
$this->id = $id;
$this->title = $title;
}
/**
* @return string
*/
public function getId(): string
{
return $this->id;
}
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @return string[]
*/
public function getWidgets(): array
{
return $this->widgets;
}
/**
* @param string $templateString
* @return $this
*/
public function add(string $templateString)
{
$this->widgets[] = $templateString;
return $this;
}
}

View File

@@ -76,21 +76,11 @@ class ActivityRepository extends AbstractRepository
}
/**
* Return global statistic data for all user.
*
* @return ActivityStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
* @return int
*/
public function getGlobalStatistics()
public function countActivity()
{
$countAll = $this->getEntityManager()
->createQuery('SELECT COUNT(a.id) FROM ' . Activity::class . ' a')
->getSingleScalarResult();
$stats = new ActivityStatistic();
$stats->setCount($countAll);
return $stats;
return $this->count([]);
}
/**

View File

@@ -32,21 +32,11 @@ class CustomerRepository extends AbstractRepository
}
/**
* Return statistic data for all customer.
*
* @return CustomerStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
* @return int
*/
public function getGlobalStatistics()
public function countCustomer()
{
$countAll = $this->getEntityManager()
->createQuery('SELECT COUNT(c.id) FROM ' . Customer::class . ' c')
->getSingleScalarResult();
$stats = new CustomerStatistic();
$stats->setCount($countAll);
return $stats;
return $this->count([]);
}
/**

View File

@@ -31,21 +31,11 @@ class ProjectRepository extends AbstractRepository
}
/**
* Return statistic data for all user.
*
* @return ProjectStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
* @return int
*/
public function getGlobalStatistics()
public function countProject()
{
$countAll = $this->getEntityManager()
->createQuery('SELECT COUNT(p.id) FROM ' . Project::class . ' p')
->getSingleScalarResult();
$stats = new ProjectStatistic();
$stats->setCount($countAll);
return $stats;
return $this->count([]);
}
/**

View File

@@ -14,7 +14,6 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use App\Model\TimesheetGlobalStatistic;
use App\Model\TimesheetStatistic;
use App\Repository\Query\TimesheetQuery;
use DateTime;
@@ -22,11 +21,15 @@ use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* Class TimesheetRepository
*/
class TimesheetRepository extends AbstractRepository
{
public const STATS_QUERY_DURATION = 'duration';
public const STATS_QUERY_RATE = 'rate';
public const STATS_QUERY_USER = 'users';
public const STATS_QUERY_AMOUNT = 'amount';
public const STATS_QUERY_ACTIVE = 'active';
public const STATS_QUERY_MONTHLY = 'monthly';
/**
* @param Timesheet $entry
* @return bool
@@ -67,45 +70,91 @@ class TimesheetRepository extends AbstractRepository
return true;
}
/**
* @param string $type
* @param DateTime|null $begin
* @param DateTime|null $end
* @param User|null $user
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user)
{
switch ($type) {
case self::STATS_QUERY_ACTIVE:
return count($this->getActiveEntries($user));
break;
case self::STATS_QUERY_MONTHLY:
return $this->getMonthlyStats($user, $begin, $end);
break;
case self::STATS_QUERY_DURATION:
$what = 'SUM(t.duration)';
break;
case self::STATS_QUERY_RATE:
$what = 'SUM(t.rate)';
break;
case self::STATS_QUERY_USER:
$what = 'COUNT(DISTINCT(t.user))';
break;
case self::STATS_QUERY_AMOUNT:
$what = 'COUNT(t.id)';
break;
default:
throw new \InvalidArgumentException('Invalid query type: ' . $type);
}
return $this->queryTimeRange($what, $begin, $end, $user);
}
/**
* @param $select
* @param User|null $user
* @return \Doctrine\ORM\QueryBuilder
* @return int
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryThisMonth($select, User $user = null)
protected function queryThisMonth($select, ?User $user)
{
$end = new DateTime('last day of this month');
$end->setTime(23, 59, 59);
$begin = new DateTime('first day of this month');
$begin->setTime(0, 0, 0);
$begin = new DateTime('first day of this month 00:00:00');
$end = new DateTime('last day of this month 23:59:59');
return $this->queryTimeRange($select, $begin, $end, $user);
}
/**
* @param $select
* @param DateTime $begin
* @param DateTime $end
* @param string $select
* @param DateTime|null $begin
* @param DateTime|null $end
* @param User|null $user
* @return \Doctrine\ORM\QueryBuilder
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryTimeRange($select, DateTime $begin, DateTime $end, User $user = null)
protected function queryTimeRange(string $select, ?DateTime $begin, ?DateTime $end, ?User $user)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select($select)
->from(Timesheet::class, 't')
->where($qb->expr()->gt('t.begin', ':from'))
->andWhere($qb->expr()->lt('t.end', ':to'))
->setParameter('from', $begin, Type::DATETIME)
->setParameter('to', $end, Type::DATETIME);
->from(Timesheet::class, 't');
if (!empty($begin)) {
$qb
->andWhere($qb->expr()->gt('t.begin', ':from'))
->setParameter('from', $begin, Type::DATETIME);
}
if (!empty($end)) {
$qb
->andWhere($qb->expr()->lt('t.end', ':to'))
->setParameter('to', $end, Type::DATETIME);
}
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
return $qb;
$result = $qb->getQuery()->getSingleScalarResult();
return empty($result) ? 0 : $result;
}
/**
@@ -117,24 +166,11 @@ class TimesheetRepository extends AbstractRepository
*/
public function getUserStatistics(User $user)
{
$durationTotal = $this->getEntityManager()
->createQuery('SELECT SUM(t.duration) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
->getSingleScalarResult();
$recordsTotal = $this->getEntityManager()
->createQuery('SELECT COUNT(t.id) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
->getSingleScalarResult();
$rateTotal = $this->getEntityManager()
->createQuery('SELECT SUM(t.rate) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
->getSingleScalarResult();
$amountMonth = $this->queryThisMonth('SUM(t.rate)', $user)
->getQuery()
->getSingleScalarResult();
$durationMonth = $this->queryThisMonth('SUM(t.duration)', $user)
->getQuery()
->getSingleScalarResult();
$durationTotal = $this->getStatistic(self::STATS_QUERY_DURATION, null, null, $user);
$recordsTotal = $this->getStatistic(self::STATS_QUERY_AMOUNT, null, null, $user);
$rateTotal = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user);
$amountMonth = $this->queryThisMonth('SUM(t.rate)', $user);
$durationMonth = $this->queryThisMonth('SUM(t.duration)', $user);
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
@@ -155,23 +191,40 @@ class TimesheetRepository extends AbstractRepository
* Returns an array of Year statistics.
*
* @param User|null $user
* @param DateTime|null $begin
* @param DateTime|null $end
* @return Year[]
*/
public function getMonthlyStats(User $user = null)
public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('SUM(t.rate) as rate, SUM(t.duration) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year')
->from(Timesheet::class, 't')
->where($qb->expr()->gt('t.begin', '0'))
->andWhere($qb->expr()->isNotNull('t.end'))
->where($qb->expr()->gt('t.begin', ':from'))
;
if (!empty($begin)) {
$qb->setParameter('from', $begin, Type::DATETIME);
} else {
$qb->setParameter('from', 0);
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lt('t.end', ':to'))
->setParameter('to', $end, Type::DATETIME);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month');
if (null !== $user) {
$qb->where('t.user = :user')
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
@@ -198,52 +251,6 @@ class TimesheetRepository extends AbstractRepository
}
/**
* Fetch statistic data for all user.
*
* @return TimesheetGlobalStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getGlobalStatistics()
{
$durationTotal = $this->getEntityManager()
->createQuery('SELECT SUM(t.duration) FROM ' . Timesheet::class . ' t')
->getSingleScalarResult();
$recordsTotal = $this->getEntityManager()
->createQuery('SELECT COUNT(t.id) FROM ' . Timesheet::class . ' t')
->getSingleScalarResult();
$rateTotal = $this->getEntityManager()
->createQuery('SELECT SUM(t.rate) FROM ' . Timesheet::class . ' t')
->getSingleScalarResult();
$userTotal = $this->getEntityManager()
->createQuery('SELECT COUNT(DISTINCT(t.user)) FROM ' . Timesheet::class . ' t')
->getSingleScalarResult();
$activeNow = $this->getActiveEntries();
$amountMonth = $this->queryThisMonth('SUM(t.rate)')
->getQuery()
->getSingleScalarResult();
$durationMonth = $this->queryThisMonth('SUM(t.duration)')
->getQuery()
->getSingleScalarResult();
$activeMonth = $this->queryThisMonth('COUNT(DISTINCT(t.user))')
->getQuery()
->getSingleScalarResult();
$stats = new TimesheetGlobalStatistic();
$stats->setAmountTotal($rateTotal);
$stats->setDurationTotal($durationTotal);
$stats->setActiveTotal($userTotal);
$stats->setActiveCurrently(count($activeNow));
$stats->setActiveThisMonth($activeMonth);
$stats->setAmountThisMonth($amountMonth);
$stats->setDurationThisMonth($durationMonth);
$stats->setRecordsTotal($recordsTotal);
return $stats;
}
/**
* TODO replace me by a findByQuery() call
*
* @param User $user
* @return Timesheet[]|null
*/

View File

@@ -10,7 +10,6 @@
namespace App\Repository;
use App\Entity\User;
use App\Model\UserStatistic;
use App\Repository\Query\UserQuery;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
@@ -29,21 +28,11 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
}
/**
* Return statistic data for all user.
*
* @return UserStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
* @return int
*/
public function getGlobalStatistics()
public function countUser()
{
$countAll = $this->getEntityManager()
->createQuery('SELECT COUNT(u.id) FROM ' . User::class . ' u')
->getSingleScalarResult();
$stats = new UserStatistic();
$stats->setTotalAmount($countAll);
return $stats;
return $this->count([]);
}
/**

View File

@@ -0,0 +1,82 @@
<?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\Repository;
use App\Entity\User;
use App\Model\Widget;
class WidgetRepository
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @var array
*/
protected $widgets = [];
/**
* @param TimesheetRepository $repository
* @param array $widgets
*/
public function __construct(TimesheetRepository $repository, array $widgets)
{
$this->repository = $repository;
$this->widgets = $widgets;
}
/**
* @param string $name
* @return bool
*/
public function has(string $name)
{
return isset($this->widgets[$name]);
}
/**
* @param string $name
* @param User|null $user
* @return Widget
* @throws \InvalidArgumentException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function get(string $name, ?User $user)
{
if (!$this->has($name)) {
throw new \InvalidArgumentException('Cannot find widget: ' . $name);
}
$widget = $this->widgets[$name];
$begin = !empty($widget['begin']) ? new \DateTime($widget['begin']) : null;
$end = !empty($widget['end']) ? new \DateTime($widget['end']) : null;
$theUser = $widget['user'] ? $user : null;
$type = isset($widget['type']) ? $widget['type'] : Widget::TYPE_COUNTER;
$data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser);
$model = new Widget($widget['title'], $data);
$model
->setColor($widget['color'])
->setIcon($widget['icon'])
->setType($type)
;
if ($widget['query'] == TimesheetRepository::STATS_QUERY_DURATION) {
$model->setDataType(Widget::DATA_TYPE_DURATION);
} elseif ($widget['query'] == TimesheetRepository::STATS_QUERY_AMOUNT) {
$model->setDataType(Widget::DATA_TYPE_MONEY);
}
return $model;
}
}

View File

@@ -80,6 +80,8 @@ class Extensions extends \Twig_Extension
'trash' => 'far fa-trash-alt',
'user' => 'fas fa-user',
'visibility' => 'far fa-eye',
'money' => 'far fa-money-bill-alt',
'duration' => 'far fa-hourglass',
];
/**