improved widget experience with sortable and inline options

This commit is contained in:
Kevin Papst
2025-03-12 17:42:07 +01:00
parent 8a51b9c47a
commit f74425fec6
38 changed files with 583 additions and 543 deletions

File diff suppressed because one or more lines are too long

View File

@@ -144,6 +144,10 @@
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
*/
/*!
* [KIMAI] KimaiSortable: allow sorting of HTML elements
*/
/*!
* [KIMAI] KimaiStorage: simple wrapper to handle localStorage access
*/
@@ -171,3 +175,10 @@
/*!
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
*/
/**!
* Sortable 1.15.6
* @author RubaXa <trash@rubaxa.org>
* @author owenm <owen23355@gmail.com>
* @license MIT
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
/*!
* GridStack 7.3.0
* https://gridstackjs.com/
*
* Copyright (c) 2021-2022 Alain Dumesny
* see root license https://github.com/gridstack/gridstack.js/tree/master/LICENSE
*/

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,8 @@
"entrypoints": {
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.7719f352.js"
"/build/runtime.1440a7f0.js",
"/build/app.1bb6240b.js"
],
"css": [
"/build/app.079f3158.css"
@@ -11,7 +11,7 @@
},
"app-rtl": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/app-rtl.7a875ca7.js"
],
"css": [
@@ -20,7 +20,7 @@
},
"export-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/export-pdf.395749ab.js"
],
"css": [
@@ -29,7 +29,7 @@
},
"invoice": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/invoice.773af9c4.js"
],
"css": [
@@ -38,7 +38,7 @@
},
"invoice-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/invoice-pdf.26d98626.js"
],
"css": [
@@ -47,32 +47,23 @@
},
"chart": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/chart.62631acc.js"
]
},
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/runtime.1440a7f0.js",
"/build/calendar.7415ab21.js"
],
"css": [
"/build/calendar.d757753e.css"
]
},
"dashboard": {
"js": [
"/build/runtime.6c399d29.js",
"/build/dashboard.632f98fb.js"
],
"css": [
"/build/dashboard.b7129fa1.css"
]
}
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.7719f352.js": "sha384-lj0mGChJMeCxDtjDy96KBKaW5E61RIqYc2+3RlVz+wcsIroUZxMApq0PsW8QcKFN",
"/build/runtime.1440a7f0.js": "sha384-aX7qq4dPkl9vztZOIlBKii2dw2xiScUgZRgReqtcRQiC9wU+Mp2msgHYC8qaHlwU",
"/build/app.1bb6240b.js": "sha384-4vRnLowDOAgphyVkUYMBjAzipSFZYiB7EA+DZqyJclCUXeDRAJsfJQxfowwAOtzC",
"/build/app.079f3158.css": "sha384-t+fIci9AGsrOKYFgljugvKDSz0SHtY8THfqdVTeTLi3CFeN6Ozw9sCbT+hzFphSI",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.f2a83683.css": "sha384-ac4ikgEEEgKbWhUyO22ns7W5a68gpbNXt0CmCvSPdAlOWBygWmoeCNWnYW3iajWm",
@@ -84,8 +75,6 @@
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.62631acc.js": "sha384-L4evSO0OZiQt+jTqfMR70M2Vid7Hl5YdPocC6syhSoBRavlf7Az9/Ldh4YJUuwAU",
"/build/calendar.7415ab21.js": "sha384-+iYr7VnMIxSQMERlaruX2Ktp6gWmGSyCxfUAaeSDkoninXdUdiKBjlcZZwhHhZWU",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS"
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ"
}
}

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.079f3158.css",
"build/app.js": "/build/app.7719f352.js",
"build/app.js": "/build/app.1bb6240b.js",
"build/app-rtl.css": "/build/app-rtl.f2a83683.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
@@ -12,9 +12,7 @@
"build/chart.js": "/build/chart.62631acc.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.7415ab21.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.632f98fb.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/runtime.js": "/build/runtime.1440a7f0.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.ad1782c7.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.26b80c88.ttf",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.83a538a0.woff2",

View File

@@ -0,0 +1 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e](i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -1 +0,0 @@
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();

View File

@@ -17,12 +17,13 @@ use App\Utils\PageSetup;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Json;
/**
* Dashboard controller for the admin area.
@@ -33,6 +34,7 @@ final class DashboardController extends AbstractController
{
public const BOOKMARK_TYPE = 'dashboard';
public const BOOKMARK_NAME = 'default';
/**
* @var WidgetInterface[]|null
*/
@@ -47,9 +49,7 @@ final class DashboardController extends AbstractController
}
/**
* @param User $user
* @return array<WidgetInterface>
* @throws \Exception
*/
private function getAllAvailableWidgets(User $user): array
{
@@ -107,14 +107,16 @@ final class DashboardController extends AbstractController
/**
* Returns the list of widgets names and options for a user.
*
* @param User $user
* @return array<int, array<string, mixed>>
*/
private function getUserConfig(User $user): array
{
$bookmark = $this->getBookmark($user);
if ($bookmark !== null) {
return $bookmark->getContent();
$config = $bookmark->getContent();
if (\count($config) > 0) {
return $config;
}
}
$widgets = [];
@@ -128,7 +130,6 @@ final class DashboardController extends AbstractController
/**
* @param array<WidgetInterface> $widgets
* @param User $user
* @return array<WidgetInterface>
*/
private function filterWidgets(array $widgets, User $user): array
@@ -141,7 +142,14 @@ final class DashboardController extends AbstractController
foreach ($widgets as $widget) {
if ($widget->getId() === $id) {
$tmpWidget = clone $widget;
// protect from invalid old values
if (!\is_array($options)) {
break;
}
foreach ($options as $key => $value) {
if (!is_scalar($value)) {
continue;
}
$tmpWidget->setOption($key, $value);
}
$filteredWidgets[] = $tmpWidget;
@@ -153,8 +161,8 @@ final class DashboardController extends AbstractController
return $filteredWidgets;
}
#[Route(path: '/', defaults: [], name: 'dashboard', methods: ['GET'])]
public function index(): Response
#[Route(path: '/', defaults: [], name: 'dashboard', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
$user = $this->getUser();
$available = $this->getAllAvailableWidgets($user);
@@ -165,10 +173,79 @@ final class DashboardController extends AbstractController
$page->setActionName('dashboard');
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
$choices = [];
// the list of widgets for the dropdown is mainly used for saving
foreach ($available as $widget) {
if ($widget->isInternal() || $widget->getTitle() === '') {
continue;
}
$choices[$widget->getId()] = $widget->getId();
}
$form = $this->createFormBuilder(null, [])
->add('widgets', TextType::class, ['constraints' => new Json([
'message' => 'Invalid widget configuration',
])
])
->setMethod('POST')
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted()) {
$error = null;
$saveWidgets = [];
if (!$form->isValid()) {
$error = 'Invalid widget configuration';
} else {
$widgetsJson = $form->getData()['widgets'];
$json = false;
if (is_string($widgetsJson)) {
$json = json_decode($widgetsJson, true);
}
if ($json === false) {
$error = 'Invalid widget configuration';
} else {
try {
foreach ($json as $widget) {
if (!is_array($widget) || !isset($widget['name']) || !isset($widget['options'])) {
$error = 'Invalid widget configuration';
} else {
$widgetId = $widget['name'];
$options = $widget['options'];
if (!\is_array($options)) {
throw new \InvalidArgumentException('Widgets options must be an array');
}
foreach ($available as $tmpWidget) {
if ($widgetId === $tmpWidget->getId()) {
$saveWidgets[] = ['id' => $widgetId, 'options' => $options];
break;
}
}
}
}
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
}
}
}
if ($error !== null) {
$this->flashError($error);
} else {
$this->saveBookmark($user, $saveWidgets);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('dashboard');
}
}
return $this->render('dashboard/index.html.twig', [
'page_setup' => $page,
'widgets' => $widgets,
'available' => $available,
'form' => $form,
]);
}
@@ -187,21 +264,21 @@ final class DashboardController extends AbstractController
public function add(string $widget): Response
{
$user = $this->getUser();
$widgets = $this->getUserConfig($user);
// prevent to add the same widget multiple times
foreach ($widgets as $id => $setting) {
if ($setting['id'] === $widget) {
return $this->redirectToRoute('dashboard_edit');
$this->flashError(\sprintf('Cannot add widget "%s" multiple times.', $widget));
return $this->redirectToRoute('dashboard');
}
}
$widgets[] = ['id' => $widget, 'options' => []];
$this->saveBookmark($user, $widgets);
return $this->redirectToRoute('dashboard_edit');
return $this->redirectToRoute('dashboard');
}
private function saveBookmark(User $user, array $widgets): void
@@ -217,68 +294,4 @@ final class DashboardController extends AbstractController
$this->repository->saveBookmark($bookmark);
}
#[Route(path: '/edit/', defaults: [], name: 'dashboard_edit', methods: ['GET', 'POST'])]
public function edit(Request $request): Response
{
$user = $this->getUser();
$available = $this->getAllAvailableWidgets($user);
$widgets = $this->filterWidgets($available, $user);
$choices = [];
// the list of widgets for the dropdown is created in the EventListener and not here
// this form is mainly used for saving
foreach ($available as $widget) {
if (empty($widget->getTitle())) {
continue;
}
$choices[$widget->getId()] = $widget->getId();
}
$form = $this->createFormBuilder(null, [])
->add('widgets', ChoiceType::class, ['choices' => $choices, 'multiple' => true])
->setAction($this->generateUrl('dashboard_edit'))
->setMethod('POST')
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$userWidgets = $this->getUserConfig($user);
$saveWidgets = [];
foreach ($form->getData()['widgets'] as $widgetId) {
$options = [];
foreach ($userWidgets as $setting) {
if ($setting['id'] === $widgetId) {
$options = $setting['options'];
}
}
$saveWidgets[] = ['id' => $widgetId, 'options' => $options];
}
$this->saveBookmark($user, $saveWidgets);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('dashboard');
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
}
}
$page = new PageSetup('dashboard.title');
$page->setHelp('dashboard.html');
$page->setActionName('dashboard');
$page->setActionView('edit');
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
return $this->render('dashboard/grid.html.twig', [
'page_setup' => $page,
'widgets' => $widgets,
'form' => $form->createView(),
]);
}
}

View File

@@ -28,49 +28,43 @@ final class DashboardSubscriber extends AbstractActionsSubscriber
/** @var WidgetInterface[] $available */
$available = $payload['available'];
if (!$event->isView('edit')) {
$event->addConfig($this->path('dashboard_edit'));
} else {
$ids = [];
$event->addAction('save', ['title' => 'action.save', 'onclick' => 'saveDashboard(); return false;', 'icon' => 'save']);
foreach ($widgets as $widget) {
$ids[] = $widget->getId();
}
foreach ($available as $widget) {
if ($widget->isInternal()) {
continue;
}
// prevent to use the same widget multiple times
if (\in_array($widget->getId(), $ids)) {
continue;
}
$permissions = $widget->getPermissions();
if (\count($permissions) > 0) {
$allow = false;
foreach ($widget->getPermissions() as $permission) {
if ($this->isGranted($permission)) {
$allow = true;
}
}
if (!$allow) {
continue;
}
}
if (empty($widget->getTitle())) {
continue;
}
$event->addActionToSubmenu('widget_add', $widget->getId(), ['url' => $this->path('dashboard_add', ['widget' => $widget->getId()]), 'title' => $widget->getTitle(), 'translation_domain' => $widget->getTranslationDomain()]);
}
$event->addAction('reset', ['title' => 'action.reset', 'url' => $this->path('dashboard_reset'), 'icon' => 'delete', 'class' => 'confirmation-link', 'attr' => ['data-question' => 'confirm.delete']]);
$ids = [];
foreach ($widgets as $widget) {
$ids[] = $widget->getId();
}
foreach ($available as $widget) {
if ($widget->isInternal()) {
continue;
}
// prevent to use the same widget multiple times
if (\in_array($widget->getId(), $ids)) {
continue;
}
$permissions = $widget->getPermissions();
if (\count($permissions) > 0) {
$allow = false;
foreach ($widget->getPermissions() as $permission) {
if ($this->isGranted($permission)) {
$allow = true;
}
}
if (!$allow) {
continue;
}
}
if (!$event->hasAction('create')) {
$event->addAction('create', ['title' => 'action.add']);
}
$event->addActionToSubmenu('create', $widget->getId(), ['url' => $this->path('dashboard_add', ['widget' => $widget->getId()]), 'title' => $widget->getTitle(), 'translation_domain' => $widget->getTranslationDomain()]);
}
$event->addAction('reset', ['title' => 'action.reset', 'url' => $this->path('dashboard_reset'), 'icon' => false, 'class' => 'confirmation-link widget_edit d-none btn-outline-danger', 'attr' => ['data-question' => 'confirm.delete']]);
$event->addAction('save', ['url' => '#', 'title' => 'action.save', 'onclick' => 'saveDashboard()', 'class' => 'widget_edit d-none btn-primary', 'icon' => false]);
$event->addAction('edit', ['url' => '#', 'title' => 'edit', 'icon' => 'settings', 'onclick' => 'editDashboard()', 'class' => 'widget_edit']);
}
}

View File

@@ -0,0 +1,20 @@
<?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\Timesheet;
enum DateRangeEnum: string
{
case TODAY = 'today';
case WEEK = 'week';
case MONTH = 'month';
case YEAR = 'year';
case TOTAL = 'total';
case FINANCIAL = 'financial';
}

View File

@@ -10,7 +10,6 @@
namespace App\Twig\Runtime;
use App\Entity\User;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Symfony\Bundle\SecurityBundle\Security;
@@ -19,22 +18,18 @@ use Twig\Extension\RuntimeExtensionInterface;
final class WidgetExtension implements RuntimeExtensionInterface
{
public function __construct(private WidgetService $service, private Security $security)
public function __construct(
private readonly WidgetService $service,
private readonly Security $security
)
{
}
/**
* @param WidgetInterface|string $widget
* @param array $options
* @return string
* @throws WidgetException
* @param array<string, string|bool|int|float> $options
*/
public function renderWidget(Environment $environment, $widget, array $options = []): string
public function renderWidget(Environment $environment, WidgetInterface|string $widget, array $options = []): string
{
if (!($widget instanceof WidgetInterface) && !\is_string($widget)) {
throw new \InvalidArgumentException('Widget must be either a WidgetInterface or a string');
}
if (\is_string($widget)) {
if (!$this->service->hasWidget($widget)) {
throw new \InvalidArgumentException(\sprintf('Unknown widget "%s" requested', $widget));
@@ -48,7 +43,7 @@ final class WidgetExtension implements RuntimeExtensionInterface
$widget->setUser($user);
}
$options = $widget->getOptions($options);
$options = array_merge($widget->getOptions(), $options);
return $environment->render($widget->getTemplateName(), [
'data' => $widget->getData($options),

View File

@@ -10,27 +10,45 @@
namespace App\Widget\Type;
use App\Entity\User;
use App\Timesheet\DateRangeEnum;
use App\Widget\WidgetInterface;
use Symfony\Component\Form\Form;
abstract class AbstractWidget implements WidgetInterface
{
private array $options = [];
private ?User $user = null;
public function getDateRangeColor(DateRangeEnum $dateRangeEnum): string
{
return match ($dateRangeEnum) {
DateRangeEnum::TODAY => 'green',
DateRangeEnum::WEEK => 'blue',
DateRangeEnum::MONTH => 'purple',
DateRangeEnum::FINANCIAL, DateRangeEnum::YEAR => 'yellow',
DateRangeEnum::TOTAL => 'red',
};
}
public function getDateRangeTitle(DateRangeEnum $dateRangeEnum): string
{
return match ($dateRangeEnum) {
DateRangeEnum::TODAY => 'daterangepicker.today',
DateRangeEnum::WEEK => 'daterangepicker.thisWeek',
DateRangeEnum::MONTH => 'daterangepicker.thisMonth',
DateRangeEnum::YEAR => 'daterangepicker.thisYear',
DateRangeEnum::FINANCIAL => 'daterangepicker.thisFinancialYear',
DateRangeEnum::TOTAL => 'daterangepicker.allTime',
};
}
public function getTranslationDomain(): string
{
return 'messages';
}
public function hasForm(): bool
public function getWidth(): int
{
return false;
}
public function getForm(): ?Form
{
return null;
return WidgetInterface::WIDTH_NORMAL;
}
public function getPermissions(): array
@@ -48,21 +66,17 @@ abstract class AbstractWidget implements WidgetInterface
return $this->user;
}
/**
* @param mixed $value
*/
public function setOption(string $name, $value): void
public function setOption(string $name, string|bool|int|float $value): void
{
$this->options[$name] = $value;
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @return array<string, string|bool|int|null|array<string, mixed>>
* @return array<string, string|bool|int|float>
*/
public function getOptions(array $options = []): array
public function getOptions(): array
{
return array_merge($this->options, $options);
return $this->options;
}
public function isInternal(): bool
@@ -70,11 +84,6 @@ abstract class AbstractWidget implements WidgetInterface
return false;
}
public function getId(): string
{
return (new \ReflectionClass($this))->getShortName();
}
public function getTemplateName(): string
{
return \sprintf('widget/widget-%s.html.twig', strtolower($this->getId()));

View File

@@ -10,7 +10,7 @@
namespace App\Widget\Type;
/**
* @deprecated since 2.31.0, use AbstractWidget instead
* @deprecated since 3.0: use AbstractWidget instead
*/
abstract class AbstractWidgetType extends AbstractWidget
{
@@ -20,4 +20,9 @@ abstract class AbstractWidgetType extends AbstractWidget
$this->setOption($key, $value);
}
}
public function getId(): string
{
return (new \ReflectionClass($this))->getShortName(); // @phpstan-ignore-line
}
}

View File

@@ -59,11 +59,11 @@ final class ActiveUsers extends AbstractWidget
$qb = $this->repository->createQueryBuilder('t');
$qb->select($qb->expr()->count('t'))->where($qb->expr()->isNull('t.end'));
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{

View File

@@ -39,29 +39,14 @@ final class DailyWorkingTimeChart extends AbstractWidget
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @return array<string, string|bool|int|null|array<string, mixed>>
*/
public function getOptions(array $options = []): array
{
return array_merge([
'begin' => null,
'end' => null,
'color' => '',
'type' => 'bar',
'id' => uniqid('DailyWorkingTimeChart_'),
], parent::getOptions($options));
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{
$user = $this->getUser();
$dateTimeFactory = DateTimeFactory::createByUser($user);
$begin = $options['begin'];
$begin = $options['begin'] ?? null;
if (!($begin instanceof \DateTimeInterface)) {
if (\is_string($begin)) {
$begin = new \DateTimeImmutable($begin, new \DateTimeZone($user->getTimezone()));
@@ -70,7 +55,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
}
}
$end = $options['end'];
$end = $options['end'] ?? null;
if (!($end instanceof \DateTimeInterface)) {
if (\is_string($end)) {
$end = new \DateTimeImmutable($end, new \DateTimeZone($user->getTimezone()));

View File

@@ -55,7 +55,7 @@ final class Duration extends AbstractWidget
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{

View File

@@ -13,6 +13,7 @@ use App\Configuration\SystemConfiguration;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
use DateTime;
final class PaginatedWorkingTimeChart extends AbstractWidget
@@ -24,6 +25,11 @@ final class PaginatedWorkingTimeChart extends AbstractWidget
{
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_FULL;
}
public function getPermissions(): array
{
return ['view_own_timesheet'];
@@ -39,37 +45,6 @@ final class PaginatedWorkingTimeChart extends AbstractWidget
return 'widget/widget-paginatedworkingtimechart.html.twig';
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
@return array<string, string|bool|int|null|array<string, mixed>>
*/
public function getOptions(array $options = []): array
{
$options = parent::getOptions($options);
if (!\array_key_exists('type', $options) || !\in_array($options['type'], ['bar', 'line'])) {
$options['type'] = 'bar';
}
if (!\array_key_exists('year', $options) || !\array_key_exists('week', $options)) {
$timezone = date_default_timezone_get();
if ($this->getUser() !== null) {
$timezone = $this->getUser()->getTimezone();
}
$now = new DateTime('now', new \DateTimeZone($timezone));
if (!\array_key_exists('year', $options)) {
$options['year'] = $now->format('o');
}
if (!\array_key_exists('week', $options)) {
$options['week'] = $now->format('W');
}
}
return $options;
}
private function getLastWeekInYear($year): int
{
$lastWeekInYear = new DateTime();
@@ -79,7 +54,7 @@ final class PaginatedWorkingTimeChart extends AbstractWidget
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{
@@ -87,6 +62,22 @@ final class PaginatedWorkingTimeChart extends AbstractWidget
$dateTimeFactory = DateTimeFactory::createByUser($user);
if (!\array_key_exists('year', $options) || !\array_key_exists('week', $options)) {
$timezone = date_default_timezone_get();
if ($this->getUser() !== null) {
$timezone = $this->getUser()->getTimezone();
}
$now = new \DateTimeImmutable('now', new \DateTimeZone($timezone));
if (!\array_key_exists('year', $options)) {
$options['year'] = $now->format('o');
}
if (!\array_key_exists('week', $options)) {
$options['week'] = $now->format('W');
}
}
$year = $options['year'];
if (\is_string($year)) {
$year = (int) $year;
@@ -134,6 +125,8 @@ final class PaginatedWorkingTimeChart extends AbstractWidget
}
return [
'pagination_year' => $options['year'],
'pagination_week' => $options['week'],
'begin' => clone $weekBegin,
'end' => clone $weekEnd,
'dateYear' => $year,

View File

@@ -12,7 +12,9 @@ namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Event\RevenueStatisticEvent;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateRangeEnum;
use App\Timesheet\DateTimeFactory;
use App\Widget\WidgetInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
final class Revenue extends AbstractWidget
@@ -23,32 +25,51 @@ final class Revenue extends AbstractWidget
private readonly EventDispatcherInterface $dispatcher
)
{
$this->setOption('daterange', DateRangeEnum::MONTH->value);
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{
$financialYear = null;
$range = is_string($options['daterange']) ? $options['daterange'] : DateRangeEnum::MONTH->value;
$factory = DateTimeFactory::createByUser($this->getUser());
$type = DateRangeEnum::tryFrom($range);
$type ??= DateRangeEnum::MONTH;
if (null !== ($yearConfig = $this->systemConfiguration->getFinancialYearStart())) {
$begin = $factory->createStartOfFinancialYear($yearConfig);
$end = $factory->createEndOfFinancialYear($begin);
$financialYear = $this->getRevenue($begin, $end);
$data = null;
if ($type === DateRangeEnum::FINANCIAL) {
if (null !== ($yearConfig = $this->systemConfiguration->getFinancialYearStart())) {
$begin = $factory->createStartOfFinancialYear($yearConfig);
$end = $factory->createEndOfFinancialYear($begin);
$data = $this->getRevenue($begin, $end);
} else {
$type = DateRangeEnum::YEAR;
}
}
if ($data === null) {
$data = match ($type) {
DateRangeEnum::TODAY => $this->getRevenue($factory->createStartOfDay(), $factory->createEndOfDay()),
DateRangeEnum::WEEK => $this->getRevenue($factory->getStartOfWeek(), $factory->getEndOfWeek()),
DateRangeEnum::YEAR => $this->getRevenue($factory->createStartOfYear(), $factory->createEndOfYear()),
DateRangeEnum::TOTAL => $this->getRevenue(null, null),
default => $this->getRevenue($factory->getStartOfMonth(), $factory->getEndOfMonth()),
};
}
return [
'today' => $this->getRevenue($factory->createStartOfDay(), $factory->createEndOfDay()),
'week' => $this->getRevenue($factory->getStartOfWeek(), $factory->getEndOfWeek()),
'month' => $this->getRevenue($factory->getStartOfMonth(), $factory->getEndOfMonth()),
'year' => $this->getRevenue($factory->createStartOfYear(), $factory->createEndOfYear()),
'total' => $this->getRevenue(null, null),
'financial' => $financialYear,
'title' => $this->getDateRangeTitle($type),
'color' => $this->getDateRangeColor($type),
'value' => $data
];
}
/**
* @return array<float>
*/
private function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end): array
{
/** @var array<\App\Model\Revenue> $data */
@@ -63,6 +84,11 @@ final class Revenue extends AbstractWidget
return $event->getRevenue();
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_SMALL;
}
public function getTitle(): string
{
return 'revenue';

View File

@@ -12,7 +12,9 @@ namespace App\Widget\Type;
use App\Configuration\SystemConfiguration;
use App\Event\UserRevenueStatisticEvent;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateRangeEnum;
use App\Timesheet\DateTimeFactory;
use App\Widget\WidgetInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
final class RevenueUser extends AbstractWidget
@@ -23,32 +25,51 @@ final class RevenueUser extends AbstractWidget
private readonly EventDispatcherInterface $dispatcher
)
{
$this->setOption('daterange', DateRangeEnum::MONTH->value);
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{
$financialYear = null;
$range = is_string($options['daterange']) ? $options['daterange'] : DateRangeEnum::MONTH->value;
$factory = DateTimeFactory::createByUser($this->getUser());
$type = DateRangeEnum::tryFrom($range);
$type ??= DateRangeEnum::MONTH;
if (null !== ($yearConfig = $this->systemConfiguration->getFinancialYearStart())) {
$begin = $factory->createStartOfFinancialYear($yearConfig);
$end = $factory->createEndOfFinancialYear($begin);
$financialYear = $this->getRevenue($begin, $end);
$data = null;
if ($type === DateRangeEnum::FINANCIAL) {
if (null !== ($yearConfig = $this->systemConfiguration->getFinancialYearStart())) {
$begin = $factory->createStartOfFinancialYear($yearConfig);
$end = $factory->createEndOfFinancialYear($begin);
$data = $this->getRevenue($begin, $end);
} else {
$type = DateRangeEnum::YEAR;
}
}
if ($data === null) {
$data = match ($type) {
DateRangeEnum::TODAY => $this->getRevenue($factory->createStartOfDay(), $factory->createEndOfDay()),
DateRangeEnum::WEEK => $this->getRevenue($factory->getStartOfWeek(), $factory->getEndOfWeek()),
DateRangeEnum::YEAR => $this->getRevenue($factory->createStartOfYear(), $factory->createEndOfYear()),
DateRangeEnum::TOTAL => $this->getRevenue(null, null),
default => $this->getRevenue($factory->getStartOfMonth(), $factory->getEndOfMonth()),
};
}
return [
'today' => $this->getRevenue($factory->createStartOfDay(), $factory->createEndOfDay()),
'week' => $this->getRevenue($factory->getStartOfWeek(), $factory->getEndOfWeek()),
'month' => $this->getRevenue($factory->getStartOfMonth(), $factory->getEndOfMonth()),
'year' => $this->getRevenue($factory->createStartOfYear(), $factory->createEndOfYear()),
'total' => $this->getRevenue(null, null),
'financial' => $financialYear,
'title' => $this->getDateRangeTitle($type),
'color' => $this->getDateRangeColor($type),
'value' => $data
];
}
/**
* @return array<float>
*/
private function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end): array
{
$user = $this->getUser();
@@ -65,6 +86,11 @@ final class RevenueUser extends AbstractWidget
return $event->getRevenue();
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_SMALL;
}
public function getTitle(): string
{
return 'stats.userRevenue';

View File

@@ -49,7 +49,7 @@ final class UserTeamProjects extends AbstractWidget
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{

View File

@@ -35,7 +35,7 @@ final class UserTeams extends AbstractWidget
}
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
*/
public function getData(array $options = []): mixed
{

View File

@@ -11,7 +11,6 @@ namespace App\Widget;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Form\Form;
/**
* No BC promise!
@@ -20,21 +19,25 @@ use Symfony\Component\Form\Form;
#[AutoconfigureTag]
interface WidgetInterface
{
/** @deprecated */
public const COLOR_TODAY = 'green';
/** @deprecated */
public const COLOR_WEEK = 'blue';
/** @deprecated */
public const COLOR_MONTH = 'purple';
/** @deprecated */
public const COLOR_YEAR = 'yellow';
/** @deprecated */
public const COLOR_TOTAL = 'red';
/** @deprecated */
public const WIDTH_FULL = 4;
/** @deprecated */
public const WIDTH_LARGE = 3;
/** @deprecated */
public const WIDTH_HALF = 2;
/** @deprecated */
public const WIDTH_NORMAL = 2;
public const WIDTH_SMALL = 1;
/** @deprecated */
public const WIDTH_LARGE = 2;
/** @deprecated */
public const WIDTH_HALF = 2;
/** @deprecated */
public const HEIGHT_MAXIMUM = 6;
/** @deprecated */
@@ -46,11 +49,13 @@ interface WidgetInterface
/**
* Returns a unique ID for this widget.
* @return non-empty-string
*/
public function getId(): string;
/**
* Returns the widget title (must be non-empty).
* Returns the widget title.
* @return non-empty-string
*/
public function getTitle(): string;
@@ -59,6 +64,11 @@ interface WidgetInterface
*/
public function getTranslationDomain(): string;
/**
* Returns the width for this widget.
*/
public function getWidth(): int;
/**
* Injects the current user.
*/
@@ -71,31 +81,27 @@ interface WidgetInterface
* make sure that the given $options will overwrite the internal option for
* this one call.
*
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @param array<string, string|bool|int|float> $options
* @return mixed|null
*/
public function getData(array $options = []): mixed;
/**
* Returns all widget options to be used in the frontend.
* Returns all widget options, which should be configurable in the frontend.
*
* The given $options are not meant to be persisted, but only to
* overwrite the default values one time.
* Kimai supports the following named options by default:
* - daterange: see AbstractWidget::getDateRangeColor() and AbstractWidget::getDateRangeTitle()
*
* You can validate the options or simply return:
* return array_merge($this->options, $options);
* Do not return internal options from this method.
*
* @param array<string, string|bool|int|null|array<string, mixed>> $options
* @return array<string, string|bool|int|null|array<string, mixed>>
* @return array<string, string|bool|int|float>
*/
public function getOptions(array $options = []): array;
public function getOptions(): 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.
* Sets one widget option, overwriting the default value.
*/
public function setOption(string $name, string|bool|int $value): void;
public function setOption(string $name, string|bool|int|float $value): void;
/**
* Return a list of granted syntax string.

View File

@@ -1,149 +0,0 @@
{% extends 'base.html.twig' %}
{% from "macros/widgets.html.twig" import nothing_found, alert, page_actions, card_tool_button %}
{% block page_class %}dashboard{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('chart') }}
{{ encore_entry_link_tags('dashboard') }}
{% endblock %}
{% block head %}
{{ parent() }}
{{ encore_entry_script_tags('chart') }}
{{ encore_entry_script_tags('dashboard') }}
{% endblock %}
{% block main %}
{% if widgets is empty %}
{{ nothing_found() }}
{% else %}
{% if form is defined %}
{{ alert('warning', 'dashboard.edit_mode') }}
{{ form_start(form, {'attr': {'style': 'display: none', 'id': 'dashboard-widgets'}}) }}
{{ form_end(form) }}
{% endif %}
<div class="grid-stack" id="dashboard-grid-container" style="visibility: hidden">
{% for widget in widgets %}
{% set gridWidth = 2 %}
{% set gridHeight = 3 %}
{% set widgetWidth = gridWidth * 100 %}
{% set widgetHeight = 33 %}
<div class="grid-stack-item" data-widget="{{ widget.id }}" gs-w="{{ gridWidth }}" gs-min-w="{{ gridWidth }}" gs-h="{{ gridHeight }}" gs-id="{{ widget.id }}" id="{{ widget.id }}">
<div class="grid-stack-item-content">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ widget.title|trans({}, widget.translationDomain) }}</h3>
<div class="card-actions">
{% if widget.hasForm() %}
{{ card_tool_button('configuration', {'title': 'settings', 'class': 'modal-ajax-form', 'url': path('tasks_create')}) }}
{% endif %}
{{ card_tool_button('delete', {'title': 'widget_remove', 'url': '#', 'onclick': "removeWidget(this, '" ~ widget.id ~ "'); return false;"}) }}
</div>
</div>
<div class="card-body p-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-100" preserveAspectRatio="none" width="{{ widgetWidth }}" height="{{ widgetHeight }}" viewBox="0 0 {{ widgetWidth }} {{ widgetHeight }}" stroke="var(--tblr-border-color, #b8cef1)">
<line x1="0" y1="0" x2="{{ widgetWidth }}" y2="{{ widgetHeight }}"></line>
<line x1="0" y1="{{ widgetHeight }}" x2="{{ widgetWidth }}" y2="0"></line>
</svg>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
let grid = null;
function resizeGrid() {
let width = document.body.clientWidth;
if (width < 576) {
grid.column(1);
} else if (width < 992) {
grid.column(2);
} else if (width) {
grid.column(4);
}
}
window.addEventListener('resize', function() {
resizeGrid();
});
{% if widgets|length > 0 %}
document.addEventListener('kimai.initialized', function() {
grid = GridStack.init({
'float': false,
'cellHeight': 84,
'disableResize': true,
'column': 4,
'marginTop': 0,
'marginBottom': 0,
'marginLeft': 5,
'marginRight': 5,
'disableOneColumnMode': true,
{% if form is not defined %}
'staticGrid': true,
{% endif %}
});
resizeGrid();
document.dispatchEvent(new Event('dashboard.initialized'));
document.getElementById('dashboard-grid-container').style.visibility = 'visible';
});
{% endif %}
{% if form is defined %}
function removeWidget(button, widgetId)
{
{# hackish way to remove tooltip, as we do not have a reference in the frontend to the bootstrap tooltip classes #}
const id = button.attributes['aria-describedBy'].value;
const tooltip = document.getElementById(id);
if (tooltip !== null) {
tooltip.remove();
}
const widget = document.querySelector('div[data-widget=' + widgetId + ']');
if (widget !== null) {
grid.removeWidget(widget);
}
}
function saveDashboard()
{
let serializedData = grid.save();
const choice = document.getElementById('form_widgets');
const values = [];
const newChoices = [];
for (let item of serializedData) {
values.push(item.id);
for (let i = 0; i < choice.options.length; i++) {
if (choice.options[i].value === item.id) {
choice.options[i].selected = true;
newChoices.push(choice.options[i]);
break;
}
}
}
choice.options.length = 0;
newChoices.forEach(child => choice.appendChild(child));
choice.dispatchEvent(new Event('change'));
document.getElementById('dashboard-widgets').submit();
}
{% endif %}
</script>
{% endblock %}

View File

@@ -14,6 +14,7 @@
{% endblock %}
{% block footer %}
{# @CloudRequired #}
{% if 'dashboard' in app.request.attributes.get('_route') %}
{{ parent() }}
{% endif %}
@@ -21,17 +22,99 @@
{% block main %}
{% if widgets is empty %}
{{ widgets.nothing_found() }}
{% else %}
<div class="row row-deck row-cards">
{% set width = (widgets|length == 1) ? 12 : 6 %}
{% for widget in widgets %}
<div class="col-md-{{ width }} col-sm-12 widget" id="{{ widget.id }}">
{{ render_widget(widget) }}
{% if form is defined %}
{{ form_start(form, {'attr': {'style': 'display: none', 'id': 'dashboard-widgets'}}) }}
{{ form_end(form) }}
<div class="offcanvas offcanvas-end" id="addSmallWidget" tabindex="-1" aria-labelledby="addSmallWidgetLabel" aria-modal="true" role="dialog">
<div class="offcanvas-header">
<h2 class="offcanvas-title" id="addSmallWidgetLabel">Add widget</h2>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab assumenda ea est, eum exercitationem fugiat illum itaque laboriosam magni
necessitatibus, nemo nisi numquam quae reiciendis repellat sit soluta unde. Aut!
</div>
<div class="mt-3">
<button class="btn btn-primary" type="button" data-bs-dismiss="offcanvas">{{ 'action.close'|trans }}</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if widgets is empty %}
{{ widgets.nothing_found() }}
{% else %}
{% set small_widgets = widgets|filter(w => w.width == 1) %}
{% set large_widgets = widgets|filter(w => w.width > 1) %}
<div class="row row-deck row-cards widget-container mb-3">
{% for widget in small_widgets %}
{% set tmpId = tabler_unique_id() %}
<div class="col-lg-3 col-sm-6 widget" id="widget_{{ tmpId }}" data-id="{{ tmpId }}" data-widget="{{ widget.id }}" data-options="{{ widget.options|json_encode }}">
{{ render_widget(widget) }}
</div>
{% endfor %}
</div>
<div class="row row-deck row-cards widget-container">
{% for widget in large_widgets %}
{% set tmpId = tabler_unique_id() %}
<div class="{% if widget.width == 2 and large_widgets|length > 1 %}col-lg-6 {% endif %}col-sm-12 widget" id="widget_{{ tmpId }}" data-id="{{ tmpId }}" data-widget="{{ widget.id }}" data-options="{{ widget.options|json_encode }}">
{{ render_widget(widget) }}
</div>
{% endfor %}
</div>
{% endif %}
<script>
function setWidgetOption(element, name, value)
{
const widget = element.closest('div.widget[data-widget]');
let opts = JSON.parse(widget.dataset['options']);
opts[name] = value;
widget.dataset['options'] = JSON.stringify(opts);
saveDashboard();
}
function removeWidget(id)
{
document.getElementById(id).parentNode.remove();
}
function editDashboard()
{
const widgetContainer = document.getElementsByClassName('widget-container');
for (let c of widgetContainer) {
let sortable = window.kimai.getPlugin('sortable').toggle(c);
let cursor = (sortable) ? 'grab' : 'auto';
Array.from(c.getElementsByClassName('widget')).forEach(e => {
e.style.cursor = cursor;
});
}
Array.from(document.getElementsByClassName('widget_view')).forEach(e => e.classList.toggle('d-none'));
Array.from(document.getElementsByClassName('widget_edit')).forEach(e => e.classList.toggle('d-none'));
}
function saveDashboard()
{
const widgetContainer = document.getElementsByClassName('widget-container');
const values = [];
for (let c of widgetContainer) {
let serializedData = c.getElementsByClassName('widget');
for (let widget of serializedData) {
let widgetName = widget.dataset['widget'];
let opts = JSON.parse(widget.dataset['options']);
values.push({name: widgetName, options: opts});
}
}
document.getElementById('form_widgets').value = JSON.stringify(values);
document.getElementById('dashboard-widgets').submit();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% if options.daterange is defined %}
{% set ranges = {
'today': 'daterangepicker.today',
'week': 'daterangepicker.thisWeek',
'month': 'daterangepicker.thisMonth',
'year': 'daterangepicker.thisYear',
'financial': 'daterangepicker.thisFinancialYear',
'total': 'daterangepicker.allTime',
} %}
<div class="dropdown widget_view">
<a class="dropdown-toggle text-secondary" href="#" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{{ ranges[(options.daterange)]|trans({}, 'daterangepicker') }}</a>
<div class="dropdown-menu dropdown-menu-end">
{% for name, t in ranges %}
<a href="#" class="dropdown-item widget-option{% if options.daterange == name %} active{% endif %}" onclick="setWidgetOption(this, 'daterange', '{{ name }}')">{{ t|trans({}, 'daterangepicker') }}</a>
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,16 @@
{% set _id = id|default(tabler_unique_id('widget')) %}
<div id="{{ _id }}" class="card mb-0" {% block box_attributes %}{% endblock %}>
<div class="card-body">
<div class="d-flex align-items-center">
<div class="subheader text-uppercase">{{ title|trans({}, widget.translationDomain) }}</div>
<div class="ms-auto lh-1">
{% block box_options %}{% include 'dashboard/widget-options.html.twig' %}{% endblock %}
<a href="#" class="widget_edit d-none" onclick="removeWidget('{{ _id }}')">
{{ icon('remove') }}
</a>
</div>
</div>
{% block box_body %}{% endblock %}
</div>
</div>

View File

@@ -0,0 +1,41 @@
{# Options #}
{% set _id = id|default(tabler_unique_id('widget')) %}
{% set _fullsize = fullsize ?? false %}
{# Common blocks : Declared this way to use `is not empty` and prevent the `has already been rendered` form field twig error #}
{% set tools %}{% block box_tools %}{% endblock %}{% endset %}
{% set pagination %}{% block box_pagination %}{% endblock %}{% endset %}
<div id="{{ _id }}" class="card mb-0" {% block box_attributes %}{% endblock %}>
<div class="card-header">
<div class="card-title">{{ title|trans({}, widget.translationDomain) }}</div>
<div class="card-actions">
{% block box_options %}{% include 'dashboard/widget-options.html.twig' %}{% endblock %}
{% if pagination is not empty or tools is not empty %}
<div class="d-none d-md-flex">
<div class="d-flex widget_edit">
{{ pagination|raw }}
<span class="me-3"></span>
{{ tools|raw }}
</div>
</div>
{% endif %}
<a href="#" class="btn-action widget_edit d-none" onclick="removeWidget('{{ _id }}')">
{{ icon('remove') }}
</a>
</div>
</div>
<div class="card-body{% if _fullsize %} p-0{% endif %}">
{% block box_body %}{% endblock %}
</div>
{% if pagination is not empty or tools is not empty %}
<div class="card-footer d-md-none">
<div class="d-flex">
{{ tools|raw }}
{{ pagination|raw }}
</div>
</div>
{% endif %}
</div>

View File

@@ -1,8 +1,7 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% embed 'dashboard/widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_body %}
{% set id = tabler_unique_id('chart_active_users') %}
{% set values = [

View File

@@ -1,5 +1,5 @@
{% set type = options.type|default('bar') %}
{% set chart_id = options.id %}
{% set type = 'bar' %}
{% set chart_id = tabler_unique_id('DailyWorkingTimeChart_') %}
{% set backgroundColor = config('theme.chart.background_color') %}
{% set gridColor = config('theme.chart.grid_color') %}
{% set activities = data.activities %}

View File

@@ -1,7 +1,6 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% embed 'dashboard/widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_body %}
{% set id = tabler_unique_id('chart_duration') %}
{% set values = [

View File

@@ -1,7 +1,6 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% embed 'dashboard/widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_body %}
{% set id = tabler_unique_id('chart_money') %}
{% set values = [

View File

@@ -1,30 +1,30 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% set widgetId = 'PaginatedWorkingTimeChartBox' %}
{% embed 'dashboard/widget.html.twig' with {id: widgetId} %}
{% from '@theme/components/buttons.html.twig' import action_cardtoolbutton %}
{% block box_title %}
{% if not title is empty %}{{ title|trans }}{% endif %}
{% endblock %}
{% block box_attributes %}
id="PaginatedWorkingTimeChartBox" data-href="#" data-reload=""
{% endblock %}
{% block box_tools %}
{% set prevYear = options.year %}
{% set prevWeek = options.week %}
{% set nextYear = options.year %}
{% set nextWeek = options.week %}
{% if options.week == 1 %}
{% from "macros/widgets.html.twig" import pagination_simple %}
{% block box_pagination %}
{% set optYear = data.pagination_year %}
{% set optWeek = data.pagination_week %}
{% set prevYear = optYear %}
{% set prevWeek = optWeek %}
{% set nextYear = optYear %}
{% set nextWeek = optWeek %}
{% if optWeek == 1 %}
{% set prevYear = prevYear - 1 %}
{% set prevWeek = data.lastWeekInLastYear %}
{% else %}
{% set prevWeek = prevWeek - 1 %}
{% endif %}
{% if options.week == data.lastWeekInYear %}
{% if optWeek == data.lastWeekInYear %}
{% set nextYear = nextYear + 1 %}
{% set nextWeek = 1 %}
{% else %}
{% set nextWeek = nextWeek + 1 %}
{% endif %}
{{ action_cardtoolbutton('left', {'title': 'stats.workingTimeWeekShort'|trans({'%week%': (prevWeek ~ '/' ~ prevYear)}), 'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': prevYear, 'week': prevWeek})}) }}
{{ action_cardtoolbutton('right', {'title': 'stats.workingTimeWeekShort'|trans({'%week%': (nextWeek ~ '/' ~ nextYear)}), 'class': 'pagination-link', 'onclick': 'myChart.destroy()', 'url': path('widgets_working_time_chart', {'year': nextYear, 'week': nextWeek})}) }}
{{ pagination_simple(
{'title': 'stats.workingTimeWeekShort'|trans({'%week%': (prevWeek ~ '/' ~ prevYear)}), 'onclick': 'myChart.destroy()', 'href': path('widgets_working_time_chart', {'year': prevYear, 'week': prevWeek})},
{'title': 'stats.workingTimeWeekShort'|trans({'%week%': (nextWeek ~ '/' ~ nextYear)}), 'onclick': 'myChart.destroy()', 'href': path('widgets_working_time_chart', {'year': nextYear, 'week': nextWeek})}
) }}
{% endblock %}
{% block box_body %}
<div class="row">
@@ -74,7 +74,7 @@
{% if not kimai_context.javascriptRequest %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiPaginatedBoxWidget.create('#PaginatedWorkingTimeChartBox');
KimaiPaginatedBoxWidget.create('#{{ widgetId }}');
});
</script>
{% endif %}

View File

@@ -1,44 +1,20 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0, fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% embed 'dashboard/widget-small.html.twig' with {fullsize: true} %}
{% import "macros/progressbar.html.twig" as progress %}
{% from 'macros/widgets.html.twig' import label %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_body %}
{% set id = tabler_unique_id('chart_revenue') %}
{% set values = [
{title: 'daterangepicker.today'|trans({}, 'daterangepicker'), value: data.today, color: 'green'},
{title: 'daterangepicker.thisWeek'|trans({}, 'daterangepicker'), value: data.week, color: 'blue'},
{title: 'daterangepicker.thisMonth'|trans({}, 'daterangepicker'), value: data.month, color: 'purple'},
{title: 'daterangepicker.thisYear'|trans({}, 'daterangepicker'), value: data.year, color: 'yellow'},
] %}
{% if data.financial is not null %}
{% set values = values|merge([
{title: 'daterangepicker.thisFinancialYear'|trans({}, 'daterangepicker'), value: data.financial, color: 'yellow' },
]) %}
{% set values = data.value %}
{% if values is empty %}
{% set values = {(null): 0.00} %}
{% endif %}
{% set values = values|merge([
{title: 'daterangepicker.allTime'|trans({}, 'daterangepicker'), value: data.total, color: 'red' },
]) %}
<div class="row">
<div class="col-12">
<table class="table table-hover dataTable">
{% for row in values %}
{% set data = row.value %}
<tr>
<td>{{ row.title }}</td>
<td class="text-end">
{% if data is empty %}
{% set data = {(null): 0.00} %}
{% endif %}
{% for currency, revenue in data %}
{{ label(revenue|money(currency|default(options.currency|default(null))), row.color) }}
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<table class="w-100 mt-3">
<tr>
<td>{{ data.title|trans({}, 'daterangepicker') }}</td>
<td class="text-end">
{% for currency, revenue in values %}
{{ label(revenue|money(currency|default(options.currency|default(null))), data.color) }}
{% endfor %}
</td>
</tr>
</table>
{% endblock %}
{% endembed %}

View File

@@ -1,34 +1,32 @@
{% set projectStats = data %}
{% set title = options.title|default('my_team_projects') %}
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% embed 'dashboard/widget.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_body_class %}p-0 box-body-scrollable{% endblock %}
{% block box_title %}
{% if not title is empty %}{{ title|trans }}{% endif %}
{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable" role="grid">
<tbody>
{% for row in projectStats|sort((a, b) => a.entity.name <=> b.entity.name) %}
{% set project = row.entity %}
<tr{% if is_granted('details', project) %} class="alternative-link open-edit" data-href="{{ path('project_details', {'id': project.id}) }}"{% endif %}>
<td>
{{ widgets.label_project(project) }}
<br>
<small>{{ widgets.label_customer(project.customer) }}</small>
</td>
<td style="width:50%">
{% if row.hasBudget() and is_granted('budget', row.entity) %}
{{ progress.progressbar_budget(row, row.entity.customer.currency) }}
{% elseif row.hasTimeBudget() and is_granted('time', row.entity) %}
{{ progress.progressbar_timebudget(row) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="box-body-scrollable">
<table class="table table-hover dataTable" role="grid">
<tbody>
{% for row in projectStats|sort((a, b) => a.entity.name <=> b.entity.name) %}
{% set project = row.entity %}
<tr{% if is_granted('details', project) %} class="alternative-link open-edit" data-href="{{ path('project_details', {'id': project.id}) }}"{% endif %}>
<td>
{{ widgets.label_project(project) }}
<br>
<small>{{ widgets.label_customer(project.customer) }}</small>
</td>
<td style="width:50%">
{% if row.hasBudget() and is_granted('budget', row.entity) %}
{{ progress.progressbar_budget(row, row.entity.customer.currency) }}
{% elseif row.hasTimeBudget() and is_granted('time', row.entity) %}
{{ progress.progressbar_timebudget(row) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% endembed %}

View File

@@ -1,11 +1,11 @@
{% set teams = data %}
{% set title = options.title|default(title|default('teams')) %}
{% embed '@theme/embeds/card.html.twig' with {'border': false, 'margin_bottom': 0} %}
{% embed 'dashboard/widget.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_body_class %}p-0 box-body-scrollable{% endblock %}
{% block box_body %}
{{ widgets.team_list(teams, {title: false}) }}
<div class="box-body-scrollable">
{{ widgets.team_list(teams, {title: false}) }}
</div>
{% endblock %}
{% endembed %}