dashboard widgets are configurable via config (#269)
This commit is contained in:
10
UPGRADING.md
10
UPGRADING.md
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
105
src/Model/DashboardSection.php
Normal file
105
src/Model/DashboardSection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
219
src/Model/Widget.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
82
src/Repository/WidgetRepository.php
Normal file
82
src/Repository/WidgetRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
180
templates/dashboard/section-chart.html.twig
Normal file
180
templates/dashboard/section-chart.html.twig
Normal 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>
|
||||
34
templates/dashboard/section-simple.html.twig
Normal file
34
templates/dashboard/section-simple.html.twig
Normal 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>
|
||||
10
templates/embeds/widget-counter.html.twig
Normal file
10
templates/embeds/widget-counter.html.twig
Normal 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>
|
||||
14
templates/embeds/widget-more.html.twig
Normal file
14
templates/embeds/widget-more.html.twig
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
91
tests/EventSubscriber/DashboardSubscriberTest.php
Normal file
91
tests/EventSubscriber/DashboardSubscriberTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
82
tests/Repository/WidgetRepositoryTest.php
Normal file
82
tests/Repository/WidgetRepositoryTest.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
45
var/docs/calendar.md
Normal 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'
|
||||
```
|
||||
@@ -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
126
var/docs/dashboard.md
Normal 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!
|
||||
@@ -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.
|
||||
|
||||
@@ -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
98
var/docs/theme.md
Normal 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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
28
yarn.lock
28
yarn.lock
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user