Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff62c7da87 | ||
|
|
fd94f033c8 | ||
|
|
f2cfe593bf | ||
|
|
5dc054dc64 | ||
|
|
960d4f09fa | ||
|
|
aaa58e6d20 | ||
|
|
c92a8949b2 | ||
|
|
78854b7807 | ||
|
|
348da17347 | ||
|
|
f0c72d8b76 | ||
|
|
b19feb400e | ||
|
|
b5edb6a560 | ||
|
|
996e440c46 | ||
|
|
9bd0d8baf4 | ||
|
|
f74425fec6 | ||
|
|
8a51b9c47a | ||
|
|
9051fd1e1d | ||
|
|
c61638eba4 | ||
|
|
a836051dff | ||
|
|
50a3cbbbea | ||
|
|
75ee0b7b99 | ||
|
|
3572a0fb62 |
5
.github/workflows/docker.yaml
vendored
5
.github/workflows/docker.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Determine version
|
||||
run: |
|
||||
input="${{ github.event.inputs.kimai_tag }}"
|
||||
|
||||
|
||||
# Determine between manual trigger and release event
|
||||
if [ -z "$input" ]; then
|
||||
echo "Using release tag: ${{ github.event.release.tag_name }}"
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
echo "Invalid version number: $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
echo "kimai_version=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: FPM image
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
tags: |
|
||||
kimai/kimai2:latest
|
||||
kimai/kimai2:fpm
|
||||
kimai/kimai2:fpm-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
|
||||
@@ -30,8 +30,3 @@ Do not use method chaining: all fluent interface, especially in Entities, are no
|
||||
- Removed and renamed translations, most important `action.edit` => `edit`, `my.profile` => `user_profile`
|
||||
- Removed `User::isExportDecimal()`
|
||||
- Use duration format `HH:mm` in default PDF exports
|
||||
- Replace Twig `AppVariable` with custom implementation
|
||||
- You need to adjust your templates if you access anything else then `app.locale`, `app.user`. `app.current_route`.
|
||||
- Most often used:
|
||||
- Replace `app.request.locale` with `app.locale`
|
||||
- Replace `app.request.attributes.get('_route')` with `app.current_route`
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* https://gridstackjs.com
|
||||
* https://github.com/gridstack/gridstack.js/tree/master/doc
|
||||
*/
|
||||
require('gridstack/dist/gridstack.min.css');
|
||||
require('gridstack/dist/gridstack-extra.min.css');
|
||||
import { GridStack } from 'gridstack';
|
||||
global.GridStack = GridStack;
|
||||
@@ -44,6 +44,7 @@ import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
|
||||
import KimaiUser from "./plugins/KimaiUser";
|
||||
import KimaiAutocompleteTags from "./forms/KimaiAutocompleteTags";
|
||||
import KimaiMonthPicker from "./forms/KimaiMonthPicker";
|
||||
import KimaiSortable from "./plugins/KimaiSortable";
|
||||
|
||||
export default class KimaiLoader {
|
||||
|
||||
@@ -92,6 +93,7 @@ export default class KimaiLoader {
|
||||
kimai.registerPlugin(new KimaiActiveRecords());
|
||||
kimai.registerPlugin(new KimaiAPILink('api-link'));
|
||||
kimai.registerPlugin(new KimaiMultiUpdateTable());
|
||||
kimai.registerPlugin(new KimaiSortable());
|
||||
kimai.registerPlugin(new KimaiThemeInitializer());
|
||||
|
||||
// notify all listeners that Kimai plugins can now be registered
|
||||
|
||||
@@ -73,7 +73,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
const ALERT = this.getContainer().getPlugin('alert');
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
@@ -83,7 +82,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
@@ -92,8 +90,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
if (method === 'PATCH') {
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
|
||||
@@ -217,6 +217,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
const eventName = form.dataset['formEvent'];
|
||||
/** @type {KimaiEvent} alert */
|
||||
const events = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -257,8 +259,15 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined message first, but
|
||||
let msg = form.dataset['msgSuccess'];
|
||||
// if that is not available: use a generic fallback message
|
||||
if (msg === null || msg === undefined || msg === '') {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
alert.success(msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -268,8 +277,6 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
alert.error(message, error.message);
|
||||
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
|
||||
51
assets/js/plugins/KimaiSortable.js
Normal file
51
assets/js/plugins/KimaiSortable.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiSortable: allow sorting of HTML elements
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
export default class KimaiSortable extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'sortable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable sorting on the given selector
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @returns {true}
|
||||
*/
|
||||
toggle(element) {
|
||||
if (!element instanceof HTMLElement) {
|
||||
console.error('Given element is invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
let sortable = Sortable.get(element);
|
||||
if (sortable === undefined || sortable === null) {
|
||||
Sortable.create(element);
|
||||
return true;
|
||||
}
|
||||
|
||||
sortable.destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
toArray(element) {
|
||||
let sortable = Sortable.get(element);
|
||||
if (sortable === undefined || sortable === null) {
|
||||
return null;
|
||||
}
|
||||
return sortable.toArray();
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'div.page-wrapper';
|
||||
let container = 'body';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export default class KimaiCalendar {
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
/** @type {KimaiAjaxModalForm} MODAL */
|
||||
const MODAL = this.kimai.getPlugin('modal');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
|
||||
// Instead of using "buttonIcons" the theme needs to be adjusted directly
|
||||
// https://fullcalendar.io/docs/buttonIcons
|
||||
@@ -280,7 +282,6 @@ export default class KimaiCalendar {
|
||||
droppable: true,
|
||||
// drop function handles external draggable events
|
||||
drop: (dropInfo) => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
const entry = dropInfo.draggedEl;
|
||||
const source = entry.parentElement;
|
||||
let data = JSON.parse(entry.dataset.entry);
|
||||
@@ -323,7 +324,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -333,7 +334,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -702,6 +703,8 @@ export default class KimaiCalendar {
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.kimai.getPlugin('api');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
/** @type {KimaiDateUtils} DATE */
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
|
||||
@@ -713,14 +716,11 @@ export default class KimaiCalendar {
|
||||
payload.end = null;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
const updateUrl = this.options.url.update(event.id);
|
||||
API.patch(updateUrl, JSON.stringify(payload), () => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
ALERT.success('action.update.success');
|
||||
}, (error) => {
|
||||
eventArg.revert();
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError('action.update.error', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@
|
||||
@import "weekly-hours";
|
||||
@import "tabler-fixes";
|
||||
@import "help";
|
||||
@import "chart";
|
||||
@import "rtl";
|
||||
@import "dashboard";
|
||||
|
||||
7
assets/sass/chart.scss
Normal file
7
assets/sass/chart.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
div.chart-container {
|
||||
position: relative;
|
||||
canvas.chartjs {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
3
assets/sass/dashboard.scss
Normal file
3
assets/sass/dashboard.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
section.dashboard {
|
||||
.widget { max-height: 600px; }
|
||||
}
|
||||
@@ -12,6 +12,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1021;
|
||||
/*background-color: var(--tblr-backdrop-bg);*/
|
||||
background-color: var(--tblr-backdrop-bg);
|
||||
opacity: $modal-backdrop-opacity;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,4 @@ fieldset.form-fieldset > legend {
|
||||
.page-title {
|
||||
color: var(--tblr-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlighted text is not visible - https://github.com/tabler/tabler/issues/2603 */
|
||||
[data-bs-theme=dark] {
|
||||
::selection,
|
||||
.text-selected {
|
||||
background-color: var(--#{$prefix}primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"doctrine/orm": "^3.0",
|
||||
"easybill/zugferd-php": "^2.1",
|
||||
"endroid/qr-code": "^6.0",
|
||||
"parsedown/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "3.9.0-beta1",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"horstoeko/zugferd": "^1.0",
|
||||
@@ -45,7 +46,6 @@
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"openspout/openspout": "^4.0",
|
||||
"pagerfanta/pagerfanta": "^4.0",
|
||||
"parsedown/parsedown": "^1.6",
|
||||
"phpoffice/phpspreadsheet": "^2.0",
|
||||
"phpoffice/phpword": "^1.0",
|
||||
"psr/container": "^2.0",
|
||||
|
||||
1142
composer.lock
generated
1142
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,7 @@ tabler:
|
||||
manual: fas fa-book
|
||||
mobile: fas fa-mobile
|
||||
money: fas fa-coins
|
||||
move: fas fa-up-down-left-right
|
||||
ods: fas fa-table
|
||||
off: fas fa-toggle-off
|
||||
on: fas fa-toggle-on
|
||||
@@ -115,6 +116,7 @@ tabler:
|
||||
rejected: fas fa-ban
|
||||
right: fas fa-chevron-right
|
||||
right2: fas fa-angle-double-right
|
||||
remove: fas fa-xmark
|
||||
roles: fas fa-user-shield
|
||||
save: far fa-save
|
||||
search: fas fa-search
|
||||
@@ -138,7 +140,5 @@ tabler:
|
||||
visibility: far fa-eye
|
||||
warning: fas fa-exclamation
|
||||
weekly-times: fas fa-th
|
||||
widget_add: fas fa-folder-plus
|
||||
widget_remove: fas fa-folder-minus
|
||||
work_times: fas fa-calculator
|
||||
xlsx: fas fa-file-excel
|
||||
|
||||
@@ -5,8 +5,6 @@ twig:
|
||||
paths:
|
||||
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
|
||||
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme
|
||||
globals:
|
||||
app: '@App\Twig\AppVariable'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -243,7 +243,3 @@ services:
|
||||
|
||||
App\Validator\Constraints\QuickEntryTimesheetValidator:
|
||||
arguments: ['%kimai.validator_timesheet%']
|
||||
|
||||
App\Twig\AppVariable:
|
||||
tags:
|
||||
- {name: twig.global, alias: app}
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
"core-js": "^3",
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
"highlight.js": "^11",
|
||||
"litepicker": "^2",
|
||||
"luxon": "^3",
|
||||
"sass": "^1",
|
||||
"sass-loader": "^16",
|
||||
"sortablejs": "^1.15",
|
||||
"tom-select": "^2",
|
||||
"webpack": "^5",
|
||||
"webpack-cli": "^5"
|
||||
|
||||
116
phpstan.neon
116
phpstan.neon
@@ -591,7 +591,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 2
|
||||
count: 1
|
||||
path: src/Controller/DashboardController.php
|
||||
|
||||
-
|
||||
@@ -609,16 +609,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/DashboardController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Widget\\\\WidgetInterface\\:\\:setOption\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/DashboardController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$value of method App\\\\Widget\\\\WidgetInterface\\:\\:setOption\\(\\) expects bool\\|int\\|string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/DashboardController.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Controller\\\\DoctorController\\:\\:getComposerPackages\\(\\) should return array\\<string, string\\> but returns array\\<string, string\\|null\\>\\.$#"
|
||||
count: 1
|
||||
@@ -2514,6 +2504,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferenceType.php
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var for variable \\$collection contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$key of method Doctrine\\\\Common\\\\Collections\\\\ArrayCollection\\<\\(int\\|string\\),mixed\\>\\:\\:set\\(\\) expects \\(int\\|string\\), string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset mixed on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -3335,24 +3335,34 @@ parameters:
|
||||
path: src/Twig/Context.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Runtime\\\\DatatableExtensions\\:\\:checkInColumDefinition\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
|
||||
message: "#^Method App\\\\Twig\\\\DatatableExtensions\\:\\:checkInColumDefinition\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/Runtime/DatatableExtensions.php
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Runtime\\\\DatatableExtensions\\:\\:getDatatableColumnClass\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
message: "#^Method App\\\\Twig\\\\DatatableExtensions\\:\\:getClass\\(\\) has parameter \\$class with no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/Runtime/DatatableExtensions.php
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Runtime\\\\DatatableExtensions\\:\\:initializeDatatable\\(\\) has parameter \\$defaultColumns with no value type specified in iterable type array\\.$#"
|
||||
message: "#^Method App\\\\Twig\\\\DatatableExtensions\\:\\:getDatatableColumnClass\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/Runtime/DatatableExtensions.php
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Runtime\\\\DatatableExtensions\\:\\:initializeDatatable\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
message: "#^Method App\\\\Twig\\\\DatatableExtensions\\:\\:initializeDatatable\\(\\) has parameter \\$defaultColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/Runtime/DatatableExtensions.php
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\DatatableExtensions\\:\\:initializeDatatable\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Twig\\\\DatatableExtensions\\:\\:\\$tableNames type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/DatatableExtensions.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Extensions\\:\\:replaceNewline\\(\\) has no return type specified\\.$#"
|
||||
@@ -3404,11 +3414,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Twig/Runtime/ThemeExtension.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Twig\\\\Runtime\\\\WidgetExtension\\:\\:renderWidget\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Twig/Runtime/WidgetExtension.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\User\\\\UserService\\:\\:updateUser\\(\\) has parameter \\$groups with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3630,14 +3635,59 @@ parameters:
|
||||
path: src/Utils/ProfileManager.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: src/Validator/Constraints/Duration.php
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$pattern of method Symfony\\\\Component\\\\Validator\\\\Constraints\\\\Regex\\:\\:__construct\\(\\) expects array\\|string\\|null, mixed given\\.$#"
|
||||
message: "#^Cannot access offset 0 on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Validator/Constraints/Duration.php
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$body on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$draft on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$html_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$prerelease on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$published_at on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$tag_name on mixed\\.$#"
|
||||
count: 2
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$zipball_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\ReleaseVersion\\:\\:getReleasesFromGithub\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$value \\(App\\\\Entity\\\\Team\\) of method App\\\\Validator\\\\Constraints\\\\TeamValidator\\:\\:validate\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Validator\\\\ConstraintValidatorInterface\\:\\:validate\\(\\)$#"
|
||||
@@ -3729,11 +3779,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Widget/Type/AbstractWidgetType.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Widget\\\\Type\\\\AbstractWidgetType\\:\\:setPermissions\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Widget/Type/AbstractWidgetType.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Widget\\\\Type\\\\PaginatedWorkingTimeChart\\:\\:getLastWeekInYear\\(\\) has parameter \\$year with no type specified\\.$#"
|
||||
count: 1
|
||||
@@ -3763,3 +3808,8 @@ parameters:
|
||||
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Entity/InvoiceTemplate.php
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
<ini name="date.timezone" value="UTC"/>
|
||||
<ini name="intl.default_locale" value="en"/>
|
||||
<ini name="intl.default_locale" value="en_US"/>
|
||||
<ini name="date.timezone" value="Europe/Vienna"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.5f84f245.js
Normal file
2
public/build/app.5f84f245.js
Normal file
File diff suppressed because one or more lines are too long
@@ -148,6 +148,10 @@
|
||||
* [KIMAI] KimaiRemoteModal: load remote content (without forms) into a modal
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiSortable: allow sorting of HTML elements
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiStorage: simple wrapper to handle localStorage access
|
||||
*/
|
||||
@@ -175,3 +179,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
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,25 +2,25 @@
|
||||
"entrypoints": {
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.3ba0bd00.js"
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/app.5f84f245.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.16ea604e.css"
|
||||
"/build/app.a5390fc8.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/app-rtl.15853b82.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.f149299b.css"
|
||||
"/build/app-rtl.738705fe.css"
|
||||
]
|
||||
},
|
||||
"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.42b319e4.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"invoice-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/invoice-pdf.26d98626.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -47,31 +47,22 @@
|
||||
},
|
||||
"chart": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/chart.bafa38e7.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.4242f779.js"
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/calendar.b2d70caa.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/dashboard.6ce7ac9c.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
]
|
||||
},
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.1440a7f0.js",
|
||||
"/build/highlight.13d5d50e.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -80,11 +71,11 @@
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.3ba0bd00.js": "sha384-4G73IK+GVsttYdvW0Pt2Hov27rTJuGDusRBvoRnzPezDlTiArF4MV9mVDi7anlX+",
|
||||
"/build/app.16ea604e.css": "sha384-9734FGgqgVw+kNVry5jl15+pbaetOtfl3eywAy2pSigGpeIXlCh1udhh7rI9lNKo",
|
||||
"/build/runtime.1440a7f0.js": "sha384-aX7qq4dPkl9vztZOIlBKii2dw2xiScUgZRgReqtcRQiC9wU+Mp2msgHYC8qaHlwU",
|
||||
"/build/app.5f84f245.js": "sha384-d+MjeEeMfv6dvRAk+HRJ1mH0Dpc9HX+wiCbdTun5xjuWzCEyQ0NkTAql9Nar5bbe",
|
||||
"/build/app.a5390fc8.css": "sha384-3t41wr9KN/V7QiodE4JzguTuW/TE4ls/9kXhNWjVomvHmTRmXDQqhH8q92pdOe7l",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.f149299b.css": "sha384-nCTYbFHUkkF20RA5oXqGah3iDnnSUxAmuFHpbehTzHszCgJBVRoHC81Q5OwFreLf",
|
||||
"/build/app-rtl.738705fe.css": "sha384-QRo9Rd5MwfQvzAethlsHSBga8gXfejYk4wGcY8OVe0B53D0jJNcDTjFasrVIbJwd",
|
||||
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
|
||||
@@ -92,10 +83,8 @@
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.bafa38e7.js": "sha384-Ays2qGKvOqs4NSeN/zJOPcrnzIEC/uYSCIkC3kN0KQye+mA5Lq7UkfjZDjulvFUX",
|
||||
"/build/calendar.4242f779.js": "sha384-fz1ouLGHV92+3gq3eWYAgEIbwVSmuCfILeVjAnnXGjJJfUtMs+qlGEIIo3uQa0rz",
|
||||
"/build/calendar.b2d70caa.js": "sha384-FS7Q9iCWHpo2Nzk0tmNvnuTbxJKLdbXFba9F6WcXfHbKFAS2Q+lW29H9Mv+ap5kA",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.6ce7ac9c.js": "sha384-5BOoyjZx/ZKr9IbogWfT/aBRHx7XA1fePADdUpUNlSF/rqVo3taXLLcrIoaDZ+8b",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.13d5d50e.js": "sha384-+RqBBPIzp5YQpbBeHgVNXzHiewK1PZtizPh4in1PlBU6RcT1bDleW3QSJMDeBnPO",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.16ea604e.css",
|
||||
"build/app.js": "/build/app.3ba0bd00.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.f149299b.css",
|
||||
"build/app.css": "/build/app.a5390fc8.css",
|
||||
"build/app.js": "/build/app.5f84f245.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.738705fe.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
@@ -11,12 +11,10 @@
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.bafa38e7.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.4242f779.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.6ce7ac9c.js",
|
||||
"build/calendar.js": "/build/calendar.b2d70caa.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.13d5d50e.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.2582b0e4.ttf",
|
||||
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
|
||||
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.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))}()}();
|
||||
@@ -508,7 +508,7 @@ final class TranslationCommand extends Command
|
||||
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -91,29 +91,8 @@ final class DashboardController extends AbstractController
|
||||
|
||||
// default widgets
|
||||
$dashboard = [
|
||||
// FIXME
|
||||
'PaginatedWorkingTimeChart',
|
||||
//'UserAmountToday',
|
||||
//'UserAmountWeek',
|
||||
//'UserAmountMonth',
|
||||
//'UserAmountYear',
|
||||
//'UserTeams',
|
||||
//'UserTeamProjects',
|
||||
'DurationToday',
|
||||
'DurationWeek',
|
||||
'DurationMonth',
|
||||
'DurationYear',
|
||||
//'ActiveUsersToday',
|
||||
//'ActiveUsersWeek',
|
||||
//'ActiveUsersMonth',
|
||||
//'ActiveUsersYear',
|
||||
//'AmountToday',
|
||||
//'AmountWeek',
|
||||
//'AmountMonth',
|
||||
//'AmountYear',
|
||||
//'TotalsUser',
|
||||
//'TotalsCustomer',
|
||||
//'TotalsProject',
|
||||
//'TotalsActivity',
|
||||
];
|
||||
|
||||
foreach ($dashboard as $widgetName) {
|
||||
@@ -128,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 = [];
|
||||
@@ -149,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
|
||||
@@ -162,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;
|
||||
@@ -174,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);
|
||||
@@ -186,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -208,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
|
||||
@@ -238,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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ final class ProjectDateRangeController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('reporting/project/daterange.html.twig', [
|
||||
'expand_projects' => $request->query->get('view', '1') === '1',
|
||||
'report_title' => 'report_project_daterange',
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
|
||||
@@ -79,7 +79,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
$page = new PageSetup('settings');
|
||||
$page = new PageSetup('menu.system_configuration');
|
||||
$page->setHelp('configurations.html');
|
||||
|
||||
return $this->render('system-configuration/index.html.twig', [
|
||||
@@ -105,7 +105,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
$page = new PageSetup('settings');
|
||||
$page = new PageSetup('menu.system_configuration');
|
||||
$page->setHelp('configurations.html');
|
||||
|
||||
return $this->render('system-configuration/section.html.twig', [
|
||||
|
||||
@@ -35,7 +35,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
|
||||
#[Exporter\Expose(name: 'project', label: 'project', exp: 'object.getProject() === null ? null : object.getProject().getName()')]
|
||||
#[Constraints\Activity]
|
||||
#[Loggable(ignoredProperties: ['createdAt'], title: 'activity')]
|
||||
#[Loggable(title: 'activity')]
|
||||
class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
use BudgetTrait;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +18,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['activity'], title: 'activity')]
|
||||
class ActivityMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
#[Loggable(title: 'settings')]
|
||||
#[Loggable(title: 'menu.system_configuration')]
|
||||
class Configuration
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
||||
@@ -30,7 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'addressLine1', 'addressLine2', 'addressLine3', 'postCode', 'city', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
#[Constraints\Customer]
|
||||
#[Loggable(ignoredProperties: ['createdAt'], title: 'customer')]
|
||||
#[Loggable(title: 'customer')]
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
public const DEFAULT_CURRENCY = 'EUR';
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +18,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['customer'], title: 'customer')]
|
||||
class CustomerMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +18,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['invoice'], title: 'invoices')]
|
||||
class InvoiceMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
@@ -299,7 +299,7 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, InvoiceTemplateMeta>
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getMetaFields(): Collection
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
|
||||
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
|
||||
#[Constraints\Project]
|
||||
#[Loggable(ignoredProperties: ['createdAt'], title: 'project')]
|
||||
#[Loggable(title: 'project')]
|
||||
class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
use BudgetTrait;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +18,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['project'], title: 'project')]
|
||||
class ProjectMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
@@ -24,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['visible'])]
|
||||
#[UniqueEntity('name')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(title: 'tags')]
|
||||
#[Loggable(title: 'tag')]
|
||||
class Tag
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\VirtualProperty('UserAsId', exp: 'object.getUser().getId()', options: [new Serializer\SerializedName('user'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Not_Expanded'])])]
|
||||
#[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])]
|
||||
#[Constraints\TimesheetAll]
|
||||
#[Loggable(ignoredProperties: ['category', 'date', 'modifiedAt'], title: 'timesheet')]
|
||||
#[Loggable(ignoredProperties: ['category', 'date'], title: 'timesheet')]
|
||||
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
{
|
||||
use ModifiedTrait;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -19,7 +18,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['timesheet'], title: 'timesheet')]
|
||||
class TimesheetMeta implements MetaTableTypeInterface
|
||||
{
|
||||
use MetaTableTypeTrait;
|
||||
|
||||
@@ -334,24 +334,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called from the "edit user preferences" form.
|
||||
* Therefor it just merges the values for existing preferences and adds new ones.
|
||||
* But it will NOT remove existing preferences or replace the underlying collection.
|
||||
*
|
||||
* @param iterable<UserPreference> $preferences
|
||||
*/
|
||||
public function setPreferences(iterable $preferences): User
|
||||
{
|
||||
$this->preferences = new ArrayCollection();
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
if (($name = $preference->getName()) === null) {
|
||||
continue;
|
||||
}
|
||||
$p = $this->getPreference($name);
|
||||
if ($p === null) {
|
||||
$this->addPreference($preference);
|
||||
} else {
|
||||
$p->setValue($preference->getValue());
|
||||
}
|
||||
$this->addPreference($preference);
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Form\Type\YesNoType;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -25,7 +24,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(ignoredProperties: ['user'], title: 'user')]
|
||||
class UserPreference
|
||||
{
|
||||
public const HOURLY_RATE = 'hourly_rate';
|
||||
|
||||
@@ -22,7 +22,6 @@ class PageActionsEvent extends ThemeEvent
|
||||
{
|
||||
private int $divider = 0;
|
||||
private ?string $locale = null;
|
||||
private ?object $item = null;
|
||||
|
||||
/**
|
||||
* @param array<mixed> $payload
|
||||
@@ -33,17 +32,10 @@ class PageActionsEvent extends ThemeEvent
|
||||
if (!\array_key_exists('actions', $payload)) {
|
||||
$payload['actions'] = [];
|
||||
}
|
||||
|
||||
// only for BC reasons, do not access it directly!
|
||||
if (!\array_key_exists('view', $payload)) {
|
||||
$payload['view'] = $view;
|
||||
}
|
||||
|
||||
if (\array_key_exists('item', $payload)) {
|
||||
$this->item = $payload['item'];
|
||||
unset($payload['item']);
|
||||
}
|
||||
|
||||
parent::__construct($user, $payload);
|
||||
}
|
||||
|
||||
@@ -184,16 +176,13 @@ class PageActionsEvent extends ThemeEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to open a section from the System settings in a modal.
|
||||
* Link to a configuration section.
|
||||
*/
|
||||
public function addSettings(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'accesskey' => 'h']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to a page specific configuration screen.
|
||||
*/
|
||||
public function addConfig(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'title' => 'settings']);
|
||||
@@ -236,12 +225,4 @@ class PageActionsEvent extends ThemeEvent
|
||||
{
|
||||
$this->locale = $locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null for "listing" pages.
|
||||
*/
|
||||
public function getItem(): object|null
|
||||
{
|
||||
return $this->item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final class RevenueStatisticEvent extends Event
|
||||
*/
|
||||
private array $revenue = [];
|
||||
|
||||
public function __construct(private ?\DateTimeInterface $begin, private ?\DateTimeInterface $end)
|
||||
public function __construct(private readonly ?\DateTimeInterface $begin, private readonly ?\DateTimeInterface $end)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ final class RevenueStatisticEvent extends Event
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public function getRevenue(): array
|
||||
{
|
||||
return $this->revenue;
|
||||
|
||||
@@ -72,10 +72,6 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_invoice')) {
|
||||
$event->addActionToSubmenu('filter', 'invoice', ['title' => 'invoices', 'url' => $this->path('admin_invoice_list', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
$event->addDivider();
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,49 +38,30 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($allowView) {
|
||||
$event->addAction('download', ['title' => 'download', 'url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
|
||||
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
|
||||
}
|
||||
|
||||
if (\array_key_exists('token', $payload)) {
|
||||
if ($event->countActions() > 0) {
|
||||
$event->addDivider();
|
||||
}
|
||||
if ($event->countActions() > 0) {
|
||||
$event->addDivider();
|
||||
}
|
||||
|
||||
if ($allowCreate) {
|
||||
if (!$invoice->isPending()) {
|
||||
$event->addAction('invoice.pending', [
|
||||
'url' => $this->path(
|
||||
'admin_invoice_status',
|
||||
['id' => $invoice->getId(), 'status' => 'pending', 'token' => $payload['token']]
|
||||
)
|
||||
]);
|
||||
} else {
|
||||
$event->addAction('invoice.paid', [
|
||||
'url' => $this->path(
|
||||
'admin_invoice_status',
|
||||
['id' => $invoice->getId(), 'status' => 'paid', 'token' => $payload['token']]
|
||||
),
|
||||
'class' => 'modal-ajax-form'
|
||||
]);
|
||||
}
|
||||
if ($allowCreate) {
|
||||
if (!$invoice->isPending()) {
|
||||
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending', 'token' => $payload['token']])]);
|
||||
} else {
|
||||
$event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid', 'token' => $payload['token']]), 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
}
|
||||
|
||||
$allowDelete = $this->isGranted('delete_invoice');
|
||||
if (!$invoice->isCanceled()) {
|
||||
$id = $allowDelete ? 'invoice.cancel' : 'trash';
|
||||
$event->addAction($id, [
|
||||
'url' => $this->path(
|
||||
'admin_invoice_status',
|
||||
['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]
|
||||
),
|
||||
'title' => 'invoice.cancel'
|
||||
]);
|
||||
}
|
||||
$allowDelete = $this->isGranted('delete_invoice');
|
||||
if (!$invoice->isCanceled()) {
|
||||
$id = $allowDelete ? 'invoice.cancel' : 'trash';
|
||||
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('delete_invoice')) {
|
||||
$event->addDivider();
|
||||
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId(), 'token' => $payload['token']]), false);
|
||||
}
|
||||
if ($this->isGranted('delete_invoice')) {
|
||||
$event->addDivider();
|
||||
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId(), 'token' => $payload['token']]), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
|
||||
}
|
||||
if ($this->isGranted('preferences', $user)) {
|
||||
$event->addAction('settings', ['url' => $this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]), 'title' => 'profile.preferences']);
|
||||
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));
|
||||
}
|
||||
if ($this->isGranted('password', $user)) {
|
||||
$event->addAction('password', ['url' => $this->path('user_profile_password', ['username' => $user->getUserIdentifier()]), 'title' => 'profile.password']);
|
||||
|
||||
@@ -193,7 +193,7 @@ final class MenuSubscriber implements EventSubscriberInterface
|
||||
}
|
||||
|
||||
if ($auth->isGranted('system_configuration')) {
|
||||
$systemConfig = new MenuItemModel('configurations', 'settings', 'system_configuration', [], 'configuration');
|
||||
$systemConfig = new MenuItemModel('configurations', 'menu.system_configuration', 'system_configuration', [], 'configuration');
|
||||
$systemConfig->setChildRoutes(['system_configuration_update', 'system_configuration_section']);
|
||||
$menu->addChild($systemConfig);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
@@ -82,12 +82,9 @@ final class HtmlRenderer implements ExportRendererInterface
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), [
|
||||
'entries' => $exportItems,
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
@@ -85,12 +85,9 @@ final class PDFRenderer implements DispositionInlineInterface, ExportRendererInt
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), [
|
||||
'entries' => $exportItems,
|
||||
@@ -101,8 +98,6 @@ final class PDFRenderer implements DispositionInlineInterface, ExportRendererInt
|
||||
'pdfContext' => $context
|
||||
]);
|
||||
|
||||
$sandbox->disableSandbox();
|
||||
|
||||
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
|
||||
|
||||
$content = $this->converter->convertToPdf($content, $pdfOptions);
|
||||
|
||||
@@ -30,10 +30,11 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event): void {
|
||||
/** @var ArrayCollection<int, UserPreference> $collection */
|
||||
/** @var ArrayCollection<UserPreference> $collection */
|
||||
$collection = $event->getData();
|
||||
$preferences = new ArrayCollection();
|
||||
foreach ($collection as $collectionItem) {
|
||||
$collection->removeElement($collectionItem);
|
||||
|
||||
if (!($collectionItem instanceof UserPreference)) {
|
||||
continue;
|
||||
}
|
||||
@@ -43,9 +44,8 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
continue;
|
||||
}
|
||||
|
||||
$preferences->set($collectionItem->getName(), clone $collectionItem);
|
||||
$collection->set($collectionItem->getName(), $collectionItem);
|
||||
}
|
||||
$event->setData($preferences);
|
||||
},
|
||||
// must be a higher priority then the listener in UserPreferenceType
|
||||
100
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Model\InvoiceDocument;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use App\Twig\SecurityPolicy\InvoicePolicy;
|
||||
use Symfony\Bridge\Twig\Extension\TranslationExtension;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Twig\Environment;
|
||||
@@ -64,7 +64,7 @@ abstract class AbstractTwigRenderer implements RendererInterface
|
||||
}
|
||||
|
||||
if (!$twig->hasExtension(SandboxExtension::class)) {
|
||||
$twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
$twig->addExtension(new SandboxExtension(new InvoicePolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $twig->getExtension(SandboxExtension::class);
|
||||
|
||||
@@ -341,7 +341,7 @@ class ActivityRepository extends EntityRepository
|
||||
/**
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countActivitiesForQuery(ActivityQuery $query): int
|
||||
private function countActivitiesForQuery(ActivityQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
|
||||
@@ -226,10 +226,9 @@ class CustomerRepository extends EntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME make this private and remove the widget that this currently uses
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countCustomersForQuery(CustomerQuery $query): int
|
||||
private function countCustomersForQuery(CustomerQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
|
||||
@@ -331,7 +331,7 @@ class ProjectRepository extends EntityRepository
|
||||
/**
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countProjectsForQuery(ProjectQuery $query): int
|
||||
private function countProjectsForQuery(ProjectQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
|
||||
@@ -148,7 +148,7 @@ class TimesheetRepository extends EntityRepository
|
||||
/**
|
||||
* @return array<Revenue>
|
||||
*/
|
||||
public function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?User $user): array
|
||||
public function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?User $user = null): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -302,43 +302,6 @@ class TimesheetRepository extends EntityRepository
|
||||
return $this->getHydratedResultsByQuery($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countActiveEntries(?User $user = null): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select($qb->expr()->count('t'))
|
||||
->from(Timesheet::class, 't')
|
||||
->andWhere($qb->expr()->isNull('t.end'))
|
||||
;
|
||||
|
||||
if (null !== $user) {
|
||||
$qb
|
||||
->andWhere('t.user = :user')
|
||||
->groupBy('t.user')
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countActiveUsers(?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?bool $billable = null): int
|
||||
{
|
||||
$tmp = $this->queryTimeRange('COUNT(DISTINCT(t.user))', $begin, $end, null, $billable);
|
||||
|
||||
if (!is_numeric($tmp)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $tmp; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* This method causes me some headaches ...
|
||||
*
|
||||
|
||||
@@ -348,7 +348,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
/**
|
||||
* @return int<0, max>
|
||||
*/
|
||||
public function countUsersForQuery(UserQuery $query): int
|
||||
private function countUsersForQuery(UserQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
|
||||
43
src/Timesheet/DateRangeEnum.php
Normal file
43
src/Timesheet/DateRangeEnum.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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';
|
||||
|
||||
public function getColorName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
DateRangeEnum::TODAY => 'green',
|
||||
DateRangeEnum::WEEK => 'blue',
|
||||
DateRangeEnum::MONTH => 'purple',
|
||||
DateRangeEnum::FINANCIAL, DateRangeEnum::YEAR => 'yellow',
|
||||
DateRangeEnum::TOTAL => 'red',
|
||||
};
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return match ($this) {
|
||||
DateRangeEnum::TODAY => 'daterangepicker.today',
|
||||
DateRangeEnum::WEEK => 'daterangepicker.thisWeek',
|
||||
DateRangeEnum::MONTH => 'daterangepicker.thisMonth',
|
||||
DateRangeEnum::YEAR => 'daterangepicker.thisYear',
|
||||
DateRangeEnum::FINANCIAL => 'daterangepicker.thisFinancialYear',
|
||||
DateRangeEnum::TOTAL => 'daterangepicker.allTime',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Replaces the Symfony default AppVariable, which exposes security relevant information.
|
||||
*/
|
||||
final class AppVariable
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly TokenStorageInterface $tokenStorage
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
return $this->requestStack->getMainRequest()?->getLocale() ?? 'en';
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->tokenStorage->getToken()?->getUser();
|
||||
}
|
||||
|
||||
public function getCurrent_route(): ?string
|
||||
{
|
||||
$route = $this->requestStack->getCurrentRequest()?->attributes->get('_route');
|
||||
if (!\is_string($route)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string[]>
|
||||
*/
|
||||
public function getFlashes(): array
|
||||
{
|
||||
$session = $this->getSession();
|
||||
|
||||
if (!$session instanceof FlashBagAwareSessionInterface) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $session->getFlashBag()->all();
|
||||
}
|
||||
|
||||
private function getSession(): ?SessionInterface
|
||||
{
|
||||
try {
|
||||
return $this->requestStack->getSession();
|
||||
} catch (SessionNotFoundException) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request should not be exposed under any circumstance to the frontend.
|
||||
* This here is added as fallback for old customer templates still using this object.
|
||||
*
|
||||
* @return array{locale: string}
|
||||
*/
|
||||
public function getRequest(): array
|
||||
{
|
||||
return [
|
||||
'locale' => $this->getLocale(),
|
||||
'pathinfo' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,33 +7,37 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\Runtime;
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Entity\Bookmark;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookmarkRepository;
|
||||
use App\Utils\ProfileManager;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class DatatableExtensions implements RuntimeExtensionInterface
|
||||
final class DatatableExtensions extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, array<string, string|bool>>>
|
||||
*/
|
||||
private array $dataTables = [];
|
||||
/** @var array<string, string> */
|
||||
private array $tableNames = [];
|
||||
private ?string $prefix = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookmarkRepository $bookmarkRepository,
|
||||
private readonly ProfileManager $profileManager,
|
||||
private readonly RequestStack $requestStack,
|
||||
)
|
||||
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('initialize_datatable', [$this, 'initializeDatatable']),
|
||||
new TwigFunction('datatable_column_class', [$this, 'getDatatableColumnClass']),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDatatableName(string $dataTable): string
|
||||
{
|
||||
if (!\array_key_exists($dataTable, $this->tableNames)) {
|
||||
@@ -43,10 +47,10 @@ final class DatatableExtensions implements RuntimeExtensionInterface
|
||||
return $this->tableNames[$dataTable];
|
||||
}
|
||||
|
||||
public function initializeDatatable(User $user, string $dataTable, array $defaultColumns): array
|
||||
public function initializeDatatable(User $user, Session $session, string $dataTable, array $defaultColumns): array
|
||||
{
|
||||
if ($this->prefix === null) {
|
||||
$this->prefix = $this->profileManager->getProfileFromSession($this->requestStack->getSession());
|
||||
$this->prefix = $this->profileManager->getProfileFromSession($session);
|
||||
$dataTable = $this->getDatatableName($dataTable);
|
||||
}
|
||||
|
||||
@@ -93,10 +97,7 @@ final class DatatableExtensions implements RuntimeExtensionInterface
|
||||
$this->dataTables[$dataTable] = $columns;
|
||||
}
|
||||
|
||||
return [
|
||||
'columns' => $this->dataTables[$dataTable],
|
||||
'profile' => $this->prefix,
|
||||
];
|
||||
return $this->dataTables[$dataTable];
|
||||
}
|
||||
|
||||
public function getDatatableColumnClass(string $dataTable, string $column): string
|
||||
@@ -168,7 +169,7 @@ final class DatatableExtensions implements RuntimeExtensionInterface
|
||||
return $newClass;
|
||||
}
|
||||
|
||||
private function getClass(mixed $class): string
|
||||
private function getClass($class): string
|
||||
{
|
||||
if (\is_array($class)) {
|
||||
if (!\array_key_exists('class', $class)) {
|
||||
@@ -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),
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Twig\Runtime\DatatableExtensions;
|
||||
use App\Twig\Runtime\EncoreExtension;
|
||||
use App\Twig\Runtime\MarkdownExtension;
|
||||
use App\Twig\Runtime\MenuExtension;
|
||||
@@ -41,8 +40,6 @@ final class RuntimeExtensions extends AbstractExtension
|
||||
new TwigFunction('icon', [RuntimeExtension::class, 'createIcon'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('qr_code_data_uri', [QrCodeExtension::class, 'qrCodeDataUriFunction']),
|
||||
new TwigFunction('user_shortcuts', [MenuExtension::class, 'getUserShortcuts']),
|
||||
new TwigFunction('initialize_datatable', [DatatableExtensions::class, 'initializeDatatable']),
|
||||
new TwigFunction('datatable_column_class', [DatatableExtensions::class, 'getDatatableColumnClass']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
44
src/Twig/SecurityPolicy/ChainPolicy.php
Normal file
44
src/Twig/SecurityPolicy/ChainPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
final class ChainPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var array<SecurityPolicyInterface> */
|
||||
private array $policies = [];
|
||||
|
||||
public function addPolicy(SecurityPolicyInterface $policy): void
|
||||
{
|
||||
$this->policies[] = $policy;
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
80
src/Twig/SecurityPolicy/DefaultPolicy.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Bridge\Twig\AppVariable;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\ServerBag;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
*/
|
||||
final class DefaultPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof ServerBag) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof SessionInterface) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
|
||||
}
|
||||
|
||||
$lcm = strtolower($method);
|
||||
|
||||
if ($obj instanceof PdfContext) {
|
||||
if ($lcm !== 'setoption') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && !str_starts_with($lcm, 'get') && $lcm !== '__tostring') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof Request) {
|
||||
if (!str_starts_with($lcm, 'get')) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof AppVariable) {
|
||||
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
}
|
||||
}
|
||||
41
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
41
src/Twig/SecurityPolicy/ExportPolicy.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig export templates.
|
||||
*/
|
||||
final class ExportPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
230
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
230
src/Twig/SecurityPolicy/InvoicePolicy.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityPolicy;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig invoice templates.
|
||||
*/
|
||||
final class InvoicePolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $default;
|
||||
private SecurityPolicyInterface $security;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->default = new DefaultPolicy();
|
||||
$this->security = new SecurityPolicy(
|
||||
['block', 'if', 'for', 'set', 'extends', 'import'],
|
||||
[
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
|
||||
// Twig 4.0 disallows "use" and "extends" - do not allow them!
|
||||
// https://github.com/twigphp/Twig/pull/4244/files
|
||||
|
||||
// formatting filters
|
||||
'date',
|
||||
'date_modify',
|
||||
'format',
|
||||
'replace',
|
||||
'number_format',
|
||||
'abs',
|
||||
'round',
|
||||
|
||||
// encoding
|
||||
'url_encode',
|
||||
'json_encode',
|
||||
'convert_encoding',
|
||||
|
||||
// string filters
|
||||
'title',
|
||||
'capitalize',
|
||||
'upper',
|
||||
'lower',
|
||||
'striptags',
|
||||
'trim',
|
||||
'nl2br',
|
||||
|
||||
// array helpers
|
||||
'join',
|
||||
'split',
|
||||
'sort',
|
||||
'merge',
|
||||
'batch',
|
||||
'column',
|
||||
'filter',
|
||||
'map',
|
||||
'reduce',
|
||||
|
||||
// string/array filters
|
||||
'reverse',
|
||||
'length',
|
||||
'slice',
|
||||
'first',
|
||||
'last',
|
||||
|
||||
// iteration and runtime
|
||||
'default',
|
||||
'keys',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/EscaperExtension.php
|
||||
'escape',
|
||||
'e',
|
||||
'raw',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
'trans',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/string-extra/StringExtension.php
|
||||
'u',
|
||||
'slug',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/intl-extra/IntlExtension.php
|
||||
'country_name',
|
||||
'currency_name',
|
||||
'currency_symbol',
|
||||
'language_name',
|
||||
'locale_name',
|
||||
'timezone_name',
|
||||
'format_currency',
|
||||
'format_number',
|
||||
'format_decimal_number',
|
||||
'format_currency_number',
|
||||
'format_percent_number',
|
||||
'format_scientific_number',
|
||||
'format_spellout_number',
|
||||
'format_ordinal_number',
|
||||
'format_duration_number',
|
||||
'format_datetime',
|
||||
'format_date',
|
||||
'format_time',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/LocaleFormatExtensions.php
|
||||
'month_name',
|
||||
'day_name',
|
||||
'date_short',
|
||||
'date_time',
|
||||
'date_full',
|
||||
'date_format',
|
||||
'date_weekday',
|
||||
'time',
|
||||
'duration',
|
||||
'duration_decimal',
|
||||
'money',
|
||||
'amount',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/RuntimeExtensions.php
|
||||
'md2html',
|
||||
'desc2html',
|
||||
'comment2html',
|
||||
'comment1line',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/Extensions.php
|
||||
'multiline_indent',
|
||||
'color',
|
||||
'font_contrast',
|
||||
'default_color',
|
||||
'nl2str',
|
||||
],
|
||||
[
|
||||
PdfContext::class => ['setoption'],
|
||||
InvoiceModel::class => ['toarray'],
|
||||
],
|
||||
[], // properties
|
||||
[
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
'max',
|
||||
'min',
|
||||
'range',
|
||||
'constant',
|
||||
'cycle',
|
||||
'random',
|
||||
'date',
|
||||
'asset',
|
||||
'range',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
't',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
|
||||
'encore_entry_css_source',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/AssetExtension.php
|
||||
'asset',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
|
||||
'is_granted',
|
||||
|
||||
// =================================================================
|
||||
// Twig/RuntimeExtensions.php
|
||||
'qr_code_data_uri',
|
||||
|
||||
// =================================================================
|
||||
// Twig/Configuration.php
|
||||
'config',
|
||||
|
||||
// =================================================================
|
||||
// Twig/LocaleFormatExtensions.php
|
||||
'create_date',
|
||||
'month_names',
|
||||
'locale_format',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->default->checkSecurity($tags, $filters, $functions);
|
||||
$this->security->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { // @phpstan-ignore instanceof.internalClass
|
||||
return;
|
||||
}
|
||||
|
||||
$this->default->checkMethodAllowed($obj, $method);
|
||||
|
||||
$lm = strtolower($method);
|
||||
|
||||
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has') || $lm === '__tostring') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->security->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->default->checkPropertyAllowed($obj, $property);
|
||||
$this->security->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Bridge\Twig\AppVariable;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\ServerBag;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
use Twig\Sandbox\SecurityNotAllowedFilterError;
|
||||
use Twig\Sandbox\SecurityNotAllowedFunctionError;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityNotAllowedTagError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
*/
|
||||
final class StrictPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $allowedTags = ['block', 'if', 'for', 'set', 'macro', 'import', 'extends', 'from'];
|
||||
/** @var string[] */
|
||||
private array $allowedFunctions = [
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
'max', 'min', 'range', 'constant', 'cycle', 'random', 'date',
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
't',
|
||||
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
|
||||
'encore_entry_css_source', 'encore_entry_link_tags', 'encore_entry_script_tags',
|
||||
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
|
||||
'is_granted',
|
||||
// Twig/RuntimeExtensions.php
|
||||
'qr_code_data_uri',
|
||||
// Twig/Configuration.php
|
||||
'config',
|
||||
// Twig/LocaleFormatExtensions.php
|
||||
'create_date', 'month_names', 'locale_format',
|
||||
// Twig/Extensions.php
|
||||
'class_name'
|
||||
];
|
||||
/** @var string[] */
|
||||
private array $allowedFilters = [
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
// formatting filters
|
||||
'date', 'date_modify', 'format', 'replace', 'number_format', 'abs', 'round',
|
||||
// encoding
|
||||
'url_encode', 'json_encode',
|
||||
// string filters
|
||||
'title', 'capitalize', 'upper', 'lower', 'striptags', 'trim', 'nl2br', 'spaceless',
|
||||
// array helpers
|
||||
'join', 'split', 'sort', 'merge', 'column', 'filter', 'map',
|
||||
// string/array filters
|
||||
'reverse', 'length', 'slice', 'first', 'last',
|
||||
// iteration and runtime
|
||||
'default', 'keys',
|
||||
// vendor/twig/twig/src/Extension/EscaperExtension.php
|
||||
'escape', 'e', 'raw',
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
'trans',
|
||||
// vendor/twig/string-extra/StringExtension.php
|
||||
'u', 'slug',
|
||||
// vendor/twig/intl-extra/IntlExtension.php
|
||||
'country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name',
|
||||
'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number',
|
||||
'format_duration_number', 'format_datetime', 'format_date', 'format_time',
|
||||
// src/Twig/LocaleFormatExtensions.php
|
||||
'month_name', 'day_name', 'date_short', 'date_time', 'date_full', 'date_format',
|
||||
'date_weekday', 'time', 'duration', 'duration_decimal', 'money', 'amount',
|
||||
// src/Twig/RuntimeExtensions.php
|
||||
'md2html', 'desc2html', 'comment2html', 'comment1line',
|
||||
// src/Twig/Extensions.php
|
||||
'multiline_indent', 'color', 'nl2str'
|
||||
];
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
if (!\in_array($tag, $this->allowedTags, true)) {
|
||||
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if (!\in_array($filter, $this->allowedFilters, true)) {
|
||||
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($functions as $function) {
|
||||
if (!\in_array($function, $this->allowedFunctions, true)) {
|
||||
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof UnicodeString) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof ServerBag) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof SessionInterface) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
|
||||
}
|
||||
|
||||
$lcm = strtolower($method);
|
||||
|
||||
if ($obj instanceof PdfContext) {
|
||||
if ($lcm !== 'setoption') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof MetaTableTypeInterface && $lcm === 'merge') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof Request) {
|
||||
if (!str_starts_with($lcm, 'get')) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof AppVariable) {
|
||||
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!str_starts_with($lcm, 'get') && !str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && $lcm !== '__tostring') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,15 @@
|
||||
namespace App\Utils;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
|
||||
final class ProfileManager
|
||||
{
|
||||
public const string SESSION_PROFILE = 'datatable_profile';
|
||||
public const string PROFILE_DESKTOP = 'desktop';
|
||||
public const string PROFILE_MOBILE = 'mobile';
|
||||
public const string COOKIE_PROFILE = 'K2P';
|
||||
public const SESSION_PROFILE = 'datatable_profile';
|
||||
public const PROFILE_DESKTOP = 'desktop';
|
||||
public const PROFILE_MOBILE = 'mobile';
|
||||
public const COOKIE_PROFILE = 'K2P';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -39,6 +40,9 @@ final class ProfileManager
|
||||
|
||||
/**
|
||||
* Always returns a valid profile name (default: desktop).
|
||||
*
|
||||
* @param string $profile
|
||||
* @return string
|
||||
*/
|
||||
public function getProfile(string $profile): string
|
||||
{
|
||||
@@ -60,6 +64,9 @@ final class ProfileManager
|
||||
|
||||
/**
|
||||
* Always returns a valid profile name (default: desktop).
|
||||
*
|
||||
* @param Request $request
|
||||
* @return string
|
||||
*/
|
||||
public function getProfileFromCookie(Request $request): string
|
||||
{
|
||||
@@ -70,8 +77,11 @@ final class ProfileManager
|
||||
|
||||
/**
|
||||
* Always returns a valid profile name (default: desktop).
|
||||
*
|
||||
* @param Session $session
|
||||
* @return string
|
||||
*/
|
||||
public function getProfileFromSession(SessionInterface $session): string
|
||||
public function getProfileFromSession(Session $session): string
|
||||
{
|
||||
$profile = $session->get(self::SESSION_PROFILE, self::PROFILE_DESKTOP);
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ final class ReleaseVersion
|
||||
/**
|
||||
* Get all releases from GitHub.
|
||||
*
|
||||
* @return array<string, array{'version': non-empty-string, 'date': \DateTimeInterface, 'url': non-empty-string, 'download': non-empty-string, 'content': string}>
|
||||
* @throws \Exception
|
||||
* @return array|null
|
||||
* @return array<string, array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}>
|
||||
* @return array
|
||||
*/
|
||||
private function getReleasesFromGithub(): array
|
||||
{
|
||||
@@ -38,24 +41,19 @@ final class ReleaseVersion
|
||||
$context = stream_context_create($opts);
|
||||
|
||||
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
|
||||
if ($releases === false) {
|
||||
throw new \Exception('Could not load releases from GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
}
|
||||
/** @var array<string, array{url: non-empty-string, html_url: non-empty-string, tag_name: non-empty-string, name: non-empty-string, draft: bool, immutable: bool, prerelease: bool, created_at: non-empty-string, updated_at: non-empty-string, published_at: non-empty-string, zipball_url: non-empty-string, body: string}> $releases */
|
||||
$releases = json_decode($releases, true);
|
||||
$releases = json_decode($releases);
|
||||
|
||||
if ($releases === false) {
|
||||
throw new \Exception('Failed parsing release found at GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
if (!isset($releases[0])) {
|
||||
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
}
|
||||
|
||||
$parsed = [];
|
||||
foreach ($releases as $release) {
|
||||
if ($release['draft'] || $release['prerelease']) {
|
||||
if ($release->draft || $release->prerelease) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$normalized = $versionParser->normalize($release['tag_name']);
|
||||
$normalized = $versionParser->normalize($release->tag_name);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
continue;
|
||||
}
|
||||
@@ -64,19 +62,19 @@ final class ReleaseVersion
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $release['published_at'];
|
||||
$date = $release->published_at;
|
||||
try {
|
||||
$date = new \DateTimeImmutable($date);
|
||||
} catch (\Exception $ex) {
|
||||
continue;
|
||||
// can be ignored, we return a string
|
||||
}
|
||||
|
||||
$parsed[$normalized] = [
|
||||
'version' => $release['tag_name'],
|
||||
'version' => $release->tag_name,
|
||||
'date' => $date,
|
||||
'url' => $release['html_url'],
|
||||
'download' => $release['zipball_url'],
|
||||
'content' => $release['body'],
|
||||
'url' => $release->html_url,
|
||||
'download' => $release->zipball_url,
|
||||
'content' => $release->body,
|
||||
];
|
||||
}
|
||||
$versions = Semver::rsort(array_keys($parsed));
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
abstract class AbstractActiveUsers extends AbstractWidgetType
|
||||
{
|
||||
/**
|
||||
* @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([
|
||||
'icon' => 'users',
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.' . lcfirst($this->getId());
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['ROLE_TEAMLEAD'];
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter.html.twig';
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Event\RevenueStatisticEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
abstract class AbstractAmountPeriod extends AbstractWidget
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository, private EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.' . lcfirst($this->getId());
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter-money.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
|
||||
{
|
||||
return array_merge([
|
||||
'icon' => 'money',
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
* @return array<string, float>
|
||||
*/
|
||||
protected function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, array $options = []): array
|
||||
{
|
||||
$data = $this->repository->getRevenue($begin, $end, null);
|
||||
|
||||
$event = new RevenueStatisticEvent($begin, $end);
|
||||
foreach ($data as $row) {
|
||||
$event->addRevenue($row->getCurrency(), $row->getAmount());
|
||||
}
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getRevenue();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
abstract class AbstractCounterDuration extends AbstractWidgetType
|
||||
{
|
||||
/**
|
||||
* @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([
|
||||
'icon' => 'duration',
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.' . lcfirst($this->getId());
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter-duration.html.twig';
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
|
||||
abstract class AbstractCounterYear extends AbstractWidgetType
|
||||
{
|
||||
private bool $isFinancialYear = false;
|
||||
|
||||
public function __construct(private SystemConfiguration $systemConfiguration)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
$begin = $this->createDate('01 january this year 00:00:00');
|
||||
$end = $this->createDate('31 december this year 23:59:59');
|
||||
|
||||
if (null !== ($financialYear = $this->systemConfiguration->getFinancialYearStart())) {
|
||||
$factory = new DateTimeFactory($this->getTimezone());
|
||||
$begin = $factory->createStartOfFinancialYear($financialYear);
|
||||
$end = $factory->createEndOfFinancialYear($begin);
|
||||
$this->isFinancialYear = true;
|
||||
}
|
||||
|
||||
return $this->getYearData($begin, $end, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
abstract protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed;
|
||||
|
||||
abstract protected function getFinancialYearTitle(): string;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
if ($this->isFinancialYear) {
|
||||
return $this->getFinancialYearTitle();
|
||||
}
|
||||
|
||||
return 'stats.' . lcfirst($this->getId());
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter.html.twig';
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Event\UserRevenueStatisticEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
abstract class AbstractUserRevenuePeriod extends AbstractWidget
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository, private EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.' . lcfirst($this->getId());
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter-money.html.twig';
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_rate_own_timesheet'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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([
|
||||
'icon' => 'money',
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
* @return array<string, float>
|
||||
*/
|
||||
protected function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, array $options = []): array
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$data = $this->repository->getRevenue($begin, $end, $user);
|
||||
|
||||
$event = new UserRevenueStatisticEvent($user, $begin, $end);
|
||||
foreach ($data as $row) {
|
||||
$event->addRevenue($row->getCurrency(), $row->getAmount());
|
||||
}
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getRevenue();
|
||||
}
|
||||
}
|
||||
@@ -10,37 +10,32 @@
|
||||
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 $dateRangeEnum->getColorName();
|
||||
}
|
||||
|
||||
public function getDateRangeTitle(DateRangeEnum $dateRangeEnum): string
|
||||
{
|
||||
return $dateRangeEnum->getTitle();
|
||||
}
|
||||
|
||||
public function getTranslationDomain(): string
|
||||
{
|
||||
return 'messages';
|
||||
}
|
||||
|
||||
public function hasForm(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getForm(): ?Form
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getHeight(): int
|
||||
{
|
||||
return WidgetInterface::HEIGHT_SMALL;
|
||||
}
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return WidgetInterface::WIDTH_HALF;
|
||||
return WidgetInterface::WIDTH_NORMAL;
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
@@ -58,22 +53,17 @@ abstract class AbstractWidget implements WidgetInterface
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @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
|
||||
@@ -81,55 +71,8 @@ abstract class AbstractWidget implements WidgetInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function createDate(string $date): \DateTime
|
||||
{
|
||||
return new \DateTime($date, $this->getTimezone());
|
||||
}
|
||||
|
||||
protected function createMonthStartDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('first day of this month 00:00:00');
|
||||
}
|
||||
|
||||
protected function createMonthEndDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('last day of this month 23:59:59');
|
||||
}
|
||||
|
||||
protected function createWeekStartDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('monday this week 00:00:00');
|
||||
}
|
||||
|
||||
protected function createWeekEndDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('sunday this week 23:59:59');
|
||||
}
|
||||
|
||||
protected function createTodayStartDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('00:00:00');
|
||||
}
|
||||
|
||||
protected function createTodayEndDate(): \DateTime
|
||||
{
|
||||
return $this->createDate('23:59:59');
|
||||
}
|
||||
|
||||
public function getTimezone(): \DateTimeZone
|
||||
{
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->user) {
|
||||
$timezone = $this->user->getTimezone();
|
||||
}
|
||||
|
||||
return new \DateTimeZone($timezone);
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
$name = (new \ReflectionClass($this))->getShortName();
|
||||
|
||||
return \sprintf('widget/widget-%s.html.twig', strtolower($name));
|
||||
return \sprintf('widget/widget-%s.html.twig', strtolower($this->getId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,39 +9,11 @@
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
/**
|
||||
* @deprecated since 3.0: use AbstractWidget instead
|
||||
*/
|
||||
abstract class AbstractWidgetType extends AbstractWidget
|
||||
{
|
||||
private ?string $id = null;
|
||||
private string $title = '';
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $permissions = [];
|
||||
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
if (!empty($this->id)) {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
return (new \ReflectionClass($this))->getShortName();
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setOptions(array $options): void
|
||||
{
|
||||
foreach ($options as $key => $value) {
|
||||
@@ -49,13 +21,8 @@ abstract class AbstractWidgetType extends AbstractWidget
|
||||
}
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function setPermissions(array $permissions): void
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
return (new \ReflectionClass($this))->getShortName(); // @phpstan-ignore-line
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveTimesheets extends AbstractWidgetType
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
// we can safely assume that the user can see
|
||||
$route = 'admin_timesheet';
|
||||
|
||||
return array_merge([
|
||||
'color' => WidgetInterface::COLOR_TOTAL,
|
||||
'icon' => 'duration',
|
||||
'route' => $route,
|
||||
'routeOptions' => ['state' => TimesheetQuery::STATE_RUNNING],
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
// if you ever loosen that check, make sure that the above link is probably removed
|
||||
return ['view_all_data'];
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeRecordings';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.activeRecordings';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveEntries();
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-counter.html.twig';
|
||||
}
|
||||
}
|
||||
124
src/Widget/Type/ActiveUsers.php
Normal file
124
src/Widget/Type/ActiveUsers.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Model\Statistic\DateRange;
|
||||
use App\Model\Statistic\StatisticDate;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
|
||||
final class ActiveUsers extends AbstractWidget
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly SystemConfiguration $systemConfiguration
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
private function getDataForTimeRange(?\DateTimeInterface $begin, ?\DateTimeInterface $end, bool $grouped = false): array|int // @phpstan-ignore missingType.iterableValue
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
|
||||
$qb
|
||||
->select('COUNT(DISTINCT(t.user))')
|
||||
->andWhere($qb->expr()->gte('t.begin', ':from'))
|
||||
->setParameter('from', $begin)
|
||||
->andWhere($qb->expr()->lte('t.end', ':to'))
|
||||
->setParameter('to', $end)
|
||||
;
|
||||
|
||||
if ($grouped) {
|
||||
$qb
|
||||
->addSelect('DATE(t.date)')
|
||||
->addGroupBy('t.date')
|
||||
;
|
||||
|
||||
return $qb->getQuery()->getArrayResult();
|
||||
}
|
||||
|
||||
$tmp = $qb->getQuery()->getSingleScalarResult();
|
||||
|
||||
if (!is_numeric($tmp)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $tmp;
|
||||
}
|
||||
|
||||
private function countActiveEntries(): int
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
$qb->select($qb->expr()->count('t'))->where($qb->expr()->isNull('t.end'));
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|float> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
$financialYear = null;
|
||||
$factory = DateTimeFactory::createByUser($this->getUser());
|
||||
|
||||
if (null !== ($yearConfig = $this->systemConfiguration->getFinancialYearStart())) {
|
||||
$begin = $factory->createStartOfFinancialYear($yearConfig);
|
||||
$end = $factory->createEndOfFinancialYear($begin);
|
||||
$financialYear = $this->getDataForTimeRange($begin, $end);
|
||||
}
|
||||
|
||||
$end = $factory->create('23:59:59');
|
||||
$begin = $end->modify('-6 days 00:00:00');
|
||||
/** @var array<array{1: int, 2: string}> $tmp */
|
||||
$tmp = $this->getDataForTimeRange($begin, $end, true);
|
||||
$days = new DateRange($begin, $end);
|
||||
foreach ($tmp as $item) {
|
||||
$d = new StatisticDate(new \DateTimeImmutable($item[2]));
|
||||
$d->setAmount($item[1]);
|
||||
$days->setDate($d);
|
||||
}
|
||||
|
||||
$today = $factory->create();
|
||||
$beginOfDay = $today->modify('00:00:00');
|
||||
$endOfDay = $today->modify('23:59:59');
|
||||
|
||||
return [
|
||||
'days' => $days->getDays(),
|
||||
'now' => $this->countActiveEntries(),
|
||||
'today' => $this->getDataForTimeRange($beginOfDay, $endOfDay),
|
||||
'week' => $this->getDataForTimeRange($factory->getStartOfWeek(), $factory->getEndOfWeek()),
|
||||
'month' => $this->getDataForTimeRange($factory->getStartOfMonth(), $factory->getEndOfMonth()),
|
||||
'year' => $this->getDataForTimeRange($factory->createStartOfYear(), $factory->createEndOfYear()),
|
||||
'financial' => $financialYear,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'stats.activeUsers';
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['ROLE_TEAMLEAD'];
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'widget/widget-active-users.html.twig';
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'ActiveUsers';
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveUsersMonth extends AbstractActiveUsers
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_MONTH], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeUsersMonth';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveUsers($this->createMonthStartDate(), $this->createMonthEndDate(), null);
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveUsersToday extends AbstractActiveUsers
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_TODAY], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeUsersToday';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveUsers($this->createTodayStartDate(), $this->createTodayEndDate(), null);
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveUsersTotal extends AbstractActiveUsers
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_TOTAL], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeUsersTotal';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveUsers(null, null, null);
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveUsersWeek extends AbstractActiveUsers
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_WEEK], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeUsersWeek';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveUsers($this->createWeekStartDate(), $this->createWeekEndDate(), null);
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class ActiveUsersYear extends AbstractCounterYear
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository, SystemConfiguration $systemConfiguration)
|
||||
{
|
||||
parent::__construct($systemConfiguration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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([
|
||||
'icon' => 'users',
|
||||
'color' => WidgetInterface::COLOR_YEAR,
|
||||
], parent::getOptions($options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed
|
||||
{
|
||||
try {
|
||||
return $this->repository->countActiveUsers($begin, $end, null);
|
||||
} catch (\Exception $ex) {
|
||||
throw new WidgetException(
|
||||
'Failed loading widget data: ' . $ex->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getFinancialYearTitle(): string
|
||||
{
|
||||
return 'stats.activeUsersFinancialYear';
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['ROLE_TEAMLEAD'];
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'activeUsersYear';
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class AmountMonth extends AbstractAmountPeriod
|
||||
{
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_MONTH], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'AmountMonth';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
return $this->getRevenue($this->createMonthStartDate(), $this->createMonthEndDate(), $options);
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_all_data'];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class AmountToday extends AbstractAmountPeriod
|
||||
{
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_TODAY], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'AmountToday';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
return $this->getRevenue($this->createTodayStartDate(), $this->createTodayEndDate(), $options);
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_all_data'];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class AmountTotal extends AbstractAmountPeriod
|
||||
{
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_TOTAL], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'amountTotal';
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_all_data'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
return $this->getRevenue(null, null, $options);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Widget\Type;
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
|
||||
final class AmountWeek extends AbstractAmountPeriod
|
||||
{
|
||||
/**
|
||||
* @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(['color' => WidgetInterface::COLOR_WEEK], parent::getOptions($options));
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'AmountWeek';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
*/
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
return $this->getRevenue($this->createWeekStartDate(), $this->createWeekEndDate(), $options);
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_all_data'];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user