users weekly stats as bar-chart in dashboard (#847)

This commit is contained in:
Kevin Papst
2019-06-13 18:38:49 +02:00
committed by GitHub
parent e1f862507e
commit 3311f17bbb
96 changed files with 2754 additions and 846 deletions

View File

@@ -27,12 +27,12 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, re
### About
This new version of the open source timetracker Kimai. It is in a stable development phase, usable in production and
with most advanced features from Kimai 1 and many new ones:
This is new version of the open source timetracker Kimai. It is in a stable development phase, usable in production and
with most advanced features from Kimai 1 and many new ones, including but not limited to:
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
LDAP and built-in authentication, customizable role permissions, responsive and usable on your mobile device,
hourly and fixed rates, advanced filtering, support for plugins and many more.
LDAP and built-in authentication, customizable role permissions, responsive and ready for your mobile device,
hourly and fixed rates, advanced filtering, money and time budgets and report, support for plugins and many more.
## Installation
@@ -40,7 +40,7 @@ hourly and fixed rates, advanced filtering, support for plugins and many more.
- [Docker](https://www.kimai.org/documentation/docker.html) - containerized
- [Development](https://www.kimai.org/documentation/installation.html#development-installation) - on your local machine
- [1-click installer](https://www.kimai.org/documentation/installation.html#hosting-and-1-click-installations) - hosted environments
- [FTP](https://www.kimai.org/documentation/installation.html#ftp-installation) - unfortunately still widely used
- [FTP](https://www.kimai.org/documentation/installation.html#ftp-installation) - unfortunately still widely used ;-)
### Updating Kimai
@@ -49,7 +49,7 @@ hourly and fixed rates, advanced filtering, support for plugins and many more.
### Plugins
- [Plugin marketplace](https://www.kimai.org/store/) - users find existing plugins here
- [Plugin marketplace](https://www.kimai.org/store/) - find existing plugins here
- [Developer documentation](https://www.kimai.org/documentation/developers.html) - how to create a plugin
## Roadmap and releases

View File

@@ -35,6 +35,7 @@ Removed permission:
- API: Format for queries including a datetime object fixed to use HTML5 format (previously `2019-03-02 14:23` - now `2019-03-02T14:23:00`)
- **Permission config**: the `permissions` definition in your `local.yaml` needs to be verified/changed, as the internal structure was highly optimized to simplify the definition.
Thanks to the new structure, you should be able to remove almost everything from your `local.yaml` (tip: start over from scratch!). Please read [the updated permission docu](https://www.kimai.org/documentation/permissions.html).
- default widgets were removed from `kimai.yaml`, that shouldn't cause any issues ... but if something is odd, now you know [where to look for help](https://www.kimai.org/documentation/dashboard.html)
## [0.9](https://github.com/kevinpapst/kimai2/releases/tag/0.9)

View File

@@ -41,7 +41,8 @@ export default class KimaiAlert extends KimaiPlugin {
}
Swal.fire({
timer: 1500,
timer: 2000,
toast: true,
position: 'top-end',
showConfirmButton: false,
type: 'success',

30
composer.lock generated
View File

@@ -2971,33 +2971,37 @@
},
{
"name": "kevinpapst/adminlte-bundle",
"version": "2.7.3",
"version": "2.8.1",
"source": {
"type": "git",
"url": "https://github.com/kevinpapst/AdminLTEBundle.git",
"reference": "bbe650bda5319ac55637457e23d60bcafb734da1"
"reference": "62df10d0565f2ed5971b1a28ef30195ad1893807"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/bbe650bda5319ac55637457e23d60bcafb734da1",
"reference": "bbe650bda5319ac55637457e23d60bcafb734da1",
"url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/62df10d0565f2ed5971b1a28ef30195ad1893807",
"reference": "62df10d0565f2ed5971b1a28ef30195ad1893807",
"shasum": ""
},
"require": {
"php": "^7.1.3",
"symfony/config": ">3.4",
"symfony/dependency-injection": ">3.4",
"symfony/event-dispatcher": ">3.4",
"symfony/http-foundation": ">3.4",
"symfony/http-kernel": ">3.4",
"symfony/options-resolver": ">3.4",
"symfony/security-core": ">3.4",
"symfony/config": ">4.0",
"symfony/dependency-injection": ">4.0",
"symfony/event-dispatcher": ">4.0",
"symfony/http-foundation": ">4.0",
"symfony/http-kernel": ">4.0",
"symfony/options-resolver": ">4.0",
"symfony/security-core": ">4.0",
"twig/twig": ">2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.10",
"knplabs/knp-menu-bundle": "^2.2",
"phpspec/prophecy": "^1.6",
"phpunit/phpunit": "^7.3"
"phpstan/phpstan": "^0.11.8",
"phpstan/phpstan-phpunit": "^0.11.2",
"phpunit/phpunit": "^7.3",
"symfony/framework-bundle": ">4.0"
},
"suggest": {
"friendsofsymfony/user-bundle": "Allows easy user management and security support",
@@ -3028,7 +3032,7 @@
}
],
"description": "Admin theme bundle for Symfony 4 based on AdminLTE 2.4.8 with FOSUserBundle support",
"time": "2019-05-13T16:17:08+00:00"
"time": "2019-06-13T11:25:39+00:00"
},
{
"name": "kimai/kimai2-composer",

View File

@@ -13,5 +13,12 @@ jms_serializer:
App:
namespace_prefix: "App"
path: "%kernel.root_dir%/../config/serializer/App"
warmup:
paths:
included:
- "%kernel.root_dir%/Entity/"
- "%kernel.root_dir%/API/Model/"
- "%kernel.root_dir%/../vendor/friendsofsymfony/user-bundle/Model"
excluded: []
property_naming:
id: 'jms_serializer.identical_property_naming_strategy'

View File

@@ -169,7 +169,8 @@ kimai:
title: dashboard.you
order: 10
permission: ROLE_USER
widgets: [userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
type: 'compoundChart'
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates:
title: ~
order: 20
@@ -190,34 +191,6 @@ kimai:
order: 50
permission: view_rate_other_timesheet
widgets: [amountToday, amountWeek, amountMonth, amountYear]
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
userDurationWeek: { title: stats.durationWeek, query: duration, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
userDurationMonth: { title: stats.durationMonth, query: duration, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
userDurationYear: { title: stats.durationYear, query: duration, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
userDurationTotal: { title: stats.durationTotal, query: duration, user: true, icon: duration, color: red }
userAmountToday: { title: stats.amountToday, query: rate, user: true, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
userAmountWeek: { title: stats.amountWeek, query: rate, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
userAmountMonth: { title: stats.amountMonth, query: rate, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
userAmountYear: { title: stats.amountYear, query: rate, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
userAmountTotal: { title: stats.amountTotal, query: rate, user: true, icon: money, color: red }
durationToday: { title: stats.durationToday, query: duration, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
durationWeek: { title: stats.durationWeek, query: duration, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
durationMonth: { title: stats.durationMonth, query: duration, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
durationYear: { title: stats.durationYear, query: duration, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
durationTotal: { title: stats.durationTotal, query: duration, icon: duration, color: red }
amountToday: { title: stats.amountToday, query: rate, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
amountWeek: { title: stats.amountWeek, query: rate, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
amountMonth: { title: stats.amountMonth, query: rate, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
amountYear: { title: stats.amountYear, query: rate, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
amountTotal: { title: stats.amountTotal, query: rate, icon: money, color: red }
activeUsersToday: { title: stats.userActiveToday, query: users, begin: '00:00:00', end: '23:59:59', icon: user, color: green }
activeUsersWeek: { title: stats.userActiveWeek, query: users, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: user, color: blue }
activeUsersMonth: { title: stats.userActiveMonth, query: users, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: user, color: purple }
activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow }
activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red }
# --------------------------------------------------------------------------------

View File

@@ -77,6 +77,10 @@ services:
App\Export\ServiceExport:
arguments: [!tagged export.renderer]
App\Widget\WidgetService:
arguments:
$renderer: !tagged widget.renderer
# ================================================================================
# DATABASE
# ================================================================================

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.784b5d0d.js",
"build/0.66b5fd0b.js",
"build/1.93f0cf2b.js",
"build/app.26c26669.js"
"build/app.0ac6f178.js"
],
"css": [
"build/app.7f4a18a1.css"
@@ -35,7 +35,7 @@
"build/runtime.784b5d0d.js": "sha384-+AsC7zoefPga1AkczlNSCXcbGwD0TwokkldvNISHf1nMlOpafanLjtKvlZy9cVsm",
"build/0.66b5fd0b.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.93f0cf2b.js": "sha384-pmnwzvj+UJhDD3uDf+ekLARMBRtMDxWGbnJDrJvwIoRZd3MW/tOGzAW5Ybhbvw20",
"build/app.26c26669.js": "sha384-SyN1k+3kOWKnJSOyY+atWLSXNubTbsgz1DZ47jH8PrgLFj9WjXB9GRhO6E7tpJWf",
"build/app.0ac6f178.js": "sha384-eORpGh3VKK5IISACV4rmp8CpkZ+s47G8aKaZnXVpswgZccU/YcJPkSa5+jhcI88T",
"build/app.7f4a18a1.css": "sha384-p8Dw7CP1RxxJ2K5yuaNpCw8r1lKd64DlVA2IQeEGxBAC2QxOjweafd8w9CSMk0GC",
"build/2.c06cd055.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.2bb71e2e.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.93f0cf2b.js": "build/1.93f0cf2b.js",
"build/2.c06cd055.js": "build/2.c06cd055.js",
"build/app.css": "build/app.7f4a18a1.css",
"build/app.js": "build/app.26c26669.js",
"build/app.js": "build/app.0ac6f178.js",
"build/calendar.css": "build/calendar.b0551848.css",
"build/calendar.js": "build/calendar.59417f4c.js",
"build/chart.js": "build/chart.2bb71e2e.js",

View File

@@ -10,8 +10,10 @@
namespace App\Controller;
use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Repository\WidgetRepository;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\CompoundRow;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Annotation\Route;
@@ -29,9 +31,9 @@ class DashboardController extends AbstractController
*/
protected $eventDispatcher;
/**
* @var WidgetRepository
* @var WidgetService
*/
protected $repository;
protected $widgets;
/**
* @var array
*/
@@ -39,13 +41,13 @@ class DashboardController extends AbstractController
/**
* @param EventDispatcherInterface $dispatcher
* @param WidgetRepository $repository
* @param WidgetService $service
* @param array $dashboard
*/
public function __construct(EventDispatcherInterface $dispatcher, WidgetRepository $repository, array $dashboard)
public function __construct(EventDispatcherInterface $dispatcher, WidgetService $service, array $dashboard)
{
$this->eventDispatcher = $dispatcher;
$this->repository = $repository;
$this->widgets = $service;
$this->dashboard = $dashboard;
}
@@ -61,22 +63,26 @@ class DashboardController extends AbstractController
continue;
}
if (!$this->isGranted($widgetRow['permission'])) {
if (null !== $widgetRow['permission'] && !$this->isGranted($widgetRow['permission'])) {
continue;
}
$row = new DashboardSection($widgetRow['title'] ?? null);
$row
->setOrder($widgetRow['order'])
->setType($widgetRow['type'])
;
// TODO this should be dynamic
if ($widgetRow['type'] === 'compoundChart') {
$row = new CompoundChart();
} else {
$row = new CompoundRow();
}
$row->setTitle($widgetRow['title'] ?? '');
$row->setOrder($widgetRow['order']);
foreach ($widgetRow['widgets'] as $widgetName) {
if (!$this->repository->has($widgetName)) {
throw new \Exception('Unknwon widget: ' . $widgetName);
if (!$this->widgets->hasWidget($widgetName)) {
throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
}
$row->addWidget($this->repository->get($widgetName, $event->getUser()));
$row->addWidget($this->widgets->getWidget($widgetName));
}
$event->addSection($row);
@@ -91,7 +97,7 @@ class DashboardController extends AbstractController
uasort(
$sections,
function (DashboardSection $a, DashboardSection $b) {
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
if ($a->getOrder() == $b->getOrder()) {
return 0;
}
@@ -101,7 +107,7 @@ class DashboardController extends AbstractController
);
return $this->render('dashboard/index.html.twig', [
'widget_rows' => $sections
'widgets' => $sections
]);
}
}

View File

@@ -0,0 +1,41 @@
<?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\DependencyInjection\Compiler;
use App\Kernel;
use App\Repository\WidgetRepository;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all widgets to the WidgetRepository.
*/
class WidgetCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(WidgetRepository::class)) {
return;
}
$definition = $container->findDefinition(WidgetRepository::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_WIDGET);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('registerWidget', [new Reference($id)]);
}
}
}

View File

@@ -9,8 +9,6 @@
namespace App\DependencyInjection;
use App\Model\DashboardSection;
use App\Model\Widget;
use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
@@ -319,6 +317,15 @@ class Configuration implements ConfigurationInterface
->booleanNode('show_about')
->defaultTrue()
->end()
->arrayNode('chart')
->addDefaultsIfNotSet()
->children()
->scalarNode('background_color')->defaultValue('rgba(0,115,183,0.7)')->end()
->scalarNode('border_color')->defaultValue('#3b8bba')->end()
->scalarNode('grid_color')->defaultValue('rgba(0,0,0,.05)')->end()
->scalarNode('height')->defaultValue('200')->end()
->end()
->end()
->end()
;
@@ -365,12 +372,7 @@ class Configuration implements ConfigurationInterface
->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()
->scalarNode('type')->defaultValue('counter')->end()
->end()
->end()
;
@@ -390,15 +392,10 @@ class Configuration implements ConfigurationInterface
->arrayPrototype()
->addDefaultsIfNotSet()
->children()
->scalarNode('type')
->validate()
->ifNotInArray([DashboardSection::TYPE_SIMPLE, DashboardSection::TYPE_CHART])->thenInvalid('Unknown section type')
->end()
->defaultValue(DashboardSection::TYPE_SIMPLE)
->end()
->scalarNode('type')->defaultValue('simple')->end()
->integerNode('order')->defaultValue(0)->end()
->scalarNode('title')->end()
->scalarNode('permission')->isRequired()->end()
->scalarNode('permission')->defaultNull()->end()
->arrayNode('widgets')
->isRequired()
->performNoDeepMerging()

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
trait BudgetTrait
{

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\User;
use App\Model\DashboardSection;
use App\Widget\WidgetContainerInterface;
use Symfony\Component\EventDispatcher\Event;
class DashboardEvent extends Event
@@ -22,41 +22,31 @@ class DashboardEvent extends Event
*/
protected $user;
/**
* @var DashboardSection[]
* @var WidgetContainerInterface[]
*/
protected $widgetRows = [];
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* @return User
*/
public function getUser()
public function getUser(): User
{
return $this->user;
}
/**
* @param DashboardSection $row
* @return DashboardEvent
*/
public function addSection(DashboardSection $row)
public function addSection(WidgetContainerInterface $container): DashboardEvent
{
$this->widgetRows[] = $row;
$this->widgetRows[] = $container;
return $this;
}
/**
* @return DashboardSection[]
* @return WidgetContainerInterface[]
*/
public function getSections()
public function getSections(): array
{
return $this->widgetRows;
}

View File

@@ -11,12 +11,12 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Event\DashboardEvent;
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 App\Widget\Type\CompoundRow;
use App\Widget\Type\More;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -79,7 +79,6 @@ class DashboardSubscriber implements EventSubscriberInterface
/**
* @param DashboardEvent $event
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function onDashboardEvent(DashboardEvent $event)
{
@@ -87,54 +86,68 @@ class DashboardSubscriber implements EventSubscriberInterface
return;
}
$this->addAdminWidgets($event);
}
/**
* @param DashboardEvent $event
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function addAdminWidgets(DashboardEvent $event)
{
$section = new DashboardSection('dashboard.admin');
$section = new CompoundRow();
$section->setTitle('ROLE_ADMIN');
$section->setOrder(100);
$widget = new Widget('stats.userTotal', $this->user->countUser());
$widget
->setRoute('admin_user')
->setIcon('user')
->setColor('green')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
if ($this->security->isGranted('view_user')) {
$section->addWidget(
(new More())
->setId('userTotal')
->setTitle('stats.userTotal')
->setData($this->user->countUser())
->setOptions([
'route' => 'admin_user',
'icon' => 'user',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.customerTotal', $this->customer->countCustomer());
$widget
->setRoute('admin_customer')
->setIcon('customer')
->setColor('blue')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
if ($this->security->isGranted('view_customer')) {
$section->addWidget(
(new More())
->setId('customerTotal')
->setTitle('stats.customerTotal')
->setData($this->customer->countCustomer())
->setOptions([
'route' => 'admin_customer',
'icon' => 'customer',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.projectTotal', $this->project->countProject());
$widget
->setRoute('admin_project')
->setIcon('project')
->setColor('yellow')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
if ($this->security->isGranted('view_project')) {
$section->addWidget(
(new More())
->setId('projectTotal')
->setTitle('stats.projectTotal')
->setData($this->project->countProject())
->setOptions([
'route' => 'admin_project',
'icon' => 'project',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.activityTotal', $this->activity->countActivity());
$widget
->setRoute('admin_activity')
->setIcon('activity')
->setColor('purple')
->setType(Widget::TYPE_MORE)
;
$section->addWidget($widget);
if ($this->security->isGranted('view_activity')) {
$section->addWidget(
(new More())
->setId('activityTotal')
->setTitle('stats.activityTotal')
->setData($this->activity->countActivity())
->setOptions([
'route' => 'admin_activity',
'icon' => 'activity',
'color' => 'primary',
])
);
}
$event->addSection($section);
if (count($section->getWidgets()) > 0) {
$event->addSection($section);
}
}
}

View File

@@ -13,6 +13,7 @@ use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
@@ -20,6 +21,8 @@ use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -37,10 +40,13 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_PLUGIN = 'kimai.plugin';
public const TAG_WIDGET = 'widget';
public const TAG_WIDGET_RENDERER = 'widget.renderer';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public function getCacheDir()
{
@@ -54,12 +60,14 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator');
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag(self::TAG_TIMESHEET_CALCULATOR);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
$container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
@@ -144,6 +152,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
protected function configureRoutes(RouteCollectionBuilder $routes)

View File

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

View File

@@ -0,0 +1,64 @@
<?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\Statistic;
use DateTime;
class Day
{
/**
* @var int
*/
protected $totalDuration = 0;
/**
* @var float
*/
protected $totalRate = 0.00;
/**
* @var DateTime
*/
protected $day;
public function __construct(DateTime $day, int $duration, float $rate)
{
$this->day = $day;
$this->totalDuration = $duration;
$this->totalRate = $rate;
}
public function getDay(): DateTime
{
return $this->day;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
}
public function setTotalDuration(int $seconds): Day
{
$this->totalDuration = $seconds;
return $this;
}
public function getTotalRate(): float
{
return $this->totalRate;
}
public function setTotalRate(float $totalRate): Day
{
$this->totalRate = $totalRate;
return $this;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Model\Statistic;
use InvalidArgumentException;
/**
* Monthly statistics
*/
@@ -23,9 +25,9 @@ class Month
*/
protected $totalDuration = 0;
/**
* @var int
* @var float
*/
protected $totalRate = 0;
protected $totalRate = 0.00;
/**
* @param string $month
@@ -34,7 +36,9 @@ class Month
{
$monthNumber = (int) $month;
if ($monthNumber < 1 || $monthNumber > 12) {
throw new \InvalidArgumentException('Invalid month given, expected 01-12 but given: ' . $monthNumber);
throw new InvalidArgumentException(
sprintf('Invalid month given. Expected 1-12, received "%s".', $monthNumber)
);
}
$this->month = $month;
}
@@ -47,38 +51,24 @@ class Month
return $this->month;
}
/**
* @return int
*/
public function getTotalDuration()
public function getTotalDuration(): int
{
return $this->totalDuration;
}
/**
* @param int $totalDuration
* @return $this
*/
public function setTotalDuration($totalDuration)
public function setTotalDuration(int $seconds): Month
{
$this->totalDuration = $totalDuration;
$this->totalDuration = $seconds;
return $this;
}
/**
* @return int
*/
public function getTotalRate()
public function getTotalRate(): float
{
return $this->totalRate;
}
/**
* @param int $totalRate
* @return $this
*/
public function setTotalRate($totalRate)
public function setTotalRate(float $totalRate): Month
{
$this->totalRate = $totalRate;

View File

@@ -24,7 +24,6 @@ class Year
protected $months = [];
/**
* Year constructor.
* @param string $year
*/
public function __construct($year)
@@ -40,22 +39,14 @@ class Year
return $this->year;
}
/**
* @param Month $month
* @return $this
*/
public function setMonth(Month $month)
public function setMonth(Month $month): Year
{
$this->months[(int) $month->getMonth()] = $month;
return $this;
}
/**
* @param int $month
* @return null|Month
*/
public function getMonth(int $month)
public function getMonth(int $month): ?Month
{
if (isset($this->months[$month])) {
return $this->months[$month];
@@ -67,7 +58,7 @@ class Year
/**
* @return Month[]
*/
public function getMonths()
public function getMonths(): array
{
return array_values($this->months);
}

View File

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

View File

@@ -12,6 +12,7 @@ namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use App\Model\TimesheetStatistic;
@@ -98,6 +99,9 @@ class TimesheetRepository extends AbstractRepository
case self::STATS_QUERY_MONTHLY:
return $this->getMonthlyStats($user, $begin, $end);
case 'daily':
return $this->getDailyStats($user, $begin, $end);
case self::STATS_QUERY_DURATION:
$what = 'SUM(t.duration)';
break;
@@ -253,14 +257,87 @@ class TimesheetRepository extends AbstractRepository
}
$month = new Month($statRow['month']);
$month->setTotalDuration($statRow['duration'])
->setTotalRate($statRow['rate']);
$month->setTotalDuration((int) $statRow['duration'])
->setTotalRate((float) $statRow['rate']);
$years[$curYear]->setMonth($month);
}
return $years;
}
/**
* @param DateTime $begin
* @param DateTime $end
* @param User|null $user
* @return mixed
*/
public function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.duration) as duration')
->addSelect('MONTH(t.begin) as month')
->addSelect('YEAR(t.begin) as year')
->addSelect('DAY(t.begin) as day')
->from(Timesheet::class, 't')
->andWhere($qb->expr()->gte('t.begin', ':from'))
->setParameter('from', $begin, Type::DATETIME)
->andWhere($qb->expr()->lte('t.end', ':to'))
->setParameter('to', $end, Type::DATETIME);
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$qb
->addGroupBy('year')
->addGroupBy('month')
->addGroupBy('day')
->addOrderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->addOrderBy('day', 'ASC')
;
return $qb->getQuery()->execute();
}
/**
* @param User $user
* @param DateTime $begin
* @param DateTime $end
* @return Day[]
* @throws \Exception
*/
public function getDailyStats(User $user, DateTime $begin, DateTime $end): array
{
$results = $this->getDailyData($begin, $end, $user);
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = clone $end;
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') > $until) {
$tmp->modify('-1 day');
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
}
foreach ($results as $statRow) {
$dateTime = new DateTime();
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$days[$dateTime->format('Ymd')] = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
}
ksort($days);
return array_values($days);
}
/**
* @param User $user
* @return Timesheet[]|null

View File

@@ -10,8 +10,14 @@
namespace App\Repository;
use App\Entity\User;
use App\Model\Widget;
use App\Security\CurrentUser;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
/**
* @internal
*/
class WidgetRepository
{
/**
@@ -22,61 +28,398 @@ class WidgetRepository
* @var array
*/
protected $widgets = [];
/**
* @var array
*/
protected $definitions = [];
/**
* @var User|null
*/
protected $user;
/**
* @param TimesheetRepository $repository
* @param CurrentUser $user
* @param array $widgets
*/
public function __construct(TimesheetRepository $repository, array $widgets)
public function __construct(TimesheetRepository $repository, CurrentUser $user, array $widgets)
{
$this->repository = $repository;
$this->widgets = $widgets;
$this->user = $user->getUser();
$this->definitions = array_merge($this->getDefaultWidgets(), $widgets);
}
/**
* @param string $name
* @return bool
*/
public function has(string $name)
public function has(string $id): bool
{
return isset($this->widgets[$name]);
return isset($this->definitions[$id]) || isset($this->widgets[$id]);
}
/**
* @param string $name
* @param User|null $user
* @return Widget
* @throws \InvalidArgumentException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function get(string $name, ?User $user)
public function registerWidget(WidgetInterface $widget): WidgetRepository
{
if (!$this->has($name)) {
throw new \InvalidArgumentException('Cannot find widget: ' . $name);
if (!empty($widget->getId())) {
$this->widgets[$widget->getId()] = $widget;
}
$widget = $this->widgets[$name];
return $this;
}
public function get(string $id): WidgetInterface
{
if (!$this->has($id)) {
throw new \InvalidArgumentException(sprintf('Cannot find widget "%s".', $id));
}
if (isset($this->widgets[$id])) {
return $this->widgets[$id];
}
// this code should ONLY be reached for internal (pre-registered) widgets
$this->registerWidget($this->create($id, $this->definitions[$id]));
return $this->widgets[$id];
}
protected function create(string $name, array $widget): WidgetInterface
{
$user = $this->user;
$begin = !empty($widget['begin']) ? new \DateTime($widget['begin']) : null;
$end = !empty($widget['end']) ? new \DateTime($widget['end']) : null;
$theUser = $widget['user'] ? $user : null;
$type = $widget['type'] ?? Widget::TYPE_COUNTER;
if (!isset($widget['type'])) {
@trigger_error('Using a widget definition without a "type" is deprecated', E_USER_DEPRECATED);
$widget['type'] = 'counter';
}
$widgetClassName = '\\App\\Widget\\Type\\' . ucfirst($widget['type']);
if (!class_exists($widgetClassName)) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetClassName));
}
$model = new \ReflectionClass($widgetClassName);
if (!$model->isSubclassOf(AbstractWidgetType::class)) {
throw new WidgetException(sprintf('Invalid widget type "%s" does not extend AbstractWidgetType', $widgetClassName));
}
$data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser);
$model = new Widget($widget['title'], $data);
/** @var AbstractWidgetType $model */
$model = new $widgetClassName();
$model
->setColor($widget['color'])
->setIcon($widget['icon'])
->setType($type)
;
->setId($name)
->setTitle($widget['title'])
->setData($data);
if ($widget['query'] == TimesheetRepository::STATS_QUERY_DURATION) {
$model->setDataType(Widget::DATA_TYPE_DURATION);
$model->setOption('dataType', 'duration');
} elseif ($widget['query'] == TimesheetRepository::STATS_QUERY_RATE) {
$model->setDataType(Widget::DATA_TYPE_MONEY);
$model->setOption('dataType', 'money');
} else {
$model->setOption('dataType', 'int');
}
if (isset($widget['color'])) {
$model->setOption('color', $widget['color']);
}
if (isset($widget['icon'])) {
$model->setOption('icon', $widget['icon']);
}
return $model;
}
protected function getDefaultWidgets(): array
{
return
[
'userDurationToday' => [
'title' => 'stats.durationToday',
'query' => 'duration',
'user' => true,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'userDurationTotal' => [
'title' => 'stats.durationTotal',
'query' => 'duration',
'user' => true,
'icon' => 'duration',
'color' => 'red',
'type' => 'counter'
],
'userAmountToday' => [
'title' => 'stats.amountToday',
'query' => 'rate',
'user' => true,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'money',
'color' => 'green',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'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',
'type' => 'counter'
],
'userAmountTotal' => [
'title' => 'stats.amountTotal',
'query' => 'rate',
'user' => true,
'icon' => 'money',
'color' => 'red',
'type' => 'counter'
],
'durationToday' => [
'title' => 'stats.durationToday',
'query' => 'duration',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'durationWeek' => [
'title' => 'stats.durationWeek',
'query' => 'duration',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'duration',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'durationTotal' => [
'title' => 'stats.durationTotal',
'query' => 'duration',
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'amountToday' => [
'title' => 'stats.amountToday',
'query' => 'rate',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'money',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'amountWeek' => [
'title' => 'stats.amountWeek',
'query' => 'rate',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'money',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'amountTotal' => [
'title' => 'stats.amountTotal',
'query' => 'rate',
'icon' => 'money',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'activeUsersToday' => [
'title' => 'stats.userActiveToday',
'query' => 'users',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'user',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'activeUsersWeek' => [
'title' => 'stats.userActiveWeek',
'query' => 'users',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'user',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'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',
'user' => false,
'type' => 'counter'
],
'activeUsersTotal' => [
'title' => 'stats.userActiveTotal',
'query' => 'users',
'icon' => 'user',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'activeRecordings' => [
'title' => 'stats.activeRecordings',
'query' => 'active',
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'userRecapThisYear' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => '',
'icon' => '',
'type' => 'yearChart'
],
'userRecapLastYear' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december last year 23:59:59',
'color' => 'rgba(0,115,183,0.7)|#3b8bba',
'icon' => '',
'type' => 'yearChart'
],
'userRecapTwoYears' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => 'rgba(0,115,183,0.6)|#3b8bba;rgba(233,233,233,0.8)|#ccc',
'icon' => '',
'type' => 'yearChart'
],
'userRecapThreeYears' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '2 years ago first day of january 00:00:00',
'end' => 'this year last day of december 23:59:59',
'color' => 'rgba(0,115,183,0.4)|#3b8bba;rgba(233,233,233,0.7)|#ccc;rgba(210,214,222,0.9)|#c1c7d1',
'icon' => '',
'type' => 'yearChart'
],
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use InvalidArgumentException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class WidgetExtension extends AbstractExtension
{
/**
* @var WidgetService
*/
protected $service;
public function __construct(WidgetService $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('render_widget', [$this, 'renderWidget'], ['is_safe' => ['html']]),
];
}
/**
* @param WidgetInterface|string $widget
* @param array $options
* @return string
* @throws WidgetException
*/
public function renderWidget($widget, array $options = [])
{
if (!($widget instanceof WidgetInterface) && !is_string($widget)) {
throw new InvalidArgumentException('Widget must either implement WidgetInterface or be a string');
}
if (is_string($widget)) {
if (!$this->service->hasWidget($widget)) {
throw new InvalidArgumentException(sprintf('Unknown widget "%s" requested', $widget));
}
$widget = $this->service->getWidget($widget);
}
$renderer = $this->service->findRenderer($widget);
return $renderer->render($widget, $options);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\WidgetRendererInterface;
use Twig\Environment;
abstract class AbstractTwigRenderer implements WidgetRendererInterface
{
/**
* @var Environment
*/
protected $twig;
/**
* @param Environment $twig
*/
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
/**
* @param string $template
* @param array $data
* @return string
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
protected function renderTemplate(string $template, array $data): string
{
return $this->twig->render($template, $data);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\Type\CompoundChart;
use App\Widget\WidgetInterface;
class CompoundChartRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return ($widget instanceof CompoundChart);
}
public function render(WidgetInterface $widget, array $options = []): string
{
return $this->renderTemplate('widget/section-chart.html.twig', [
'title' => $widget->getTitle(),
'widgets' => $widget->getData(),
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\Type\CompoundRow;
use App\Widget\WidgetInterface;
class CompoundRowRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return ($widget instanceof CompoundRow);
}
public function render(WidgetInterface $widget, array $options = []): string
{
return $this->renderTemplate('widget/section-simple.html.twig', [
'title' => $widget->getTitle(),
'widgets' => $widget->getData(),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?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\Widget\Renderer;
use App\Widget\Type\SimpleWidget;
use App\Widget\WidgetInterface;
use ReflectionClass;
class SimpleWidgetRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return $widget instanceof SimpleWidget;
}
public function render(WidgetInterface $widget, array $options = []): string
{
$name = (new ReflectionClass($widget))->getShortName();
return $this->renderTemplate(sprintf('widget/widget-%s.html.twig', strtolower($name)), [
'data' => $widget->getData($options),
'options' => $widget->getOptions($options),
'title' => $widget->getTitle(),
]);
}
}

View File

@@ -0,0 +1,95 @@
<?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\Widget\Type;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetInterface;
use BadMethodCallException;
abstract class AbstractContainer implements WidgetContainerInterface
{
/**
* @var string
*/
protected $title = '';
/**
* @var WidgetInterface[]
*/
protected $widgets = [];
/**
* @var int
*/
protected $order = 0;
public function setTitle(string $title)
{
$this->title = $title;
}
/**
* @param array $options
* @return WidgetInterface[]|array|mixed|null
*/
public function getData(array $options = [])
{
return $this->getWidgets();
}
public function getOptions(array $options = []): array
{
return $options;
}
/**
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void
{
throw new BadMethodCallException('setOption() is not supported on AbstractContainer');
}
public function getId(): string
{
return $this->title;
}
public function getOrder(): int
{
return $this->order;
}
public function setOrder(int $order)
{
$this->order = $order;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
/**
* @return WidgetInterface[]
*/
public function getWidgets(): array
{
return $this->widgets;
}
public function addWidget(WidgetInterface $widget)
{
$this->widgets[] = $widget;
return $this;
}
}

View File

@@ -0,0 +1,109 @@
<?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\Widget\Type;
use App\Widget\WidgetInterface;
abstract class AbstractWidgetType implements WidgetInterface
{
/**
* @var string
*/
protected $id = '';
/**
* @var string
*/
protected $title = '';
/**
* @var array
*/
protected $options = [];
/**
* @var mixed
*/
protected $data;
public function setId(string $id): AbstractWidgetType
{
$this->id = $id;
return $this;
}
public function getId(): string
{
return $this->id;
}
public function setData($data): AbstractWidgetType
{
$this->data = $data;
return $this;
}
/**
* @param array $options
* @return mixed|null
*/
public function getData(array $options = [])
{
return $this->data;
}
public function setTitle(string $title): AbstractWidgetType
{
$this->title = $title;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setOptions(array $options): AbstractWidgetType
{
foreach ($options as $key => $value) {
$this->options[$key] = $value;
}
return $this;
}
/**
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void
{
$this->options[$name] = $value;
}
/**
* @param string $name
* @param mixed|null $default
* @return mixed|null
*/
public function getOption(string $name, $default = null)
{
if (array_key_exists($name, $this->options)) {
return $this->options[$name];
}
return $default;
}
public function getOptions(array $options = []): array
{
return array_merge($this->options, $options);
}
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class CompoundChart extends AbstractContainer
{
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class CompoundRow extends AbstractContainer
{
}

View File

@@ -0,0 +1,18 @@
<?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\Widget\Type;
class Counter extends SimpleWidget
{
public function __construct()
{
$this->setOption('dataType', 'int');
}
}

View File

@@ -0,0 +1,65 @@
<?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\Widget\Type;
use App\Repository\TimesheetRepository;
use App\Security\CurrentUser;
use DateTime;
class DailyWorkingTimeChart extends SimpleWidget
{
public const DEFAULT_CHART = 'bar';
/**
* @var TimesheetRepository
*/
protected $repository;
public function __construct(TimesheetRepository $repository, CurrentUser $user)
{
$this->repository = $repository;
$this->setId('DailyWorkingTimeChart');
$this->setTitle('stats.yourWorkingHours');
$this->setOptions([
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'color' => '',
'user' => $user->getUser(),
'type' => self::DEFAULT_CHART,
'id' => '',
]);
}
public function getOptions(array $options = []): array
{
$options = parent::getOptions($options);
if (!in_array($options['type'], ['bar', 'line'])) {
$options['type'] = self::DEFAULT_CHART;
}
if (empty($options['id'])) {
$options['id'] = uniqid('DailyWorkingTimeChart_');
}
return $options;
}
public function getData(array $options = [])
{
$options = $this->getOptions($options);
$user = $options['user'];
$begin = new DateTime($options['begin']);
$end = new DateTime($options['end']);
return $this->repository->getDailyStats($user, $begin, $end);
}
}

18
src/Widget/Type/More.php Normal file
View File

@@ -0,0 +1,18 @@
<?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\Widget\Type;
class More extends SimpleWidget
{
public function __construct()
{
$this->setOption('dataType', 'int');
}
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class SimpleWidget extends AbstractWidgetType
{
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class YearChart extends SimpleWidget
{
}

View File

@@ -0,0 +1,24 @@
<?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\Widget;
interface WidgetContainerInterface extends WidgetInterface
{
public function getOrder(): int;
public function setOrder(int $order);
/**
* @return WidgetInterface[]
*/
public function getWidgets(): array;
public function addWidget(WidgetInterface $widget);
}

View File

@@ -0,0 +1,14 @@
<?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\Widget;
class WidgetException extends \Exception
{
}

View File

@@ -0,0 +1,65 @@
<?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\Widget;
interface WidgetInterface
{
/**
* Returns a unique ID for this widget.
*
* @return string
*/
public function getId(): string;
/**
* Returns the widget title.
*
* If no title is necessary, return an empty string.
*
* @return string
*/
public function getTitle(): string;
/**
* Returns the widgets data, to be used in the frontend rendering.
*
* If your widget relies on options to dynamically change the result data,
* make sure that the given $options will overwrite the internal option for
* this one call.
*
* @param array $options
* @return mixed|null
*/
public function getData(array $options = []);
/**
* Returns all widget options to be used in the frontend.
*
* The given $options are not meant to be persisted, but only to
* overwrite the default values one time.
*
* You can validate the options or simply return:
* return array_merge($this->options, $options);
*
* @param array $options
* @return array
*/
public function getOptions(array $options = []): array;
/**
* Sets one widget option, both for internal use and for frontend rendering.
*
* The given option should be persisted and permanently overwrite the internal option.
*
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void;
}

View File

@@ -0,0 +1,35 @@
<?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\Widget;
interface WidgetRendererInterface
{
/**
* Checks if the given widget can be rendered.
*
* Most renderer check something like:
* return $widget instanceof MyWidgetType;
*
* @param WidgetInterface $widget
* @return bool
*/
public function supports(WidgetInterface $widget): bool;
/**
* Renders the given widget.
*
* The given $options array overwrites the widgets internal options for this call.
*
* @param WidgetInterface $widget
* @param array $options
* @return string
*/
public function render(WidgetInterface $widget, array $options = []): string;
}

View File

@@ -0,0 +1,81 @@
<?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\Widget;
use App\Repository\WidgetRepository;
class WidgetService
{
/**
* @var WidgetRendererInterface[]
*/
protected $renderer = [];
/**
* @var WidgetRepository
*/
protected $repository;
/**
* @param WidgetRepository $repository
* @param WidgetRendererInterface[] $renderer
*/
public function __construct(WidgetRepository $repository, iterable $renderer)
{
foreach ($renderer as $render) {
$this->addRenderer($render);
}
$this->repository = $repository;
}
/**
* @param string $widget
* @return bool
*/
public function hasWidget(string $widget): bool
{
return $this->repository->has($widget);
}
public function getWidget(string $widget): WidgetInterface
{
return $this->repository->get($widget);
}
public function addRenderer(WidgetRendererInterface $renderer): WidgetService
{
$this->renderer[] = $renderer;
return $this;
}
/**
* @param WidgetInterface $widget
* @return WidgetRendererInterface
* @throws WidgetException
*/
public function findRenderer(WidgetInterface $widget): WidgetRendererInterface
{
foreach ($this->renderer as $renderer) {
if ($renderer->supports($widget)) {
return $renderer;
}
}
throw new WidgetException(sprintf('No renderer available for widget "%s"', get_class($widget)));
}
/**
* @return WidgetRendererInterface[]
*/
public function getRenderer(): array
{
return $this->renderer;
}
}

View File

@@ -35,13 +35,8 @@
{% set currency = activity.project.customer.currency %}
{% endif %}
{% if activity.budget > 0 %}
{{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }}
{% endif %}
{% if activity.timeBudget > 0 %}
{{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endif %}
{{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }}
{{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -24,13 +24,8 @@
{{ 'admin_customer.short_stats'|trans(params)|raw }}
</p>
{% if customer.budget > 0 %}
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }}
{% endif %}
{% if customer.timeBudget > 0 %}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endif %}
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -18,12 +18,8 @@
{% block main %}
{% for row in widget_rows %}
{% 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 %}
{% for row in widgets %}
{{ render_widget(row) }}
{% endfor %}
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
{% macro progressbar(max, current, title, subTitle) %}
{% set percentReached = (current / (max / 100)) %}
{% set percentReached = 0 %}
{% if max > 0 %}
{% set percentReached = (current / (max / 100)) %}
{% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %}
@@ -18,7 +21,7 @@
{% endif %}
<div class="progress-group">
<span class="progress-text">{{ title }} &ndash; {{ percentReached|number_format(2) }}%</span>
<span class="progress-text">{{ title }}{% if percentReached > 0 %} &ndash; {{ percentReached|number_format(2) }}%{% endif %}</span>
<span class="progress-number">{{ subTitle }}</span>
<div class="progress">

View File

@@ -254,29 +254,17 @@
{% endif %}
<a class="{{ class | trim }}" href="{{ url }}"
{%- if disabled is same as (true) -%}
disabled="disabled"
{%- endif -%}
{%- if id is not empty -%}
id="{{ id }}"
{%- endif -%}
{%- if toggle is not empty -%}
data-toggle="{{ toggle }}"
{%- endif -%}
{%- if modal is not empty -%}
data-toggle="modal" data-target="{{ modal }}"
{%- endif -%}
{%- if onclick is not empty -%}
onclick="{{ onclick }}"
{%- endif -%}
{%- if target is not empty -%}
target="{{ target }}"
{%- endif -%}
{%- if attr is not empty -%}
{%- for name, value in attr %}
{{- ' ' ~ name }}={% if '"' in value %}'{{ value|raw }}'{% else %}"{{ value|raw }}"{% endif %}
{%- endfor -%}
{%- endif -%}
{%- if disabled is same as (true) %} disabled="disabled"{% endif %}
{%- if id is not empty %} id="{{ id }}"{% endif %}
{%- if toggle is not empty %} data-toggle="{{ toggle }}"{% endif %}
{%- if modal is not empty %} data-toggle="modal" data-target="{{ modal }}"{% endif %}
{%- if onclick is not empty %} onclick="{{ onclick }}"{% endif %}
{%- if target is not empty %} target="{{ target }}"{% endif %}
{%- if attr is not empty %}
{% for name, value in attr %}
{{ ' ' ~ name }}={% if '"' in value %}'{{ value|raw }}'{% else %}"{{ value|raw }}"{% endif %}
{% endfor %}
{% endif %}
>{% if title is not null %}{{ title }}{% else %}{{ macro.icon(icon) }}{% endif %}</a>
{% endapply %}
{% endmacro %}

View File

@@ -23,13 +23,8 @@
{{ 'admin_project.short_stats'|trans(params)|raw }}
</p>
{% if project.budget > 0 %}
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }}
{% endif %}
{% if project.timeBudget > 0 %}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endif %}
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -33,12 +33,22 @@
}
}],
yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: {
display: true,
color: 'rgba(0,0,0,.05)',
color: '{{ kimai_context.chart.grid_color }}',
lineWidth: 1
}
}]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
}
};
}
@@ -51,7 +61,7 @@
{% block box_body %}
<div class="chart">
<canvas id="userProfileChart{{ year }}" style="height: 200px;"></canvas>
<canvas id="userProfileChart{{ year }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
@@ -60,11 +70,17 @@
datasets: [
{
label: '{{ year }}',
backgroundColor : '#00a65a',
borderColor : '#00a65a',
backgroundColor: '{{ kimai_context.chart.background_color }}',
borderColor: '{{ kimai_context.chart.border_color }}',
data: [
{% for month in yearStat.months %}
{{ (month.totalDuration / 3600)|round }}
{{ (month.totalDuration / 3600)|number_format(2, '.', '') }}
{% if not loop.last %},{% endif %}
{% endfor %}
],
realData : [
{% for month in yearStat.months %}
'{{ month.totalDuration|duration }}'
{% if not loop.last %},{% endif %}
{% endfor %}
]

View File

@@ -1,11 +1,9 @@
{% 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>
<h3 class="box-title">{{ 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>
@@ -27,25 +25,7 @@
<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 %}
moment.months({{ loop.index }}) {{ yearName }}
{% endif %}
{% endfor %}
{% endif %}
{% if loop.first %} - {% endif %}
{% endfor %}
</strong>
</p>
#}
<div class="chart">
<canvas id="timeChart" style="height: 180px;"></canvas>
</div>
{{ render_widget(widgets.0) }}
</div>
{#
<div class="col-md-4">
@@ -85,17 +65,17 @@
#}
</div>
</div>
{% if section.widgets|length > 1 %}
{% if widgets|length > 1 %}
<div class="box-footer">
<div class="row">
{% set width = (section.widgets|length) - 1 %}
{% set width = (widgets|length) - 1 %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
{% for widget in section.widgets|slice(1, width) %}
{% for widget in widgets|slice(1, width) %}
{% set data = widget.data %}
{% if widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_DURATION') %}
{% if widget.option('dataType') == 'duration' %}
{% set data = widget.data|duration %}
{% elseif widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_MONEY') %}
{% elseif widget.option('dataType') == 'money' %}
{% set data = widget.data|money %}
{% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
@@ -113,63 +93,5 @@
</div>
{% endif %}
</div>
<script>
$(function () {
'use strict';
var timeChartData = {
labels: moment.months(),
datasets: [
{% for yearName, year in chartWidget.data %}
{% set yearColors = colors[loop.index-1]|split('|') %}
{
label : '{{ yearName }}',
backgroundColor : '{{ yearColors.1 }}',
borderColor : '{{ yearColors.0 }}',
pointRadius : 2,
borderWidth : 1,
pointHitRadius : 10,
lineTension : 0.3,
data : [
{% for month in year.months %}
{{ (month.totalDuration / 3600)|round }}
{% if not loop.last %},{% endif %}
{% endfor %}
]
}
{% if not loop.last %},{% endif %}
{% endfor %}
]
};
var timeChartOptions = {
maintainAspectRatio : true,
responsive : true,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: 'rgba(0,0,0,.05)',
lineWidth: 1
}
}]
}
};
var timeChartCanvas = $('#timeChart').get(0).getContext('2d');
var timeChart = new Chart(
timeChartCanvas, {
type: 'line',
data: timeChartData,
options: timeChartOptions
}
);
});
</script>
</div>
</div>

View File

@@ -0,0 +1,20 @@
{% import "macros/widgets.html.twig" as widgets %}
{% if not title is empty %}
{{ widgets.page_header(title) }}
{% endif %}
{% set width = widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widget in 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 }}">
{{ render_widget(widget) }}
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,28 @@
{% set url = null %}
{% if options.route is defined %}
{% set url = path(options.route, options.routeOptions|default([])) %}
{% endif %}
<div class="info-box">
<span class="info-box-icon bg-{{ options.color|default('green') }}"><i class="{{ options.icon|icon(options.icon) }}"></i></span>
<div class="info-box-content">
{% if not url is empty %}<a href="{{ url }}" class="small-box-footer">{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">
{% if data is iterable %}
{{ 'Invalid data' }}
{% else %}
{% block widget_data %}
{% if options.dataType == 'duration' %}
{% set data = data|duration %}
{% elseif options.dataType == 'money' %}
{% set data = data|money %}
{% endif %}
{{ data }}
{% endblock %}
{% endif %}
</span>
{% if not url is empty %}</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,95 @@
{% set type = options.type|default('bar') %}
{% set chart_id = options.id %}
{% set backgroundColor = kimai_context.chart.background_color %}
{% set borderColor = kimai_context.chart.border_color %}
{% set gridColor = kimai_context.chart.grid_color %}
{% set colors = options.color|default('')|split(';') %}
{% if colors.0 is defined and not colors.0 is empty %}
{% set backgroundColor = colors.0 %}
{% set borderColor = colors.0 %}
{% if colors.1 is defined and not colors.1 is empty %}
{% set borderColor = colors.1 %}
{% endif %}
{% endif %}
{{ encore_entry_link_tags('chart') }}
{{ encore_entry_script_tags('chart') }}
{% if not title is empty %}
<p class="text-center">
<strong>
{{ title|trans }}
</strong>
</p>
{% endif %}
<div class="chart">
<canvas id="{{ chart_id }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
new Chart(
document.getElementById('{{ chart_id }}').getContext('2d'), {
type: '{{ type }}',
data: {
labels: [
{% for day in data -%}
moment('{{ day.day|date_format(constant('\DateTime::ISO8601')) }}').format('ll')
{% if not loop.last %},{% endif -%}
{%- endfor %}
],
datasets: [
{
backgroundColor: '{{ backgroundColor }}',
borderColor: '{{ borderColor }}',
data: [
{% for day in data -%}
{{ (day.totalDuration / 3600)|number_format(2, '.', '') }}
{%- if not loop.last %},{% endif -%}
{%- endfor %}
],
realData: [
{% for day in data -%}
'{{ day.totalDuration|duration }}'
{%- if not loop.last %},{% endif -%}
{%- endfor %}
]
}
]
},
options: {
maintainAspectRatio: true,
responsive: true,
legend: false,
barPercentage: 0.5,
categoryPercentage: 0.9,
scales: {
xAxes: [{
gridLines: {
display: false
},
}],
yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: {
display: true,
color: '{{ gridColor }}',
lineWidth: 1
}
}]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
}
}
}
);
});
</script>

View File

@@ -0,0 +1,33 @@
{% set url = null %}
{% if options.route is defined %}
{% set url = path(options.route, options.routeOptions|default([])) %}
{% endif %}
<div class="small-box bg-{{ options.color|default('green') }}">
<div class="inner">
<h3>
{% if data is iterable %}
{{ 'Invalid data' }}
{% else %}
{% block widget_data %}
{% if options.dataType == 'duration' %}
{% set data = data|duration %}
{% elseif options.dataType == 'money' %}
{% set data = data|money %}
{% endif %}
{{ data }}
{% endblock %}
{% endif %}
<sup style="font-size: 20px">{{ unit|default('') }}</sup>
</h3>
<p>{{ title|trans }}</p>
</div>
<div class="icon">
<i class="{{ options.icon|icon(options.icon) }}"></i>
</div>
{% if not url is empty %}
<a href="{{ url }}" class="small-box-footer">
{{ 'more.info.link'|trans }}
</a>
{% endif %}
</div>

View File

@@ -0,0 +1,98 @@
{% set backgroundColor = kimai_context.chart.background_color %}
{% set borderColor = kimai_context.chart.border_color %}
{% set gridColor = kimai_context.chart.grid_color %}
{% set colors = options.color|default('')|split(';') %}
{% set type = options.type|default('line') %}
{% if type not in ['line', 'bar'] %}
{% set type = 'line' %}
{% endif %}
{% if not title is empty %}
<p class="text-center">
<strong>
{{ title|trans }}
</strong>
</p>
{% endif %}
<div class="chart">
<canvas id="timeChart" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
new Chart(
document.getElementById('timeChart').getContext('2d'), {
type: '{{ type }}',
data: {
labels: moment.months(),
datasets: [
{% for yearName, year in data %}
{% set yearBackgroundColor = backgroundColor %}
{% set yearBorderColor = borderColor %}
{% if colors[loop.index-1] is defined %}
{% set yearColors = colors[loop.index-1]|split('|') %}
{% if yearColors.0 is defined and not yearColors.0 is empty %}
{% set yearBackgroundColor = yearColors.0 %}
{% set yearBorderColor = yearColors.0 %}
{% if yearColors.1 is defined and not yearColors.1 is empty %}
{% set yearBorderColor = yearColors.1 %}
{% endif %}
{% endif %}
{% endif %}
{
label : '{{ yearName }}',
backgroundColor : '{{ yearBackgroundColor }}',
borderColor : '{{ yearBorderColor }}',
pointRadius : 2,
borderWidth : 1,
pointHitRadius : 10,
lineTension : 0.3,
data : [
{% for month in year.months %}
{{ (month.totalDuration / 3600)|number_format(2, '.', '') }}
{% if not loop.last %},{% endif %}
{% endfor %}
],
realData : [
{% for month in year.months %}
'{{ month.totalDuration|duration }}'
{% if not loop.last %},{% endif %}
{% endfor %}
]
}
{% if not loop.last %},{% endif %}
{% endfor %}
]
},
options: {
maintainAspectRatio: true,
responsive: true,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: '{{ gridColor }}',
lineWidth: 1
}
}]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
}
}
}
);
});
</script>

View File

@@ -97,6 +97,12 @@ class AppExtensionTest extends TestCase
'box_color' => 'green',
'select_type' => null,
'show_about' => true,
'chart' => [
'background_color' => 'rgba(0,115,183,0.7)',
'border_color' => '#3b8bba',
'grid_color' => 'rgba(0,0,0,.05)',
'height' => '200'
]
],
'kimai.theme.select_type' => null,
'kimai.theme.show_about' => true,

View File

@@ -11,7 +11,7 @@ namespace App\Tests\Event;
use App\Entity\User;
use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Widget\Type\CompoundRow;
use PHPUnit\Framework\TestCase;
/**
@@ -29,7 +29,7 @@ class DashboardEventTest extends TestCase
$this->assertEquals($user, $sut->getUser());
$this->assertEquals([], $sut->getSections());
$section = new DashboardSection('foo');
$section = new CompoundRow();
$sut->addSection($section);
$this->assertEquals([$section], $sut->getSections());

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\CsvRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class CsvRendererTest extends AbstractRendererTest
{

View File

@@ -16,6 +16,7 @@ use Twig\Environment;
/**
* @covers \App\Export\Renderer\HtmlRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class HtmlRendererTest extends AbstractRendererTest
{

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\OdsRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class OdsRendererTest extends AbstractRendererTest
{

View File

@@ -19,6 +19,7 @@ use Twig\Environment;
/**
* @covers \App\Export\Renderer\PDFRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class PdfRendererTest extends AbstractRendererTest
{

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\XlsxRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class XlsxRendererTest extends AbstractRendererTest
{

View File

@@ -1,42 +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\Tests\Model;
use App\Model\DashboardSection;
use App\Model\Widget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\DashboardSection
*/
class DashboardSectionTest extends TestCase
{
public function testDefaultValues()
{
$sut = new DashboardSection('test');
$this->assertEquals('test', $sut->getTitle());
$this->assertEquals(0, $sut->getOrder());
$this->assertEquals([], $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_SIMPLE, $sut->getType());
}
public function testSetter()
{
$sut = new DashboardSection('hello-world');
$sut->setType(DashboardSection::TYPE_CHART);
$sut->setOrder(13);
$sut->addWidget(new Widget('bar', []));
$this->assertCount(1, $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_CHART, $sut->getType());
$this->assertEquals(13, $sut->getOrder());
$this->assertEquals('bar', $sut->getWidgets()[0]->getTitle());
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Day;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Statistic\Day
*/
class DayTest extends TestCase
{
public function testConstruct()
{
$date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956);
$this->assertSame($date, $sut->getDay());
$this->assertEquals(12340, $sut->getTotalDuration());
$this->assertEquals(197.25956, $sut->getTotalRate());
}
public function testAllowedMonths()
{
$date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956);
$sut->setTotalDuration(999.27);
$sut->setTotalRate(0.123456789);
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
}
}

View File

@@ -10,6 +10,8 @@
namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Month;
use Exception;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
@@ -42,12 +44,12 @@ class MonthTest extends TestCase
$ex = null;
try {
new Month($month);
} catch (\Exception $e) {
} catch (Exception $e) {
$ex = $e;
}
$this->assertInstanceOf(\InvalidArgumentException::class, $ex);
$this->assertInstanceOf(InvalidArgumentException::class, $ex);
$this->assertEquals(
'Invalid month given, expected 01-12 but given: ' . ((int) $month),
'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".',
$ex->getMessage()
);
}
@@ -59,7 +61,7 @@ class MonthTest extends TestCase
$sut->setTotalDuration(999.27);
$sut->setTotalRate(0.123456789);
$this->assertEquals(999.27, $sut->getTotalDuration());
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
}
}

View File

@@ -9,9 +9,9 @@
namespace App\Tests\Repository;
use App\Model\Widget;
use App\Repository\TimesheetRepository;
use App\Repository\WidgetRepository;
use App\Security\CurrentUser;
use PHPUnit\Framework\TestCase;
/**
@@ -22,8 +22,9 @@ class WidgetRepositoryTest extends TestCase
public function testHasWidget()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]);
$sut = new WidgetRepository($repoMock, $userMock, ['test' => []]);
$this->assertFalse($sut->has('foo'));
$this->assertTrue($sut->has('test'));
@@ -31,14 +32,41 @@ class WidgetRepositoryTest extends TestCase
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Cannot find widget: foo
* @expectedExceptionMessage Cannot find widget "foo".
*/
public function testGetWidgetThrowsExceptionOnNonExistingWidget()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]);
$sut->get('foo', null);
$sut = new WidgetRepository($repoMock, $userMock, ['test' => []]);
$sut->get('foo');
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage Unknown widget type "\App\Widget\Type\FooBar"
*/
public function testGetWidgetThrowsExceptionOnInvalidType()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'FooBar', 'user' => false]]);
$sut->get('test');
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage Invalid widget type "\App\Widget\Type\CompoundChart" does not extend AbstractWidgetType
*/
public function testGetWidgetTriggersExceptionOnWrongClass()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'CompoundChart', 'user' => false]]);
$sut->get('test');
}
/**
@@ -49,6 +77,8 @@ class WidgetRepositoryTest extends TestCase
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$repoMock->method('getStatistic')->willReturn($data);
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$widget = [
'color' => 'sunny',
'icon' => 'far fa-test',
@@ -59,24 +89,25 @@ class WidgetRepositoryTest extends TestCase
'title' => 'Test widget',
];
$sut = new WidgetRepository($repoMock, ['test' => $widget]);
$widget = $sut->get('test', null);
$sut = new WidgetRepository($repoMock, $userMock, ['test' => $widget]);
$widget = $sut->get('test');
$options = $widget->getOptions();
$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());
$this->assertEquals('sunny', $options['color']);
$this->assertEquals('far fa-test', $options['icon']);
$this->assertEquals($dataType, $options['dataType']);
}
public function getWidgetData()
{
return [
[12, TimesheetRepository::STATS_QUERY_DURATION, Widget::DATA_TYPE_DURATION],
[112233, TimesheetRepository::STATS_QUERY_AMOUNT, Widget::DATA_TYPE_INT],
[37, TimesheetRepository::STATS_QUERY_ACTIVE, Widget::DATA_TYPE_INT],
[375, TimesheetRepository::STATS_QUERY_RATE, Widget::DATA_TYPE_MONEY],
[['test' => 'foo'], TimesheetRepository::STATS_QUERY_USER, Widget::DATA_TYPE_INT],
[12, TimesheetRepository::STATS_QUERY_DURATION, 'duration'],
[112233, TimesheetRepository::STATS_QUERY_AMOUNT, 'int'],
[37, TimesheetRepository::STATS_QUERY_ACTIVE, 'int'],
[375, TimesheetRepository::STATS_QUERY_RATE, 'money'],
[['test' => 'foo'], TimesheetRepository::STATS_QUERY_USER, 'int'],
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig;
use App\Twig\WidgetExtension;
use App\Widget\Type\Counter;
use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface;
use App\Widget\WidgetService;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
/**
* @covers \App\Twig\WidgetExtension
*/
class WidgetExtensionTest extends TestCase
{
protected function getSut($hasWidget = null, $getWidget = null, $renderer = null): WidgetExtension
{
$service = $this->getMockBuilder(WidgetService::class)->disableOriginalConstructor()->setMethods(['hasWidget', 'getWidget', 'findRenderer'])->getMock();
if (null !== $hasWidget) {
$service->expects($this->once())->method('hasWidget')->willReturn($hasWidget);
}
if (null !== $getWidget) {
$service->expects($this->once())->method('getWidget')->willReturn($getWidget);
}
if (null !== $renderer) {
$service->expects($this->once())->method('findRenderer')->willReturn($renderer);
}
return new WidgetExtension($service);
}
public function testGetFunctions()
{
$functions = ['render_widget'];
$sut = $this->getSut();
$twigFunctions = $sut->getFunctions();
$this->assertCount(count($functions), $twigFunctions);
$i = 0;
/** @var TwigFunction $function */
foreach ($twigFunctions as $function) {
$this->assertInstanceOf(TwigFunction::class, $function);
$this->assertEquals($functions[$i++], $function->getName());
}
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Widget must either implement WidgetInterface or be a string
*/
public function testRenderWidgetForInvalidValue()
{
$sut = $this->getSut();
$sut->renderWidget(true);
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Unknown widget "test" requested
*/
public function testRenderWidgetForUnknownWidget()
{
$sut = $this->getSut(false);
$sut->renderWidget('test');
}
public function testRenderWidgetByString()
{
$widget = new Counter();
$sut = $this->getSut(true, $widget, new TestRenderer());
$options = ['foo' => 'bar', 'dataType' => 'blub'];
$result = $sut->renderWidget('test', $options);
$data = json_decode($result, true);
$this->assertEquals($options, $data);
}
public function testRenderWidgetObject()
{
$widget = new Counter();
$sut = $this->getSut(null, null, new TestRenderer());
$options = ['foo' => 'bar', 'dataType' => 'blub'];
$result = $sut->renderWidget($widget, $options);
$data = json_decode($result, true);
$this->assertEquals($options, $data);
}
}
class TestRenderer implements WidgetRendererInterface
{
public function supports(WidgetInterface $widget): bool
{
return true;
}
public function render(WidgetInterface $widget, array $options = []): string
{
return json_encode($widget->getOptions($options));
}
}

View File

@@ -0,0 +1,54 @@
<?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\Widget\Renderer;
use App\Widget\Renderer\CompoundChartRenderer;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\Counter;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\CompoundChartRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class CompoundChartRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new CompoundChartRenderer($twig);
self::assertTrue($sut->supports(new CompoundChart()));
self::assertFalse($sut->supports(new CompoundRow()));
}
public function testRenderWithCounter()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new CompoundChartRenderer($twig);
$row = new CompoundChart();
$row->setTitle('foo-bar');
$row->addWidget(new Counter());
$result = $sut->render($row);
$result = json_decode($result, true);
self::assertEquals('widget/section-chart.html.twig', $result[0]);
self::assertArrayHasKey('title', $result[1]);
self::assertEquals('foo-bar', $result[1]['title']);
self::assertArrayHasKey('widgets', $result[1]);
self::assertIsArray($result[1]['widgets']);
self::assertCount(1, $result[1]['widgets']);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Widget\Renderer;
use App\Widget\Renderer\CompoundRowRenderer;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\Counter;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\CompoundRowRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class CompoundRowRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new CompoundRowRenderer($twig);
self::assertTrue($sut->supports(new CompoundRow()));
self::assertFalse($sut->supports(new CompoundChart()));
}
public function testRenderWithCounter()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new CompoundRowRenderer($twig);
$row = new CompoundRow();
$row->setTitle('foo-bar');
$row->addWidget(new Counter());
$result = $sut->render($row);
$result = json_decode($result, true);
self::assertEquals('widget/section-simple.html.twig', $result[0]);
self::assertArrayHasKey('title', $result[1]);
self::assertEquals('foo-bar', $result[1]['title']);
self::assertArrayHasKey('widgets', $result[1]);
self::assertIsArray($result[1]['widgets']);
self::assertCount(1, $result[1]['widgets']);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Renderer;
use App\Widget\Renderer\SimpleWidgetRenderer;
use App\Widget\Type\Counter;
use App\Widget\Type\More;
use App\Widget\Type\SimpleWidget;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\SimpleWidgetRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class SimpleWidgetRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new SimpleWidgetRenderer($twig);
self::assertTrue($sut->supports(new SimpleWidget()));
}
/**
* @dataProvider getSimpleWidgetsData
*/
public function testRenderWithCounter(SimpleWidget $widget, $template, $color)
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new SimpleWidgetRenderer($twig);
$data = uniqid(get_class($widget));
$widget->setData($data);
$result = $sut->render($widget, ['color' => $color]);
$result = json_decode($result, true);
self::assertEquals($template, $result[0]);
self::assertArrayHasKey('data', $result[1]);
self::assertEquals($data, $result[1]['data']);
self::assertArrayHasKey('title', $result[1]);
self::assertArrayHasKey('options', $result[1]);
self::assertIsArray($result[1]['options']);
self::assertArrayHasKey('color', $result[1]['options']);
self::assertEquals($color, $result[1]['options']['color']);
}
public function getSimpleWidgetsData()
{
return [
[new SimpleWidget(), 'widget/widget-simplewidget.html.twig', 'yellow'],
[new Counter(), 'widget/widget-counter.html.twig', 'asdfgh'],
[new More(), 'widget/widget-more.html.twig', '#123456'],
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\More;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\AbstractContainer
*/
abstract class AbstractContainerTest extends TestCase
{
abstract public function createSut(): AbstractContainer;
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractContainer::class, $sut);
self::assertEquals('', $sut->getId());
self::assertEquals('', $sut->getTitle());
self::assertEquals(0, $sut->getOrder());
self::assertEquals([], $sut->getOptions());
self::assertEquals([], $sut->getWidgets());
self::assertEquals([], $sut->getData());
}
public function testSetter()
{
$widget = (new More())->setTitle('bar')->setId('foo');
$sut = $this->createSut();
$sut->setTitle('hello-world');
$sut->setOrder(13);
$sut->addWidget($widget);
self::assertEquals('hello-world', $sut->getTitle());
self::assertEquals('hello-world', $sut->getId());
self::assertCount(1, $sut->getWidgets());
self::assertCount(1, $sut->getData());
self::assertEquals([$widget], $sut->getWidgets());
self::assertEquals([$widget], $sut->getData());
self::assertEquals(13, $sut->getOrder());
self::assertEquals('bar', $sut->getWidgets()[0]->getTitle());
}
/**
* @expectedException \BadMethodCallException
*/
public function testSetOptionNotImplemented()
{
$sut = $this->createSut();
$sut->setOption('dfsdf', []);
}
}

View File

@@ -0,0 +1,69 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\AbstractWidgetType
*/
abstract class AbstractWidgetTypeTest extends TestCase
{
abstract public function createSut(): AbstractWidgetType;
abstract public function getDefaultOptions(): array;
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut);
self::assertEquals('', $sut->getId());
self::assertEquals('', $sut->getTitle());
self::assertEquals($this->getDefaultOptions(), $sut->getOptions());
self::assertNull($sut->getData());
self::assertEquals('bar', $sut->getOption('foo', 'bar'));
}
public function testFluentInterface()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut->setOptions([]));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setId(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setTitle(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setData(''));
}
public function testSetter()
{
$sut = $this->createSut();
// options
$sut->setOption('föööö', 'trääääää');
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
self::assertEquals(array_merge($this->getDefaultOptions(), ['föööö' => 'trääääää']), $sut->getOptions());
$sut->setOptions(['blub' => 'blab', 'dataType' => 'money']);
self::assertEquals(['blub' => 'blab', 'dataType' => 'money', 'föööö' => 'trääääää'], $sut->getOptions());
// id
$sut->setId('cvbnmyx');
self::assertEquals('cvbnmyx', $sut->getId());
// data
$sut->setData('slkudfhalksjdhfkljsahdf');
self::assertEquals('slkudfhalksjdhfkljsahdf', $sut->getData());
$data = new \stdClass();
$sut->setData($data);
self::assertSame($data, $sut->getData());
}
}

View File

@@ -0,0 +1,25 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\CompoundChart;
/**
* @covers \App\Widget\Type\CompoundChart
* @covers \App\Widget\Type\AbstractContainer
*/
class CompoundChartTest extends AbstractContainerTest
{
public function createSut(): AbstractContainer
{
return new CompoundChart();
}
}

View File

@@ -0,0 +1,25 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\CompoundRow;
/**
* @covers \App\Widget\Type\CompoundRow
* @covers \App\Widget\Type\AbstractContainer
*/
class CompoundRowTest extends AbstractContainerTest
{
public function createSut(): AbstractContainer
{
return new CompoundRow();
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\Counter;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\Counter
*/
class CounterTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new Counter();
}
public function getDefaultOptions(): array
{
return ['dataType' => 'int'];
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
}

View File

@@ -0,0 +1,117 @@
<?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\Widget\Type;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use App\Security\CurrentUser;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\DailyWorkingTimeChart;
use App\Widget\Type\SimpleWidget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\DailyWorkingTimeChart
* @covers \App\Widget\Type\SimpleWidget
* @covers \App\Widget\Type\AbstractWidgetType
* @covers \App\Repository\TimesheetRepository
*/
class DailyWorkingTimeChartTest extends TestCase
{
public function createSut(): AbstractWidgetType
{
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock();
$user->expects($this->once())->method('getUser')->willReturn(new User());
return new DailyWorkingTimeChart($repository, $user);
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut);
self::assertEquals('DailyWorkingTimeChart', $sut->getId());
self::assertEquals('stats.yourWorkingHours', $sut->getTitle());
self::assertEquals('monday this week 00:00:00', $sut->getOption('begin', 'xxx'));
self::assertEquals('sunday this week 23:59:59', $sut->getOption('end', 'xxx'));
self::assertEquals('', $sut->getOption('color', 'xxx'));
self::assertInstanceOf(User::class, $sut->getOption('user', 'xxx'));
self::assertEquals('bar', $sut->getOption('type', 'xxx'));
}
public function testFluentInterface()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut->setOptions([]));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setId(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setTitle(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setData(''));
}
public function testSetter()
{
$sut = $this->createSut();
// options
$sut->setOption('föööö', 'trääääää');
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
// check default values
self::assertEquals('xxxxx', $sut->getOption('blub', 'xxxxx'));
self::assertEquals('xxxxx', $sut->getOption('dataType', 'xxxxx'));
$sut->setOptions(['blub' => 'blab', 'dataType' => 'money']);
// check option still exists
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
// check options are now existing
self::assertEquals('blab', $sut->getOption('blub', 'xxxxx'));
self::assertEquals('money', $sut->getOption('dataType', 'xxxxx'));
// id
$sut->setId('cvbnmyx');
self::assertEquals('cvbnmyx', $sut->getId());
}
public function testGetOptions()
{
$sut = $this->createSut();
$options = $sut->getOptions(['type' => 'xxx']);
self::assertStringStartsWith('DailyWorkingTimeChart_', $options['id']);
self::assertEquals('bar', $options['type']);
}
public function testGetData()
{
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->setMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($user, $begin, $end) {
return [
['year' => '2019', 'month' => '1', 'day' => 1, 'rate' => 13.75, 'duration' => 1234]
];
});
$user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock();
$user->expects($this->once())->method('getUser')->willReturn((new User())->setUsername('tralalala'));
$sut = new DailyWorkingTimeChart($repository, $user);
$data = $sut->getData([]);
self::assertCount(7, $data);
foreach ($data as $statObj) {
self::assertInstanceOf(Day::class, $statObj);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\More;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\More
*/
class MoreTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new More();
}
public function getDefaultOptions(): array
{
return ['dataType' => 'int'];
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\SimpleWidget
*/
class SimpleWidgetTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new SimpleWidget();
}
public function getDefaultOptions(): array
{
return [];
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\YearChart;
/**
* @covers \App\Widget\Type\YearChart
*/
class YearChartTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new YearChart();
}
public function getDefaultOptions(): array
{
return [];
}
}

View File

@@ -0,0 +1,25 @@
<?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\Widget;
use App\Widget\WidgetException;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\WidgetException
*/
class WidgetExceptionTest extends TestCase
{
public function testConstruct()
{
$ex = new WidgetException();
self::assertInstanceOf(\Exception::class, $ex);
}
}

View File

@@ -0,0 +1,77 @@
<?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\Widget;
use App\Repository\WidgetRepository;
use App\Widget\Renderer\SimpleWidgetRenderer;
use App\Widget\Type\More;
use App\Widget\WidgetService;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Widget\WidgetService
*/
class WidgetServiceTest extends TestCase
{
public function testConstruct()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetService($repository, []);
self::assertFalse($sut->hasWidget('sdfsdf'));
self::assertCount(0, $sut->getRenderer());
$sut = new WidgetService($repository, [
new SimpleWidgetRenderer(new Environment(new FilesystemLoader()))
]);
self::assertCount(1, $sut->getRenderer());
}
public function testFindRenderer()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$renderer = new SimpleWidgetRenderer(new Environment(new FilesystemLoader()));
$sut = new WidgetService($repository, [$renderer]);
$sut->addRenderer(new SimpleWidgetRenderer(new Environment(new FilesystemLoader())));
self::assertCount(2, $sut->getRenderer());
$found = $sut->findRenderer(new More());
self::assertSame($renderer, $found);
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage No renderer available for widget "App\Widget\Type\More"
*/
public function testFindRendererThrowsException()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetService($repository, []);
$sut->findRenderer(new More());
}
public function testHasAndGetWidget()
{
$widget = new More();
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->setMethods(['has', 'get'])->getMock();
$repository->expects($this->once())->method('has')->willReturn(true);
$repository->expects($this->once())->method('get')->willReturn($widget);
$sut = new WidgetService($repository, []);
self::assertTrue($sut->hasWidget('sdfsdf'));
self::assertSame($widget, $sut->getWidget('sdfsdf'));
}
}

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source>
<target>جميع المستخدمين</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>مدير</target>
</trans-unit>
<!--
Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source>
<target>Alle Benutzer</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrator</target>
</trans-unit>
<!--
Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source>
<target>All user</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrator</target>
</trans-unit>
<!--
Widgets

View File

@@ -305,10 +305,6 @@
<source>dashboard.all</source>
<target>Resumen de todos los usuarios</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrador</target>
</trans-unit>
<!--
Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source>
<target>Tous les usagers</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrateur</target>
</trans-unit>
<!--
Widgets

View File

@@ -277,10 +277,6 @@
<source>dashboard.all</source>
<target>Minden felhasználó</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Adminisztrátor</target>
</trans-unit>
<!--
Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source>
<target>Tutti gli utenti</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Amministratore</target>
</trans-unit>
<!--
Widgets

View File

@@ -297,10 +297,6 @@
<source>dashboard.all</source>
<target>全ユーザーの統計情報</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>管理情報</target>
</trans-unit>
<!--
Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source>
<target>Todos usuários</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrador</target>
</trans-unit>
<!--
Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source>
<target>Все пользователи</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Администратор</target>
</trans-unit>
<!--
Widgets

View File

@@ -290,10 +290,6 @@
<source>dashboard.all</source>
<target state="translated">Alla användare</target>
</trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target state="translated">Administratör</target>
</trans-unit>
<!--
Widgets