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

@@ -15,17 +15,17 @@ which leads to problems between Composer and Symfony Flex, resulting in an error
Declaration of Symfony\Flex\ParallelDownloader::getRemoteContents($originUrl, $fileUrl, $context) should be compatible with Composer\Util\RemoteFilesystem::getRemoteContents($originUrl, $fileUrl, $context, ?array &$responseHeaders = NULL)
```
This can be fixed by updating composer before the Kimai update and running composer without the flex plugin:
This can be fixed by updating Composer and Flex before executing the Kimai update:
```
composer self-update
sudo -u www-data composer install --no-plugins
sudo composer self-update
sudo -u www-data composer update symfony/flex --no-plugins
```
So the full update goes like that:
Then the full update can be executed as usual:
```bash
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader --no-plugins
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
bin/console doctrine:migrations:migrate

View File

@@ -68,10 +68,68 @@ kimai:
# id: 'de.german#holiday@group.v.calendar.google.com'
# color: '#ccc'
# theme related settings, will be available as twig settings
# theme related settings, will be available as twig globals at "kimai_context.*"
# please see documentation at var/docs/theme.md
theme:
# display a warning color if the user has at least X active recordings
active_warning: 3
# fallback color for all widgets that don't have a dedicated color
# possible options: blue, black, purple, yellow, red, green
box_color: 'green'
# Dashboard widget sections, please see documentation at var/docs/dashboard.md
dashboard:
user_duration:
title: dashboard.you
order: 10
permission: ROLE_USER
widgets: [userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates:
title: ~
order: 20
permission: ROLE_USER
widgets: [userAmountToday, userAmountWeek, userAmountMonth, userAmountYear]
duration:
title: dashboard.all
order: 30
permission: ROLE_TEAMLEAD
widgets: [durationToday, durationWeek, durationMonth, durationYear]
active_users:
title: ~
order: 40
permission: ROLE_TEAMLEAD
widgets: [activeUsersToday, activeUsersWeek, activeUsersMonth, activeUsersYear]
rates:
title: ~
order: 50
permission: ROLE_ADMIN
widgets: [amountToday, amountWeek, amountMonth, amountYear]
# All available widgets, please see documentation at var/docs/dashboard.md
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
userDurationWeek: { title: stats.durationWeek, query: duration, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
userDurationMonth: { title: stats.durationMonth, query: duration, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
userDurationYear: { title: stats.durationYear, query: duration, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
userDurationTotal: { title: stats.durationTotal, query: duration, user: true, icon: duration, color: red }
userAmountToday: { title: stats.amountToday, query: rate, user: true, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
userAmountWeek: { title: stats.amountWeek, query: rate, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
userAmountMonth: { title: stats.amountMonth, query: rate, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
userAmountYear: { title: stats.amountYear, query: rate, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
userAmountTotal: { title: stats.amountTotal, query: rate, user: true, icon: money, color: red }
durationToday: { title: stats.durationToday, query: duration, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
durationWeek: { title: stats.durationWeek, query: duration, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
durationMonth: { title: stats.durationMonth, query: duration, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
durationYear: { title: stats.durationYear, query: duration, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
durationTotal: { title: stats.durationTotal, query: duration, icon: duration, color: red }
amountToday: { title: stats.amountToday, query: rate, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
amountWeek: { title: stats.amountWeek, query: rate, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
amountMonth: { title: stats.amountMonth, query: rate, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
amountYear: { title: stats.amountYear, query: rate, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
amountTotal: { title: stats.amountTotal, query: rate, icon: money, color: red }
activeUsersToday: { title: stats.userActiveToday, query: users, begin: '00:00:00', end: '23:59:59', icon: user, color: green }
activeUsersWeek: { title: stats.userActiveWeek, query: users, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: user, color: blue }
activeUsersMonth: { title: stats.userActiveMonth, query: users, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: user, color: purple }
activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow }
activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red }

View File

@@ -46,6 +46,14 @@ services:
arguments:
$config: "%kimai.calendar%"
App\Repository\WidgetRepository:
arguments:
$widgets: "%kimai.widgets%"
App\Controller\DashboardController:
arguments:
$dashboard: "%kimai.dashboard%"
# ================================================================================
# DATABASE
# ================================================================================
@@ -109,6 +117,11 @@ services:
# REPOSITORIES
# ================================================================================
App\Repository\TimesheetRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Timesheet']
App\Repository\UserRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]

View File

@@ -12,7 +12,7 @@
"admin-lte": "~2.3.11",
"bootstrap-sass": "^3.3.7",
"bootstrap-select": "^1.13.1",
"chart.js": "1.1.*",
"chart.js": "~2.7.2",
"daterangepicker": "^3.0.3",
"fullcalendar": "^3.9.0",
"icheck": "^1.0.2",

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"build/app.js": "/build/app.js?5e09a085ecc9dfc5af6b",
"build/app.js": "/build/app.js?677d965d103e8d9800c9",
"build/app.css": "/build/app.css?921dcef8f150f460b1e6bfe0e0e17aa7",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/images/boxed-bg.jpg": "/build/images/boxed-bg.jpg?7799dece",

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',
];
/**

View File

@@ -9,26 +9,11 @@
{% block main %}
{% for row in widget_rows %}
{% if row.title %}
{{ widgets.page_header(row.title) }}
{% if row.type == constant('App\\Model\\DashboardSection::TYPE_CHART') %}
{% embed 'dashboard/section-chart.html.twig' with { section: row } %}{% endembed %}
{% else %}
{% embed 'dashboard/section-simple.html.twig' with { section: row } %}{% endembed %}
{% endif %}
{% set width = row.widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widgetTemplate in row.widgets %}
<div class="col-md-{{ columnWidth }} col-sm-{{ columnWidth * 2 }} col-xs-{{ columnWidth * 4 }}">
{{
include(
template_from_string(
'{% import "macros/widgets.html.twig" as widgets %}' ~ widgetTemplate
)
)
}}
</div>
{% endfor %}
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,180 @@
{% set chartWidget = section.widgets.0 %}
{% set colors = chartWidget.color|split(';') %}
<div class="row">
<div class="col-md-12">
<div class="box">
{#
<div class="box-header with-border">
<h3 class="box-title">{{ section.title|trans }}</h3>
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i>
</button>
<div class="btn-group">
<button type="button" class="btn btn-box-tool dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-wrench"></i></button>
<ul class="dropdown-menu" role="menu">
<li><a href="#">Action</a></li>
<li><a href="#">Another action</a></li>
<li><a href="#">Something else here</a></li>
<li class="divider"></li>
<li><a href="#">Separated link</a></li>
</ul>
</div>
<button type="button" class="btn btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button>
</div>
</div>
#}
<div class="box-body">
<div class="row">
<div class="col-md-12">
{#
<p class="text-center">
<strong>
{% for yearName, year in chartWidget.data|sort %}
{% if loop.first or loop.last %}
{% for id, month in year.months %}
{% if loop.first %}
{{ ('month.'~loop.index)|trans }} {{ yearName }}
{% endif %}
{% endfor %}
{% endif %}
{% if loop.first %} - {% endif %}
{% endfor %}
</strong>
</p>
#}
<div class="chart">
<canvas id="timeChart" style="height: 180px;"></canvas>
</div>
</div>
{#
<div class="col-md-4">
<p class="text-center">
<strong>Goal Completion</strong>
</p>
<div class="progress-group">
<span class="progress-text">Add Products to Cart</span>
<span class="progress-number"><b>160</b>/200</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Complete Purchase</span>
<span class="progress-number"><b>310</b>/400</span>
<div class="progress sm">
<div class="progress-bar progress-bar-red" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Visit Premium Page</span>
<span class="progress-number"><b>480</b>/800</span>
<div class="progress sm">
<div class="progress-bar progress-bar-green" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Send Inquiries</span>
<span class="progress-number"><b>250</b>/500</span>
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" style="width: 80%"></div>
</div>
</div>
</div>
#}
</div>
</div>
{% if section.widgets|length > 1 %}
<div class="box-footer">
<div class="row">
{% set width = (section.widgets|length) - 1 %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
{% for widget in section.widgets|slice(1, width) %}
{% set data = widget.data %}
{% if widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_DURATION') %}
{% set data = widget.data|duration %}
{% elseif widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_MONEY') %}
{% set data = widget.data|money %}
{% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
<div class="description-block border-right">
{#<span class="description-percentage text-green"><i class="fa fa-caret-up"></i> 17%</span>#}
{#<span class="description-percentage text-yellow"><i class="fa fa-caret-left"></i> 0%</span>#}
{#<span class="description-percentage text-green"><i class="fa fa-caret-up"></i> 20%</span>#}
{#<span class="description-percentage text-red"><i class="fa fa-caret-down"></i> 18%</span>#}
<h5 class="description-header">{{ data }}</h5>
<span class="description-text">{{ widget.title|trans }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
$(function () {
'use strict';
var timeChartData = {
labels : [
{% for i in 1..12 %}
'{{ ('month.' ~i)|trans }}'
{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [
{% for yearName, year in chartWidget.data %}
{% set yearColors = colors[loop.index-1]|split('|') %}
{
label : '{{ yearName }}',
backgroundColor : '{{ yearColors.1 }}',
borderColor : '{{ yearColors.0 }}',
pointRadius : 2,
borderWidth : 1,
pointHitRadius : 10,
lineTension : 0.3,
data : [
{% for month in year.months %}
{{ (month.totalDuration / 3600)|round }}
{% if not loop.last %},{% endif %}
{% endfor %}
]
}
{% if not loop.last %},{% endif %}
{% endfor %}
]
};
var timeChartOptions = {
maintainAspectRatio : true,
responsive : true,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: 'rgba(0,0,0,.05)',
lineWidth: 1
}
}]
}
};
var timeChartCanvas = $('#timeChart').get(0).getContext('2d');
var timeChart = new Chart(
timeChartCanvas, {
type: 'line',
data: timeChartData,
options: timeChartOptions
}
);
});
</script>
</div>
</div>

View File

@@ -0,0 +1,34 @@
{% import "macros/widgets.html.twig" as widgets %}
{% if section.title %}
{{ widgets.page_header(section.title) }}
{% endif %}
{% set width = section.widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widget in section.widgets %}
{% set columnSize = columnWidth %}
{% if width == 5 and (loop.first or loop.last) %}
{% set columnSize = columnWidth + 1 %}
{% endif %}
<div class="col-md-{{ columnSize }} col-sm-{{ columnSize * 2 }} col-xs-{{ columnSize * 4 }}">
{% set data = widget.data %}
{% if widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_DURATION') %}
{% set data = widget.data|duration %}
{% elseif widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_MONEY') %}
{% set data = widget.data|money %}
{% endif %}
{% set url = null %}
{% if widget.route %}
{% set url = path(widget.route, widget.routeOptions) %}
{% endif %}
{% set embedOptions = {data: data, title: widget.title, icon: widget.icon, color: widget.color, url: url} %}
{% embed 'embeds/widget-'~widget.type~'.html.twig' with embedOptions %}{% endembed %}
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,10 @@
<div class="info-box">
<span class="info-box-icon bg-{{ color|default(kimai_context.box_color) }}"><i class="{{ icon|icon(icon) }}"></i></span>
<div class="info-box-content">
{% if url %}<a href="{{ url }}" class="small-box-footer">{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">{{ data }}</span>
{% if url %}</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,14 @@
<div class="small-box bg-{{ color|default(kimai_context.box_color) }}">
<div class="inner">
<h3>{{ data }}<sup style="font-size: 20px">{{ unit|default('') }}</sup></h3>
<p>{{ title|trans }}</p>
</div>
<div class="icon">
<i class="{{ icon|icon(icon) }}"></i>
</div>
{% if url %}
<a href="{{ url }}" class="small-box-footer">
{{ 'more.info.link'|trans }}
</a>
{% endif %}
</div>

View File

@@ -108,24 +108,6 @@
</div>
{% endmacro %}
{% macro info_box_counter(title, amount, icon, color, url) %}
<div class="info-box">
<span class="info-box-icon bg-{{ color|default(kimai_context.box_color) }}"><i class="{{ icon|icon(icon) }}"></i></span>
<div class="info-box-content">
{# this is a ugly hack, make me look nicely (dashboard widget with link) #}
{% if url %}
<a href="{{ url }}" class="small-box-footer">
{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">{{ amount }}</span>
{% if url %}
</a>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro info_box_progress(title, description, amount, percentage, icon, color) %}
<div class="info-box bg-{{ color|default(kimai_context.box_color) }}">
<span class="info-box-icon"><i class="{{ icon|icon(icon) }}"></i></span>
@@ -145,21 +127,6 @@
</div>
{% endmacro %}
{% macro info_box_more(title, amount, unit, url, icon, color) %}
<div class="small-box bg-{{ color|default(kimai_context.box_color) }}">
<div class="inner">
<h3>{{ amount }}<sup style="font-size: 20px">{{ unit|default('') }}</sup></h3>
<p>{{ title|trans }}</p>
</div>
<div class="icon">
<i class="{{ icon|icon(icon) }}"></i>
</div>
<a href="{{ url }}" class="small-box-footer">
{{ 'more.info.link'|trans }} <i class="{{ icon|icon(icon) }}"></i>
</a>
</div>
{% endmacro %}
{% macro button_group_dropdown(title, actions) %}
<div class="btn-group">
<button type="button" class="btn btn-default">{{ title|trans }}</button>

View File

@@ -28,67 +28,79 @@
{% endif %}
<script type="text/javascript">
var barChartOptions = {
scaleBeginAtZero: true,
scaleShowGridLines: true,
scaleGridLineColor: "rgba(0,0,0,.05)",
scaleGridLineWidth: 1,
scaleShowHorizontalLines: true,
scaleShowVerticalLines: true,
barShowStroke: true,
barStrokeWidth: 2,
barValueSpacing: 5,
barDatasetSpacing: 1,
responsive: true,
maintainAspectRatio: true,
tooltipTemplate: "<%= value %> {{ 'label.hours'|trans }}",
};
var barChartLabels = [
{% for i in 1..11 %}
"{{ ('month.' ~i)|trans }}",
{% endfor %}
"{{ 'month.12'|trans }}"
];
var userProfileChartOptions = {
maintainAspectRatio : true,
responsive : true,
legend : false,
barPercentage : 0.5,
categoryPercentage : 0.9,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: 'rgba(0,0,0,.05)',
lineWidth: 1
}
}]
}
};
var userProfileChartLabels = [
{% for i in 1..12 %}
'{{ ('month.' ~i)|trans }}'
{% if not loop.last %},{% endif %}
{% endfor %}
];
</script>
{% for year,yearStat in years %}
<h2>{{ year }}</h2>
<div class="chart">
<canvas id="barChart{{ year }}" style="height: 230px;"></canvas>
<canvas id="userProfileChart{{ year }}" style="height: 200px;"></canvas>
</div>
<script type="text/javascript">
$(document).ready(function () {
var barChartData{{ year }} = {
labels: barChartLabels,
var userProfileChartData{{ year }} = {
labels: userProfileChartLabels,
datasets: [
{
{# label: '# hours',#}
fillColor: "#00a65a",
strokeColor: "#00a65a",
pointColor: "#00a65a",
pointStrokeColor: "#c1c7d1",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,220,1)",
label: '{{ year }}',
backgroundColor : '#00a65a',
borderColor : '#00a65a',
data: [
{% for i in 1..11 %}
{{ (yearStat.month(i).totalDuration / 3600)|round }},
{% for month in yearStat.months %}
{{ (month.totalDuration / 3600)|round }}
{% if not loop.last %},{% endif %}
{% endfor %}
{{ (yearStat.month(12).totalDuration / 3600)|round }}
]
}
]
};
{% if tab == "charts" %}
$(function () {
var barChartCanvas{{ year }} = $("#barChart{{ year }}").get(0).getContext("2d");
var barChart = new Chart(barChartCanvas{{ year }});
barChart.Bar(barChartData{{ year }}, barChartOptions);
});
var userProfileChartCanvas{{ year }} = $("#userProfileChart{{ year }}").get(0).getContext("2d");
{% if tab == 'charts' %}
var userProfileChart{{ year }} = new Chart(
userProfileChartCanvas{{ year }}, {
type: 'bar',
data: userProfileChartData{{ year }},
options: userProfileChartOptions
}
);
{% endif %}
$('a[href="#charts"]').on('shown.bs.tab', function(e){
var barChartCanvas{{ year }} = $("#barChart{{ year }}").get(0).getContext("2d");
var barChart = new Chart(barChartCanvas{{ year }});
barChart.Bar(barChartData{{ year }}, barChartOptions);
$('a[href="#charts"]').on('shown.bs.tab', function(event){
var userProfileChart{{ year }} = new Chart(
userProfileChartCanvas{{ year }}, {
type: 'bar',
data: userProfileChartData{{ year }},
options: userProfileChartOptions
}
);
});
});
</script>
@@ -140,12 +152,12 @@
<ul class="list-group list-group-unbordered">
<li class="list-group-item">
<b>{{ 'stats.durationThisMonth'|trans }}</b> <a class="pull-right">{{ stats.durationThisMonth|duration }}</a>
<b>{{ 'stats.durationMonth'|trans }}</b> <a class="pull-right">{{ stats.durationThisMonth|duration }}</a>
</li>
{% if is_granted('ROLE_ADMIN') %}
<li class="list-group-item">
<b>{{ 'stats.amountThisMonth'|trans }}</b> <a class="pull-right">{{ stats.amountThisMonth|money }}</a>
<b>{{ 'stats.amountMonth'|trans }}</b> <a class="pull-right">{{ stats.amountThisMonth|money }}</a>
</li>
{% endif %}
@@ -220,13 +232,13 @@
<div class="col-sm-3 border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.durationThisMonth|duration }}</h5>
<span class="description-text">{{ 'stats.durationThisMonth'|trans }}</span>
<span class="description-text">{{ 'stats.durationMonth'|trans }}</span>
</div>
</div>
<div class="col-sm-3 border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.amountThisMonth|money }}</h5>
<span class="description-text">{{ 'stats.amountThisMonth'|trans }}</span>
<span class="description-text">{{ 'stats.amountMonth'|trans }}</span>
</div>
</div>
</div>

View File

@@ -66,12 +66,11 @@ class TimesheetControllerTest extends ControllerBaseTest
$docuUrl = $this->createUrl('/help/timesheet');
$this->assertTrue($response->isSuccessful());
$this->assertContains(
'<a href="'.$docuUrl.'"><i class="far fa-question-circle"></i></a>',
'<a href="' . $docuUrl . '"><i class="far fa-question-circle"></i></a>',
$response->getContent(),
'Could not find link to documentation'
);
// TODO more tests
}
}

View File

@@ -0,0 +1,91 @@
<?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\Voter;
use App\Entity\User;
use App\Event\DashboardEvent;
use App\EventSubscriber\DashboardSubscriber;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\UserRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @covers \App\EventSubscriber\DashboardSubscriber
*/
class DashboardSubscriberTest extends TestCase
{
public function testGetSubscribedEvents()
{
$events = DashboardSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(DashboardEvent::DASHBOARD, $events);
$methodName = $events[DashboardEvent::DASHBOARD][0];
$this->assertTrue(method_exists(DashboardSubscriber::class, $methodName));
}
public function testWithNonAdminUser()
{
$sut = $this->getSubscriber(false, 13, 28, 37, 5);
$event = new DashboardEvent(new User());
$this->assertEquals(0, count($event->getSections()));
$sut->onDashboardEvent($event);
$this->assertEquals(0, count($event->getSections()));
}
public function testWithAdminUser()
{
$sut = $this->getSubscriber(true, 13, 28, 37, 5);
$event = new DashboardEvent(new User());
$this->assertEquals(0, count($event->getSections()));
$sut->onDashboardEvent($event);
$sections = $event->getSections();
$widgets = $sections[0]->getWidgets();
$this->assertEquals(1, count($sections));
$this->assertEquals(4, count($widgets));
$this->assertEquals('stats.userTotal', $widgets[0]->getTitle());
$this->assertEquals(13, $widgets[0]->getData());
$this->assertEquals('stats.customerTotal', $widgets[1]->getTitle());
$this->assertEquals(5, $widgets[1]->getData());
$this->assertEquals('stats.projectTotal', $widgets[2]->getTitle());
$this->assertEquals(37, $widgets[2]->getData());
$this->assertEquals('stats.activityTotal', $widgets[3]->getTitle());
$this->assertEquals(28, $widgets[3]->getData());
}
protected function getSubscriber(bool $isAdmin, int $userCount, int $activityCount, int $projectCount, int $customerCount)
{
$authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
$authMock->method('isGranted')->willReturn($isAdmin);
$userMock = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
$userMock->method('countUser')->willReturn($userCount);
$projectMock = $this->getMockBuilder(ProjectRepository::class)->disableOriginalConstructor()->getMock();
$projectMock->method('countProject')->willReturn($projectCount);
$activityMock = $this->getMockBuilder(ActivityRepository::class)->disableOriginalConstructor()->getMock();
$activityMock->method('countActivity')->willReturn($activityCount);
$customerMock = $this->getMockBuilder(CustomerRepository::class)->disableOriginalConstructor()->getMock();
$customerMock->method('countCustomer')->willReturn($customerCount);
return new DashboardSubscriber($authMock, $userMock, $activityMock, $projectMock, $customerMock);
}
}

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\Tests\Repository;
use App\Model\Widget;
use App\Repository\TimesheetRepository;
use App\Repository\WidgetRepository;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Repository\WidgetRepository
*/
class WidgetRepositoryTest extends TestCase
{
public function testHasWidget()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]);
$this->assertFalse($sut->has('foo'));
$this->assertTrue($sut->has('test'));
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Cannot find widget: foo
*/
public function testGetWidgetThrowsExceptionOnNonExistingWidget()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]);
$sut->get('foo', null);
}
/**
* @dataProvider getWidgetData
*/
public function testGetWidget($data, $query, $dataType)
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$repoMock->method('getStatistic')->willReturn($data);
$widget = [
'color' => 'sunny',
'icon' => 'far fa-test',
'user' => false,
'begin' => null,
'end' => null,
'query' => $query,
'title' => 'Test widget',
];
$sut = new WidgetRepository($repoMock, ['test' => $widget]);
$widget = $sut->get('test', null);
$this->assertEquals('Test widget', $widget->getTitle());
$this->assertEquals($data, $widget->getData());
$this->assertEquals('sunny', $widget->getColor());
$this->assertEquals('far fa-test', $widget->getIcon());
$this->assertEquals($dataType, $widget->getDataType());
}
public function getWidgetData()
{
return [
[12, TimesheetRepository::STATS_QUERY_DURATION, Widget::DATA_TYPE_DURATION],
[112233, TimesheetRepository::STATS_QUERY_AMOUNT, Widget::DATA_TYPE_MONEY],
[37, TimesheetRepository::STATS_QUERY_ACTIVE, Widget::DATA_TYPE_INT],
[375, TimesheetRepository::STATS_QUERY_RATE, Widget::DATA_TYPE_INT],
[['test' => 'foo'], TimesheetRepository::STATS_QUERY_USER, Widget::DATA_TYPE_INT],
];
}
}

View File

@@ -161,40 +161,20 @@ class ExtensionsTest extends TestCase
public function testIcon()
{
$icons = [
'user' => 'fas fa-user',
'customer' => 'fas fa-users',
'project' => 'fas fa-project-diagram',
'activity' => 'fas fa-tasks',
'admin' => 'fas fa-wrench',
'invoice' => 'fas fa-file-invoice',
'timesheet' => 'far fa-clock',
'dashboard' => 'fas fa-tachometer-alt',
'logout' => 'fas fa-sign-out-alt',
'trash' => 'far fa-trash-alt',
'delete' => 'far fa-trash-alt',
'repeat' => 'fas fa-redo-alt',
'edit' => 'far fa-edit',
'manual' => 'fas fa-book',
'help' => 'far fa-question-circle',
'start' => 'fas fa-play-circle',
'start-small' => 'fas fa-play-circle',
'stop' => 'fas fa-stop',
'stop-small' => 'far fa-stop-circle',
'filter' => 'fas fa-filter',
'create' => 'far fa-plus-square',
'list' => 'fas fa-list',
'print' => 'fas fa-print',
'visibility' => 'far fa-eye',
'calendar' => 'far fa-calendar-alt',
'user', 'customer', 'project', 'activity', 'admin', 'invoice', 'timesheet', 'dashboard', 'logout', 'trash',
'delete', 'repeat', 'edit', 'manual', 'help', 'start', 'start-small', 'stop', 'stop-small', 'filter',
'create', 'list', 'print', 'visibility', 'calendar', 'money', 'duration',
];
// test pre-defined icons
$sut = $this->getSut('en');
foreach ($icons as $icon => $class) {
foreach ($icons as $icon) {
$result = $sut->icon($icon);
$this->assertNotEmpty($result);
$this->assertNotEmpty($result, 'Problem with icon definition: ' . $icon);
$this->assertInternalType('string', $result);
}
// test fallback will be returned
$this->assertEquals('', $sut->icon('foo'));
$this->assertEquals('bar', $sut->icon('foo', 'bar'));
}

View File

@@ -576,50 +576,82 @@
</trans-unit>
<!--
Statistics
Statistics data for Dashboard & Users profile
-->
<trans-unit id="stats.durationThisMonth">
<source>stats.durationThisMonth</source>
<trans-unit id="stats.durationToday">
<source>stats.durationToday</source>
<target>Arbeitszeit heute</target>
</trans-unit>
<trans-unit id="stats.durationWeek">
<source>stats.durationWeek</source>
<target>Arbeitszeit diese Woche</target>
</trans-unit>
<trans-unit id="stats.durationMonth">
<source>stats.durationMonth</source>
<target>Arbeitszeit diesen Monat</target>
</trans-unit>
<trans-unit id="stats.amountThisMonth">
<source>stats.amountThisMonth</source>
<target>Umsatz diesen Monat</target>
<trans-unit id="stats.durationYear">
<source>stats.durationYear</source>
<target>Arbeitszeit dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Arbeitszeit total</target>
</trans-unit>
<trans-unit id="stats.amountToday">
<source>stats.amountToday</source>
<target>Umsatz heute</target>
</trans-unit>
<trans-unit id="stats.amountWeek">
<source>stats.amountWeek</source>
<target>Umsatz diese Woche</target>
</trans-unit>
<trans-unit id="stats.amountMonth">
<source>stats.amountMonth</source>
<target>Umsatz diesen Monat</target>
</trans-unit>
<trans-unit id="stats.amountYear">
<source>stats.amountYear</source>
<target>Umsatz dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Umsatz total</target>
</trans-unit>
<!--
Dashboard
-->
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Anzahl Benutzer</target>
<trans-unit id="stats.userActiveToday">
<source>stats.userActiveToday</source>
<target>Aktive Benutzer heute</target>
</trans-unit>
<trans-unit id="stats.userActiveThisMoth">
<source>stats.userActiveThisMoth</source>
<trans-unit id="stats.userActiveWeek">
<source>stats.userActiveWeek</source>
<target>Aktive Benutzer diese Woche</target>
</trans-unit>
<trans-unit id="stats.userActiveMonth">
<source>stats.userActiveMonth</source>
<target>Aktive Benutzer diesen Monat</target>
</trans-unit>
<trans-unit id="stats.userActiveEver">
<source>stats.userActiveEver</source>
<trans-unit id="stats.userActiveYear">
<source>stats.userActiveYear</source>
<target>Aktive Benutzer dieses Jahr</target>
</trans-unit>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Aktive Benutzer jemals</target>
</trans-unit>
<trans-unit id="stats.activeRecordings">
<source>stats.activeRecordings</source>
<target>Momentan aktive Zeitmessungen</target>
<target>Aktive Zeitmessungen</target>
</trans-unit>
<trans-unit id="stats.activitiesTotal">
<source>stats.activitiesTotal</source>
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Anzahl Benutzer</target>
</trans-unit>
<trans-unit id="stats.activityTotal">
<source>stats.activityTotal</source>
<target>Anzahl Tätigkeiten</target>
</trans-unit>
<trans-unit id="stats.projectsTotal">
<source>stats.projectsTotal</source>
<trans-unit id="stats.projectTotal">
<source>stats.projectTotal</source>
<target>Anzahl Projekte</target>
</trans-unit>
<trans-unit id="stats.customerTotal">

View File

@@ -584,50 +584,82 @@
</trans-unit>
<!--
Statistics
Statistics data for Dashboard & Users profile
-->
<trans-unit id="stats.durationThisMonth">
<source>stats.durationThisMonth</source>
<trans-unit id="stats.durationToday">
<source>stats.durationToday</source>
<target>Working hours today</target>
</trans-unit>
<trans-unit id="stats.durationWeek">
<source>stats.durationWeek</source>
<target>Working hours this week</target>
</trans-unit>
<trans-unit id="stats.durationMonth">
<source>stats.durationMonth</source>
<target>Working hours this month</target>
</trans-unit>
<trans-unit id="stats.amountThisMonth">
<source>stats.amountThisMonth</source>
<target>Revenue this month</target>
<trans-unit id="stats.durationYear">
<source>stats.durationYear</source>
<target>Working hours this year</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Working hours total</target>
</trans-unit>
<trans-unit id="stats.amountToday">
<source>stats.amountToday</source>
<target>Revenue today</target>
</trans-unit>
<trans-unit id="stats.amountWeek">
<source>stats.amountWeek</source>
<target>Revenue this week</target>
</trans-unit>
<trans-unit id="stats.amountMonth">
<source>stats.amountMonth</source>
<target>Revenue this month</target>
</trans-unit>
<trans-unit id="stats.amountYear">
<source>stats.amountYear</source>
<target>Revenue this year</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Revenue total</target>
</trans-unit>
<!--
Dashboard
-->
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Amount users</target>
<trans-unit id="stats.userActiveToday">
<source>stats.userActiveToday</source>
<target>Active users today</target>
</trans-unit>
<trans-unit id="stats.userActiveThisMoth">
<source>stats.userActiveThisMoth</source>
<trans-unit id="stats.userActiveWeek">
<source>stats.userActiveWeek</source>
<target>Active users this week</target>
</trans-unit>
<trans-unit id="stats.userActiveMonth">
<source>stats.userActiveMonth</source>
<target>Active users this month</target>
</trans-unit>
<trans-unit id="stats.userActiveEver">
<source>stats.userActiveEver</source>
<trans-unit id="stats.userActiveYear">
<source>stats.userActiveYear</source>
<target>Active users this year</target>
</trans-unit>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Active users ever</target>
</trans-unit>
<trans-unit id="stats.activeRecordings">
<source>stats.activeRecordings</source>
<target>Currently active records</target>
<target>Active records</target>
</trans-unit>
<trans-unit id="stats.activitiesTotal">
<source>stats.activitiesTotal</source>
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Amount users</target>
</trans-unit>
<trans-unit id="stats.activityTotal">
<source>stats.activityTotal</source>
<target>Amount activities</target>
</trans-unit>
<trans-unit id="stats.projectsTotal">
<source>stats.projectsTotal</source>
<trans-unit id="stats.projectTotal">
<source>stats.projectTotal</source>
<target>Amount projects</target>
</trans-unit>
<trans-unit id="stats.customerTotal">

View File

@@ -565,50 +565,46 @@
</trans-unit>
<!--
Statistics
Statistics data for Dashboard & Users profile
-->
<trans-unit id="stats.durationThisMonth">
<source>stats.durationThisMonth</source>
<trans-unit id="stats.durationMonth">
<source>stats.durationMonth</source>
<target>Ore mensili</target>
</trans-unit>
<trans-unit id="stats.amountThisMonth">
<source>stats.amountThisMonth</source>
<target>Reddito mensile</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Totale ore</target>
</trans-unit>
<trans-unit id="stats.amountMonth">
<source>stats.amountMonth</source>
<target>Reddito mensile</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Totale reddito</target>
</trans-unit>
<!--
Dashboard
-->
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Conteggio utenti</target>
</trans-unit>
<trans-unit id="stats.userActiveThisMoth">
<source>stats.userActiveThisMoth</source>
<trans-unit id="stats.userActiveMonth">
<source>stats.userActiveMonth</source>
<target>Utenti attivi questo mese</target>
</trans-unit>
<trans-unit id="stats.userActiveEver">
<source>stats.userActiveEver</source>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Totale utenti attivi</target>
</trans-unit>
<trans-unit id="stats.activeRecordings">
<source>stats.activeRecordings</source>
<target>Totale registrazioni attive</target>
</trans-unit>
<trans-unit id="stats.activitiesTotal">
<source>stats.activitiesTotal</source>
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Conteggio utenti</target>
</trans-unit>
<trans-unit id="stats.activityTotal">
<source>stats.activityTotal</source>
<target>Conteggio attività</target>
</trans-unit>
<trans-unit id="stats.projectsTotal">
<source>stats.projectsTotal</source>
<trans-unit id="stats.projectTotal">
<source>stats.projectTotal</source>
<target>Conteggio progetti</target>
</trans-unit>
<trans-unit id="stats.customerTotal">

View File

@@ -577,50 +577,46 @@
</trans-unit>
<!--
Statistics
Statistics data for Dashboard & Users profile
-->
<trans-unit id="stats.durationThisMonth">
<source>stats.durationThisMonth</source>
<trans-unit id="stats.durationMonth">
<source>stats.durationMonth</source>
<target>Отработанное время за этот месяц</target>
</trans-unit>
<trans-unit id="stats.amountThisMonth">
<source>stats.amountThisMonth</source>
<target>Оборот за этот месяц</target>
</trans-unit>
<trans-unit id="stats.durationTotal">
<source>stats.durationTotal</source>
<target>Общее отработанное время</target>
</trans-unit>
<trans-unit id="stats.amountMonth">
<source>stats.amountMonth</source>
<target>Оборот за этот месяц</target>
</trans-unit>
<trans-unit id="stats.amountTotal">
<source>stats.amountTotal</source>
<target>Общий оборот</target>
</trans-unit>
<!--
Dashboard
-->
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Всего пользователей</target>
</trans-unit>
<trans-unit id="stats.userActiveThisMoth">
<source>stats.userActiveThisMoth</source>
<trans-unit id="stats.userActiveMonth">
<source>stats.userActiveMonth</source>
<target>Активные пользователи в этом месяце</target>
</trans-unit>
<trans-unit id="stats.userActiveEver">
<source>stats.userActiveEver</source>
<trans-unit id="stats.userActiveTotal">
<source>stats.userActiveTotal</source>
<target>Когда либо активные пользователи </target>
</trans-unit>
<trans-unit id="stats.activeRecordings">
<source>stats.activeRecordings</source>
<target>Текущий активный хронометраж</target>
</trans-unit>
<trans-unit id="stats.ctivitiesTotal">
<source>stats.activitiesTotal</source>
<trans-unit id="stats.userTotal">
<source>stats.userTotal</source>
<target>Всего пользователей</target>
</trans-unit>
<trans-unit id="stats.activityTotal">
<source>stats.activityTotal</source>
<target>Количество дейтельностей</target>
</trans-unit>
<trans-unit id="stats.projectsTotal">
<source>stats.projectsTotal</source>
<trans-unit id="stats.projectTotal">
<source>stats.projectTotal</source>
<target>Количество проектов</target>
</trans-unit>
<trans-unit id="stats.customerTotal">

View File

@@ -8,18 +8,18 @@ try to add it as soon as possible.
## User manual
For the most part Kimai usage should be self-explanatory, so we will only cover topics here which were
For the most parts Kimai usage should be self-explanatory, so we will only cover topics here which were
[requested](https://github.com/kevinpapst/kimai2/issues) by the community.
- [Timesheets](timesheet.md) - information about timesheets
## Developer & Administrator
- [Kimai configurations](configurations.md) - application configs, which can only be changed in config files
- [Developer docu](developers.md) - how to extend Kimai's feature set
- [Timesheets](timesheet.md) - information about timesheets
- [User and Security](users.md) - docu for user and security topics, like authentication, registration and roles
- [Configurations](configurations.md) - intro into the global application configs
- [Emails](emails.md) - transport configuration and handling of emails
- [Dashboard & widgets](dashboard.md) - how to configure widgets and dashboard sections
- [Calendar](calendar.md) - the Timesheet calendar view
- [Developer docu](developers.md) - how to extend Kimai's feature set
- [Theme settings](theme.md) - theme related settings
- [FAQ](faq.md) - some answers to frequently asked questions
- [Emails](emails.md) - transport configuration and handling of emails
- [API](developers_api.md) - how to use the JSON API
- [Translations](translations.md) - all about languages and translations

45
var/docs/calendar.md Normal file
View File

@@ -0,0 +1,45 @@
# Calendar
Kimai 2 provides a calendar view, which displays your timesheet entries in a easy readable format.
You can choose between a monthly, weekly and daily view.
The calendar view look and feel is configured with the config keys below `kimai.calendar` in `kimai.yaml`:
```yaml
kimai:
calendar:
week_numbers: true
day_limit: 4
businessHours:
days: [1, 2, 3, 4, 5]
begin: '08:00'
end: '20:00'
```
- `week_numbers` - whether week numbers should be displayed in the monthly view (default: true)
- `day_limit` defined the max amount of items to be displayed for one day in the monthly view (default: 4)
- `businessHours.days` defines your working days, which will be highlighted in the weekly and daily view. counting starts with sunday and the index 0, so 1 = monday, ..., 6 = saturday. (default: 1-5 / monday to friday)
- `businessHours.begin` the start time of your working day, which will be highlighted in the weekly and daily view (default: 08:00 / 8am)
- `businessHours.end` the end time of your working day, which will be highlighted in the weekly and daily view (default: 20:00 / 8pm)
#### Integrating google calender
If you want to embed Google calendars e.g. to display regional holidays or company events you can import (multiple) Google calendars.
- read how to obtain your [Google API key and find the Calender ID](https://fullcalendar.io/docs/google-calendar)
- add the optional `kimai.calendar.google` configuration
- you can add any number of sources under the `kimai.calendar.google.sources` node, each must have its own name (like `holidays` and `company` in this example)
```yaml
kimai:
calendar:
google:
api_key: 'your-restricted-google-api-key'
sources:
holidays:
id: 'de.german#holiday@group.v.calendar.google.com'
color: '#ccc'
company:
id: 'de.german#holiday@group.v.calendar.google.com'
color: '#cc0000'
```

View File

@@ -17,13 +17,20 @@ Configuration of Kimai is spread in all files in the `config/`directory but main
- `.env` - environment specific settings
- `config/packages/kimai.yaml` - Kimai specific settings
- `config/packages/admin_lte.yaml` - theme specific settings ([read more](https://github.com/kevinpapst/AdminLTEBundle/blob/master/Resources/docs/configurations.md))
- `config/packages/admin_lte.yaml` - Kimai base theme
- `config/packages/fos_user.yaml` - user management and email settings
- `config/packages/local.yaml` - your local configuration settings
There are several other configurations that could potentially be interesting for you in [config/packages/*.yaml](../../config/packages/).
If you want to adjust a setting from any of these files, use `local.yaml`.
If you want to adjust a setting from any of these files, use `local.yaml` (see below).
#### Other topics
- [Theme settings](theme.md) - in `kimai.yaml` and `admin_lte.yaml`
- [Email configuration](emails.md) - in `swiftmailer.yaml`
- [Dashboard widgets](dashboard.md) - in `kimai.yaml`
- [Calendar](calendar.md) - in `kimai.yaml`
## Overwriting local configs (local.yaml)
@@ -63,10 +70,6 @@ bin/console cache:warmup --env=prod
Depending on your setup it might be necessary to execute these commands as webserver user,
please read the [UPGRADING guide](../../UPGRADING.md) for more details.
## Emails (swiftmailer.yaml)
Read more about [email configuration](emails.md).
## Security
Kimai uses the FOSUserBundle for security related tasks like user management. Its configuration can be found in [fos_user.yaml](../../config/packages/fos_user.yaml).
@@ -176,7 +179,7 @@ kimai:
duration: 60
```
A rule which is often used is to round to a mulitple of 10:
A rule which is often used is to round up to a mulitple of 10:
```yaml
kimai:
@@ -219,47 +222,3 @@ kimai:
days: ['saturday','sunday']
factor: 1.5
```
### Timesheet - Calendar view (kimai.yaml)
The calendar view look and feel can be be configured with the config keys below `kimai.calendar`:
```yaml
kimai:
calendar:
week_numbers: true
day_limit: 4
businessHours:
days: [1, 2, 3, 4, 5]
begin: '08:00'
end: '20:00'
```
- `week_numbers` - whether week numbers should be displayed in the monthly view (default: true)
- `day_limit` defined the max amount of items to be displayed for one day in the monthly view (default: 4)
- `businessHours.days` defines your working days, which will be highlighted in the weekly and daily view. counting starts with sunday and the index 0, so 1 = monday, ..., 6 = saturday. (default: 1-5 / monday to friday)
- `businessHours.begin` the start time of your working day, which will be highlighted in the weekly and daily view (default: 08:00 / 8am)
- `businessHours.end` the end time of your working day, which will be highlighted in the weekly and daily view (default: 20:00 / 8pm)
#### Integrating google calender
If you want to embed Google calendar events e.g. to display regional holidays or company partys you can import (multiple) Google calendars.
- read how to obtain your [Google API key and find the Calender ID](https://fullcalendar.io/docs/google-calendar)
- add the optional `kimai.calendar.google` configuration
- you can add any number of sources under the `kimai.calendar.google.sources` node, each must have its own name (like `holidays` and `company` in this example)
```yaml
kimai:
calendar:
google:
api_key: 'your-restricted-google-api-key'
sources:
holidays:
id: 'de.german#holiday@group.v.calendar.google.com'
color: '#ccc'
company:
id: 'de.german#holiday@group.v.calendar.google.com'
color: '#cc0000'
```

126
var/docs/dashboard.md Normal file
View File

@@ -0,0 +1,126 @@
# Dashboard
Read the [configuration chapter](configurations.md) before you start changing your configs.
## Widgets
Widgets are defined in the configuration node `kimai.widgets` and you find the pre-defined ones in [kimai.yaml](../../config/packages/kimai.yaml).
Here is an example of one widget definition:
```yaml
kimai:
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
```
Widgets are currently only used in the Dashboard, but maybe used in other template parts as well in the future.
### Widget settings
- `title` - the title of your widget (will be translated)
- `query` - the allowed queries to use for populating the widget data are `duration`, `rate`, `active` and `users`
- `user` - whether the query is executed for the current user or for all users. possible values are `true` and `false` (default: `false` - all data is used to calculate the result)
- `begin` - setting the start date for the query, formatted with the [PHP DateTime syntax](http://php.net/manual/en/datetime.formats.relative.php) (default: `null` - a query matching any start date)
- `end` - setting the end date for the query, formatted with the [PHP DateTime syntax](http://php.net/manual/en/datetime.formats.relative.php) (default: `null` - a query matching any end date)
- `color` - a color name, see all possible names in [theme settings](theme.md) (default: ``)
- `icon` - an icon alias from [theme settings](theme.md) or any other icon from [Font Awesome 5](https://fontawesome.com/icons) (default: `null` - no icon)
## Dashboard sections
Within the dashboard all widgets are placed in sections (rows) like this:
```yaml
kimai:
dashboard:
user_duration:
title: dashboard.you
order: 10
permission: ROLE_USER
widgets: [userDurationToday, userDurationWeek, userDurationMonth, userDurationYear, userDurationTotal]
```
### Section settings
- `permission` - the name of a role who is allowed to see the widgets, see [users](users.md)
- `title` - the title of a section, if omitted no title will be shown (default: `null`)
- `widgets` - an array of widget names (see above for an example)
- `order` - allows to define the order of the section
### Default sections
The dashboard has the following default sections:
- `user_duration` - order 10
- `user_rates` - order 20
- `duration` - order 30
- `active_users` - order 40
- `rates` - order 50
- `admin` - order 100 (this section is programmatically added)
### Overwriting sections
A section with an empty list of widgets will not be rendered.
If you don't like the default sections you can remove them by overwriting their widget list like this:
```yaml
kimai:
dashboard:
user_duration: { widgets: [] }
user_rates: { widgets: [] }
duration: { widgets: [] }
active_users: { widgets: [] }
rates: { widgets: [] }
```
It's also possible to change the title or the list of widgets for every section like this:
```yaml
kimai:
dashboard:
user_duration:
title: 'some fancy widgets'
widgets: [userDurationWeek, userDurationMonth, userDurationYear]
```
### Reorder sections
If you want to reorder the sections, you can overwrite as many sections as you want and simply change their `order` key.
Lower numbers will be rendered before higher numbers.
```yaml
kimai:
dashboard:
user_duration: { order: 30 }
user_rates: { order: 90 }
duration: { order: 40 }
active_users: { order: 20 }
rates: { order: 50 }
```
### Test widgets for the "brave users"
While working on the widgets, some of them were created for testing future functionalities of Kimai.
You can try them out, but I can't guarantee that they will be supported in the future, as I don't consider them to be `stable` for now.
You can try this configuration in your `local.yaml` to see a chart instead of plain boxes for the last 2 years of monthly times:
```yaml
kimai:
widgets:
userRecapTwoYears: { title: stats.durationToday, query: monthly, user: true, begin: '01 january last year 00:00:00', end: '31 december this year 23:59:59', color: '#3b8bba|rgba(0,115,183,0.6);#c1c7d1|rgb(210,214,222,0.9)' }
dashboard:
user_duration:
type: chart
widgets: [userRecapTwoYears, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
```
A brief description: the `monthly` query allows to fetch data by month and year, but the default widgets are not able to render this data.
So internally a __chart section template__ (`type: chart`) is used to render the data, which is also able to fetch and display the data for
all of the following widgets in the configured section (the above example overwrites the widget config of the default `user_duration` section).
In order to work, the chart widget `userRecapTwoYears` needs to be the first in the section.
This widgets setup and configuration will likely change in the future, so keep an eye on this config if this widget
doesn't work after one of the next updates!

View File

@@ -135,7 +135,8 @@ And that's how to use it:
```php
use App\Event\DashboardEvent;
use App\Model\WidgetRow;
use App\Model\DashboardSection;
use App\Model\Widget;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class MyDashboardSubscriber implements EventSubscriberInterface
@@ -147,14 +148,19 @@ class MyDashboardSubscriber implements EventSubscriberInterface
public function onDashboardEvent(DashboardEvent $event)
{
$row = new WidgetRow('my_id', 'optional.row.title');
// this needs to be a valid twig template string
$row->add("{{ widgets.info_box_counter('a title', 100, 'far fa-hourglass', 'green') }}");
$event->addWidgetRow($row);
$section = new DashboardSection('optional.row.title');
$widget = new Widget('A title', 100);
$widget
->setIcon('duration')
->setColor('purple')
->setType(Widget::TYPE_COUNTER)
;
$section->addWidget($widget);
$event->addSection($section);
}
}
```
For more details check the [official dashboard subscriber](../../src/EventSubscriber/DashboardSubscriber.php).
For more details check this [dashboard subscriber](../../src/EventSubscriber/DashboardSubscriber.php).
## Adding tabs to the "control sidebar"
@@ -172,7 +178,8 @@ admin_lte:
icon: "fas fa-question-circle"
template: sidebar/home.html.twig
```
You have to define the `icon` (FontAwesome 5) to be used and one of: `controller` action or twig `template`.
You have to define the `icon` ([read more](theme.md)) to be used and either `controller` action or twig `template`.
Both follow the default naming syntax and you can link your bundle here instead of the app controller or templates.
You should NOT add them in `config/packages/kimai.yaml` but in your own bundle or the `local.yaml` [config](configurations.md),
otherwise they might get lost during an update.

View File

@@ -1,7 +1,7 @@
# Installation
If you want to install Kimai v2 in your production environment and have SSH access, then switch to the official
installation instruction in our [README](https://github.com/kevinpapst/kimai2/blob/master/README.md).
installation instruction in our [README](https://github.com/kevinpapst/kimai2/#installation).
You need GIT and [Composer](https://getcomposer.org/doc/00-intro.md) on the machine where you want to install Kimai.

98
var/docs/theme.md Normal file
View File

@@ -0,0 +1,98 @@
# Theme
Kimai uses the [AdminLTE theme](https://github.com/kevinpapst/AdminLTEBundle/) which can be configured in the file `config/packages/admin_lte.yaml`.
You find the theme specific documentation [here](https://github.com/kevinpapst/AdminLTEBundle/blob/master/Resources/docs/configurations.md).
All Kimai specific theme settings will be available in the twig templates with the global `kimai_context` key, e.g.
```twig
{{ kimai_context.box_color }}
```
## Active entries warning
A small colored warning sign will be shown, if a user has more than 3 active timesheet entries.
You can change this soft limit by setting the config key `kimai.theme.active_warning` in your `local.yaml`:
```yaml
kimai:
theme:
active_warning: 2
```
## Colors
Kimai allows you to configure colors in several places throughout the theme.
Possible values are:
- `aqua`
- `black`
- `blue`
- `gray`
- `green`
- `purple`
- `red`
- `yellow`
### Fallback color
Whenever a color is required but none is configured, Kimai uses a fallback language from the config key `kimai.theme.box_color`.
You can change the default color `green` to any one from the above in your `local.yaml`:
```yaml
kimai:
theme:
box_color: 'blue'
```
The fallback color should be applied whenever an optional color is configurable by the user:
```twig
<div class="info-box bg-{{ color|default(kimai_context.box_color) }}"></div>
```
## Icons
Kimai allows you to configure icons in several places (provided by [Font Awesome 5](https://fontawesome.com/icons)) and ships
with a pre-defined list of icon aliases to guarantee a consistent look.
The pre-defined icons aliases are:
- `user`
- `customer`
- `project`
- `activity`
- `admin`
- `invoice`
- `timesheet`
- `dashboard`
- `logout`
- `trash`
- `delete`
- `repeat`
- `edit`
- `manual`
- `help`
- `start`
- `start-small`
- `stop`
- `stop-small`
- `filter`
- `create`
- `list`
- `print`
- `visibility`
- `calendar`
- `money`
- `duration`
Icon aliases can be used by applying the `icon` filter, e.g.
```
<i class="{{ 'money'|icon }}"></i>
```

View File

@@ -2,26 +2,18 @@
User manual on the timesheet tables and actions.
## Edit timesheet
Kimai 2 provides also a [calendar view](calendar.md), which displays your timesheet entries in an easy readable format.
### Duration only
## Duration only
When the `duration_only` mode is activated, you will only see the `date` and `duration` fields (see [configurations chapter](configurations.md)).
The `duration` field supports entering data in the following formats:
| Name | Format | Description | Examples |
|---|---|---|
|---|---|---|---|
| Colons | {hours}:{minutes}[:{seconds}] | Seconds are optional, overflow is supported for every field | `2:27` = 2 Hours, 27 Minutes / `3:143:13` = 5 Hours, 23 Minutes, 13 Seconds|
| Natural | {hours}h{minutes}m[{seconds}s] | Seconds are optional, overflow is supported for every field | `2h27m` = 2 Hours, 27 Minutes / `3h143m13s` = 5 Hours, 23 Minutes, 13 Seconds |
| Seconds | {seconds} | | `3600` = 1 Hour / `8820` = 2 Hours, 27 Minutes |
Please note: if time rounding is activated (which is the default behaviour), then your entered seconds might be removed after submitting the form.
## Calendar view
Kimai 2 provides a calendar view, which displays your timesheet entries in a easy readable format.
You can choose between a monthly, weekly and daily view.
It's also possible to include further [Google calendar sources](configurations.md) from Google, e.g. if you want to display regional holidays.

View File

@@ -1,9 +1,9 @@
# Users
There are multiple pre-defined roles in Kimai, which define the ACLs. A user can only inherit one role, where the roles extend each user.
## Roles & Permissions
There are multiple pre-defined roles in Kimai, which define the ACLs. A user can only inherit one role, where the roles extend each user.
| Role name | extends | Gives permission for |
|---|---|---|
| ROLE_CUSTOMER | - | Currently has no permissions, but was reserved for future functionality |
@@ -14,7 +14,7 @@ There are multiple pre-defined roles in Kimai, which define the ACLs. A user can
## Login
- User can login with their username or email
- User can login with username or email
- If you activate the `Remember me` option, you can use use the most common functions within the next days without a new login
### Remember me login

View File

@@ -1092,9 +1092,25 @@ chalk@^2.4.1:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
chart.js@1.1.*:
version "1.1.1"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-1.1.1.tgz#a9b17054220bd45cbdb176fd6bcb8783ef871a7d"
chart.js@~2.7.2:
version "2.7.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.2.tgz#3c9fde4dc5b95608211bdefeda7e5d33dffa5714"
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chartjs-color-string@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1"
dependencies:
color-name "^1.0.0"
chartjs-color@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae"
dependencies:
chartjs-color-string "^0.5.0"
color-convert "^0.5.3"
chokidar@^2.0.0, chokidar@^2.0.2:
version "2.0.3"
@@ -1196,6 +1212,10 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
color-convert@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
@@ -3446,7 +3466,7 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd
dependencies:
minimist "0.0.8"
moment@^2.20.1, moment@^2.9.0:
moment@^2.10.2, moment@^2.20.1, moment@^2.9.0:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"