Compare commits

..

22 Commits

Author SHA1 Message Date
Kevin Papst
ff62c7da87 code styles 2026-01-11 21:06:26 +01:00
Kevin Papst
fd94f033c8 remove gridstack dependency 2026-01-11 12:58:38 +01:00
Kevin Papst
f2cfe593bf phpstan 2026-01-11 12:31:43 +01:00
Kevin Papst
5dc054dc64 Merge branch 'dev' into dashboard 2026-01-11 12:13:55 +01:00
Kevin Papst
960d4f09fa fix deprecations 2026-01-03 00:19:54 +01:00
Kevin Papst
aaa58e6d20 fix deprecations 2026-01-03 00:09:59 +01:00
Kevin Papst
c92a8949b2 merge main 2026-01-02 23:59:45 +01:00
Kevin Papst
78854b7807 merge main 2026-01-02 23:56:59 +01:00
Kevin Papst
348da17347 merge main 2026-01-02 23:53:12 +01:00
Kevin Papst
f0c72d8b76 rebuild assets 2026-01-02 23:52:51 +01:00
Kevin Papst
b19feb400e rebuild assets 2026-01-02 23:52:41 +01:00
Kevin Papst
b5edb6a560 merge main 2026-01-02 23:51:06 +01:00
Kevin Papst
996e440c46 fix test 2025-03-12 18:23:15 +01:00
Kevin Papst
9bd0d8baf4 codestyle and tests 2025-03-12 18:07:24 +01:00
Kevin Papst
f74425fec6 improved widget experience with sortable and inline options 2025-03-12 17:42:07 +01:00
Kevin Papst
8a51b9c47a replace widget specific with generic icons 2025-03-12 16:46:26 +01:00
Kevin Papst
9051fd1e1d added sortablejs 2025-03-12 16:45:05 +01:00
Kevin Papst
c61638eba4 removed gridstack, used for dashboard edit before 2025-03-07 03:21:03 +01:00
Kevin Papst
a836051dff removed unused widget templates 2025-03-03 16:42:37 +01:00
Kevin Papst
50a3cbbbea added new simple pagination method 2025-03-03 16:42:22 +01:00
Kevin Papst
75ee0b7b99 build assets 2025-02-27 17:58:42 +01:00
Kevin Papst
3572a0fb62 refactor all widgets to unified 2-column setup 2025-02-27 17:57:52 +01:00
293 changed files with 4135 additions and 8635 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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;

View File

@@ -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

View File

@@ -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') {

View File

@@ -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)

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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);
});
}

View File

@@ -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
View File

@@ -0,0 +1,7 @@
div.chart-container {
position: relative;
canvas.chartjs {
width: 100%;
height: 300px;
}
}

View File

@@ -0,0 +1,3 @@
section.dashboard {
.widget { max-height: 600px; }
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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:

View File

@@ -243,7 +243,3 @@ services:
App\Validator\Constraints\QuickEntryTimesheetValidator:
arguments: ['%kimai.validator_timesheet%']
App\Twig\AppVariable:
tags:
- {name: twig.global, alias: app}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,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"
}

View File

@@ -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",

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -17,12 +17,13 @@ use App\Utils\PageSetup;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Json;
/**
* Dashboard controller for the admin area.
@@ -33,6 +34,7 @@ final class DashboardController extends AbstractController
{
public const BOOKMARK_TYPE = 'dashboard';
public const BOOKMARK_NAME = 'default';
/**
* @var WidgetInterface[]|null
*/
@@ -47,9 +49,7 @@ final class DashboardController extends AbstractController
}
/**
* @param User $user
* @return array<WidgetInterface>
* @throws \Exception
*/
private function getAllAvailableWidgets(User $user): array
{
@@ -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(),
]);
}
}

View File

@@ -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(),

View File

@@ -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', [

View File

@@ -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;

View File

@@ -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;

View File

@@ -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]

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -299,7 +299,7 @@ class InvoiceTemplate implements EntityWithMetaFields
}
/**
* @return Collection<int, InvoiceTemplateMeta>
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
{
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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();
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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']);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ...
*

View File

@@ -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

View 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',
};
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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)) {

View File

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

View File

@@ -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']),
];
}

View 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);
}
}
}

View 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
{
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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);
}
}

View 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);
}
}

View File

@@ -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
{
}
}

View File

@@ -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);

View File

@@ -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));

View File

@@ -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';
}
}

View File

@@ -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();
}
}

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -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();
}
}

View File

@@ -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()));
}
}

View File

@@ -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
}
}

View File

@@ -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';
}
}

View 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';
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -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';
}
}

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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'];
}
}

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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'];
}
}

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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);
}
}

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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