improved widget experience with sortable and inline options
This commit is contained in:
2
public/build/app.1bb6240b.js
Normal file
2
public/build/app.1bb6240b.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
1
public/build/runtime.1440a7f0.js
Normal file
1
public/build/runtime.1440a7f0.js
Normal 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))}()}();
|
||||
@@ -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))}()}();
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Timesheet/DateRangeEnum.php
Normal file
20
src/Timesheet/DateRangeEnum.php
Normal 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';
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
18
templates/dashboard/widget-options.html.twig
Normal file
18
templates/dashboard/widget-options.html.twig
Normal 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 %}
|
||||
16
templates/dashboard/widget-small.html.twig
Normal file
16
templates/dashboard/widget-small.html.twig
Normal 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>
|
||||
41
templates/dashboard/widget.html.twig
Normal file
41
templates/dashboard/widget.html.twig
Normal 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>
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user