Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad9f58703 | ||
|
|
70b4fbcae8 | ||
|
|
a0601c8cb2 | ||
|
|
5b320bf2ea | ||
|
|
15110f78d8 | ||
|
|
4154de6bd1 | ||
|
|
8094fcf5b5 | ||
|
|
77afa207e0 | ||
|
|
d9ef6dfcad | ||
|
|
ff6918fcab | ||
|
|
f376b5c8a1 | ||
|
|
b00fbd2516 | ||
|
|
d97abc3f57 | ||
|
|
3f184e42c8 | ||
|
|
4a31411d69 | ||
|
|
3925eacf9f | ||
|
|
394f377890 | ||
|
|
e8b0dc4969 | ||
|
|
d429c56687 |
2
.github/workflows/docker.yaml
vendored
2
.github/workflows/docker.yaml
vendored
@@ -60,7 +60,6 @@ jobs:
|
||||
tags: |
|
||||
kimai/kimai2:latest
|
||||
kimai/kimai2:fpm
|
||||
kimai/kimai2:fpm-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
@@ -74,6 +73,7 @@ jobs:
|
||||
target: prod
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
kimai/kimai2:stable
|
||||
kimai/kimai2:apache
|
||||
kimai/kimai2:apache-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
13
README.md
13
README.md
@@ -6,11 +6,9 @@
|
||||
<a href="https://github.com/kimai/kimai/actions"><img alt="CI Status" src="https://github.com/kimai/kimai/actions/workflows/testing.yaml/badge.svg"></a>
|
||||
<a href="https://codecov.io/gh/kimai/kimai"><img alt="Code Coverage" src="https://codecov.io/gh/kimai/kimai/branch/main/graph/badge.svg"></a>
|
||||
<a href="https://packagist.org/packages/kimai/kimai"><img alt="Latest stable version" src="https://poser.pugx.org/kimai/kimai/v/stable"></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html"><img alt="License" src="https://poser.pugx.org/kimai/kimai/license"></a>
|
||||
<a href="https://phpc.social/@kimai" rel="me"><img alt="Mastodon" src="https://img.shields.io/badge/toot-%40kimai-8c8dff"></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Kimai - time-tracker</h1>
|
||||
<h1 align="center">Kimai<br>#1 Open-Source Time-Tracker</h1>
|
||||
|
||||
Kimai is a professional grade time-tracking application, free and open-source.
|
||||
It handles use-cases of freelancers as well as companies with dozens or hundreds of users.
|
||||
@@ -30,7 +28,7 @@ and so much more.
|
||||
### Requirements
|
||||
|
||||
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
|
||||
- MariaDB or MySQL
|
||||
- MariaDB / MySQL: oldest maintained LTS release (MariaDB >= [10.6](https://endoflife.date/mariadb) or MySQL >= [8.4](https://endoflife.date/mysql)) or newer
|
||||
- A webserver and subdomain (subdirectory is not supported)
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
|
||||
|
||||
@@ -71,6 +69,7 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is
|
||||
In case you want to contribute, but you wouldn't know how, here are some suggestions:
|
||||
|
||||
- Spread the word: Please [write a testimonial for our Wall of love](https://love.kimai.org), vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
|
||||
- [Translate Kimai into your language](https://hosted.weblate.org/engage/kimai/), or help to improve the existing translations, many languages look for a contributor
|
||||
- Answer questions: You know the answer to another user's problem? Share your knowledge.
|
||||
- Something can be done better? An essential feature is missing? Create a feature request.
|
||||
- Report bugs makes Kimai better for everyone.
|
||||
@@ -79,6 +78,12 @@ In case you want to contribute, but you wouldn't know how, here are some suggest
|
||||
|
||||
There is one simple rule in our "Code of conduct": Don't be an ass!
|
||||
|
||||
## Follow Kimai
|
||||
|
||||
- Mastodon: [@kimai](https://phpc.social/@kimai)
|
||||
- Youtube: [@kimai_org](https://www.youtube.com/@kimai_org)
|
||||
- LinkedIn: [@kimai-org](https://www.linkedin.com/company/kimai-org/)
|
||||
|
||||
### Credits
|
||||
|
||||
Kimai is based on modern technologies and frameworks such as [PHP](https://www.php.net/),
|
||||
|
||||
@@ -73,6 +73,7 @@ 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']);
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
@@ -90,6 +92,8 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
if (method === 'PATCH') {
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
|
||||
@@ -217,8 +217,6 @@ 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();
|
||||
@@ -259,15 +257,8 @@ 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);
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -277,6 +268,8 @@ 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)
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'body';
|
||||
let container = 'div.page-wrapper';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ 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
|
||||
@@ -197,7 +195,7 @@ export default class KimaiCalendar {
|
||||
if (!this.isKimaiSource(unmountInfo.event)) {
|
||||
return;
|
||||
}
|
||||
const popover = Popover.getInstance(unmountInfo.element);
|
||||
const popover = Popover.getInstance(unmountInfo.el);
|
||||
if (popover !== null) {
|
||||
popover.dispose();
|
||||
}
|
||||
@@ -282,6 +280,7 @@ 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);
|
||||
@@ -324,7 +323,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -334,7 +333,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -703,8 +702,6 @@ 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');
|
||||
|
||||
@@ -716,11 +713,14 @@ 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), () => {
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}, (error) => {
|
||||
eventArg.revert();
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError('action.update.error', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "variables";
|
||||
@import "layout";
|
||||
@import "error-page";
|
||||
@import "print";
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1021;
|
||||
background-color: var(--tblr-backdrop-bg);
|
||||
/*background-color: var(--tblr-backdrop-bg);*/
|
||||
opacity: $modal-backdrop-opacity;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,12 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
33
assets/sass/variables.scss
Normal file
33
assets/sass/variables.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
:root {
|
||||
--kimai-public-holiday: var(--tblr-lime);
|
||||
--kimai-holiday: var(--tblr-green);
|
||||
--kimai-sickness: var(--tblr-yellow);
|
||||
--kimai-time-off: var(--tblr-blue);
|
||||
--kimai-other: var(--tblr-purple);
|
||||
|
||||
--kimai-public-holiday-bg: var(--tblr-lime-lt);
|
||||
--kimai-holiday-bg: var(--tblr-green-lt);
|
||||
--kimai-sickness-bg: var(--tblr-yellow-lt);
|
||||
--kimai-time-off-bg: var(--tblr-blue-lt);
|
||||
--kimai-other-bg: var(--tblr-purple-lt);
|
||||
|
||||
--kimai-unexpected-bg: var(--tblr-pink-lt);
|
||||
--kimai-missing-bg: var(--tblr-pink-lt);
|
||||
--kimai-weekend-bg: var(--tblr-bg-surface-tertiary);
|
||||
}
|
||||
|
||||
.public-holiday { color: var(--kimai-public-holiday); }
|
||||
.holiday { color: var(--kimai-holiday); }
|
||||
.sickness, .sickness-child { color: var(--kimai-sickness); }
|
||||
.time-off { color: var(--kimai-time-off); }
|
||||
.other, .parental, .unpaid-vacation { color: var(--kimai-other); }
|
||||
|
||||
.bg-public-holiday{ background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
|
||||
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
|
||||
.bg-sickness, .bg-sickness-child { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
|
||||
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };
|
||||
.bg-other, .bg-parental, .bg-unpaid-vacation { background-color: var(--kimai-other-bg); --tblr-table-bg: var(--kimai-other-bg); i.fas{ color: var(--kimai-other); } };
|
||||
|
||||
.bg-unexpected { background-color: var(--kimai-unexpected-bg); --tblr-table-bg: var(--kimai-unexpected-bg); };
|
||||
.bg-missing { background-color: var(--kimai-missing-bg); --tblr-table-bg: var(--kimai-missing-bg); };
|
||||
.bg-weekend { background-color: var(--kimai-weekend-bg); --tblr-table-bg: var(--kimai-weekend-bg); };
|
||||
1180
composer.lock
generated
1180
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ tabler:
|
||||
fax: fas fa-fax
|
||||
filter: fas fa-filter
|
||||
help: far fa-question-circle
|
||||
holiday: fas fa-umbrella-beach
|
||||
home: fas fa-home
|
||||
info: fas fa-info-circle
|
||||
import: fas fa-file-import
|
||||
@@ -95,6 +96,7 @@ tabler:
|
||||
ods: fas fa-table
|
||||
off: fas fa-toggle-off
|
||||
on: fas fa-toggle-on
|
||||
other: fas fa-file-alt
|
||||
password: fas fa-key
|
||||
pause: fas fa-pause
|
||||
pause-small: far fa-pause-circle
|
||||
@@ -108,6 +110,7 @@ tabler:
|
||||
profile: fas fa-user-edit
|
||||
profile-stats: far fa-chart-bar
|
||||
project: fas fa-briefcase
|
||||
public-holiday: fas fa-calendar-day
|
||||
repeat: fas fa-repeat
|
||||
reporting: far fa-chart-bar
|
||||
report: far fa-chart-bar
|
||||
@@ -119,6 +122,7 @@ tabler:
|
||||
save: far fa-save
|
||||
search: fas fa-search
|
||||
settings: fas fa-cog
|
||||
sickness: fas fa-prescription-bottle-medical
|
||||
shop: fas fa-shopping-cart
|
||||
spinner: fas fa-spinner
|
||||
start: fas fa-play
|
||||
@@ -130,6 +134,7 @@ tabler:
|
||||
team: fas fa-users
|
||||
timesheet: fas fa-clock
|
||||
timesheet-team: fas fa-user-clock
|
||||
time-off: fas fa-couch
|
||||
trash: far fa-trash-alt
|
||||
unlocked: fas fa-unlock-alt
|
||||
upload: fas fa-upload
|
||||
|
||||
76
phpstan.neon
76
phpstan.neon
@@ -27,6 +27,7 @@ parameters:
|
||||
numericOperandsInArithmeticOperators: true
|
||||
switchConditionsMatchingType: true
|
||||
noVariableVariables: false
|
||||
reportNonIntStringArrayKey: false
|
||||
paths:
|
||||
- src
|
||||
tmpDir: %rootDir%/../../../var/cache/phpstan
|
||||
@@ -2644,16 +2645,6 @@ 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
|
||||
@@ -3809,71 +3800,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$safeLinksWhitelist has no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:getProfile\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ProfileManager.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 0 on mixed\\.$#"
|
||||
count: 1
|
||||
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: "#^Cannot access offset 'pattern' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -4008,8 +3939,3 @@ 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
|
||||
|
||||
@@ -14,7 +14,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_US"/>
|
||||
<ini name="intl.default_locale" value="en"/>
|
||||
<ini name="date.timezone" value="Europe/Vienna"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
|
||||
1
public/build/app-rtl.15853b82.js
Normal file
1
public/build/app-rtl.15853b82.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{876:function(i,n,u){"use strict";u.r(n)},2395:function(i,n,u){u(876)}},function(i){var n;n=2395,i(i.s=n)}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{177:function(i,n,u){u(7996)},7996:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=177,i(i.s=n)}]);
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.41748d55.js
Normal file
2
public/build/app.41748d55.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/calendar.7e3186b2.js
Normal file
2
public/build/calendar.7e3186b2.js
Normal file
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
2
public/build/chart.56f16a68.js
Normal file
2
public/build/chart.56f16a68.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
/*!
|
||||
* @kurkle/color v0.3.2
|
||||
* @kurkle/color v0.3.4
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* (c) 2024 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/
|
||||
|
||||
2
public/build/dashboard.9708ae5e.js
Normal file
2
public/build/dashboard.9708ae5e.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,26 +2,26 @@
|
||||
"entrypoints": {
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.307ea672.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.41748d55.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.c18ba3c6.css"
|
||||
"/build/app.99ea4166.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app-rtl.fe7c5bf2.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app-rtl.15853b82.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.2003dce5.css"
|
||||
"/build/app-rtl.16262d9a.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/export-pdf.5905454a.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/export-pdf.395749ab.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/export-pdf.d8a6c23b.css"
|
||||
@@ -29,8 +29,8 @@
|
||||
},
|
||||
"invoice": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/invoice.0217cc18.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice.42b319e4.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/invoice.36018785.css"
|
||||
@@ -38,8 +38,8 @@
|
||||
},
|
||||
"invoice-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/invoice-pdf.1b6e735c.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice-pdf.26d98626.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/invoice-pdf.2b749265.css"
|
||||
@@ -47,14 +47,14 @@
|
||||
},
|
||||
"chart": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/chart.2c85f027.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/chart.56f16a68.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.cef61816.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.7e3186b2.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -62,8 +62,8 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/dashboard.faf42d4e.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/dashboard.9708ae5e.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
@@ -71,8 +71,8 @@
|
||||
},
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/highlight.718fe73d.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/highlight.0296a734.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
@@ -80,23 +80,23 @@
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.307ea672.js": "sha384-XNjtkar2JbWyJQan/C6jHZAYFI9X5rPcExLeDV86FYZMPJl0vNWMh8Pa8NgXX/EB",
|
||||
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
|
||||
"/build/app-rtl.fe7c5bf2.js": "sha384-UyLXlR/3/H6oFjRrIMH04PA8bAjVQ8rdVWkWoiO4LLYasg4H3UNWJ+Xh+FrYekKh",
|
||||
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
|
||||
"/build/export-pdf.5905454a.js": "sha384-AkNj018YEQJSxC1e/3jIvlh0uR5tHnlUe9ciBbHKIoRIwj7658tCLNVaT98SNFEX",
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
|
||||
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
|
||||
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.0217cc18.js": "sha384-ORySQp4DBSYc7so90AAb8h5abgQtz3tdoO+Zv1uHJ2XqSPMAGtFBrb+Rdzc6sZg1",
|
||||
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
|
||||
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
|
||||
"/build/invoice-pdf.1b6e735c.js": "sha384-8I5YMkETBNl7wD12V/hKX2YGfwH+V6PXWsu4I+1xXXx9bDzqDhtq+S7x/jey2/Wr",
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.2c85f027.js": "sha384-5Dm9aFCCSzlh5QY/vtU7/UrBkKt6aWnTccjljmpbQVFfzSEfFh0wUUV8qlXqyFJj",
|
||||
"/build/calendar.cef61816.js": "sha384-qCpJrSB6fZLXT94oiWHofGbX8C0TFl86oCMTdeKWqCmNGJxABkIVo+8k19p7sGlY",
|
||||
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
|
||||
"/build/calendar.7e3186b2.js": "sha384-umm2cw67dX5I6+PJ+PpNuHwWgKDJc+r2MmpN8az3dtAqUGa3N9ZGHD0SHc7xfFVB",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.faf42d4e.js": "sha384-xkM5D8wBK1sJekLI9DUQBxTjxjomfPQWWtWCo7NX03jNnFZtMv5anM0IISLWaFY0",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.718fe73d.js": "sha384-eHb21blXJ7SKqCFlMSxl4oblpusDif1B0Tsr2Iw1xQfUqxZxwtFwYYvMPllfw7BY",
|
||||
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
}
|
||||
1
public/build/export-pdf.395749ab.js
Normal file
1
public/build/export-pdf.395749ab.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{878:function(i,n,u){u(3385)},3385:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=878,i(i.s=n)}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{585:function(i,n,u){"use strict";u.r(n)},2352:function(i,n,u){u(585)}},function(i){var n;n=2352,i(i.s=n)}]);
|
||||
1
public/build/highlight.0296a734.js
Normal file
1
public/build/highlight.0296a734.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{2049:function(i,n,u){u(4860)},4860:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2049,i(i.s=n)}]);
|
||||
1
public/build/invoice-pdf.26d98626.js
Normal file
1
public/build/invoice-pdf.26d98626.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{1555:function(i,n,u){u(6620)},6620:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=1555,i(i.s=n)}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{239:function(i,n,u){"use strict";u.r(n)},8478:function(i,n,u){u(239)}},function(i){var n;n=8478,i(i.s=n)}]);
|
||||
1
public/build/invoice.42b319e4.js
Normal file
1
public/build/invoice.42b319e4.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{3631:function(i,n,u){"use strict";u.r(n)},4820:function(i,n,u){u(3631)}},function(i){var n;n=4820,i(i.s=n)}]);
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"build/app.css": "/build/app.c18ba3c6.css",
|
||||
"build/app.js": "/build/app.307ea672.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.fe7c5bf2.js",
|
||||
"build/app.css": "/build/app.99ea4166.css",
|
||||
"build/app.js": "/build/app.41748d55.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.16262d9a.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.5905454a.js",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
"build/invoice.css": "/build/invoice.36018785.css",
|
||||
"build/invoice.js": "/build/invoice.0217cc18.js",
|
||||
"build/invoice.js": "/build/invoice.42b319e4.js",
|
||||
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.1b6e735c.js",
|
||||
"build/chart.js": "/build/chart.2c85f027.js",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.56f16a68.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.cef61816.js",
|
||||
"build/calendar.js": "/build/calendar.7e3186b2.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.faf42d4e.js",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.718fe73d.js",
|
||||
"build/runtime.js": "/build/runtime.6c399d29.js",
|
||||
"build/highlight.js": "/build/highlight.0296a734.js",
|
||||
"build/runtime.js": "/build/runtime.684e9f6d.js",
|
||||
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
|
||||
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
|
||||
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.woff2",
|
||||
|
||||
1
public/build/runtime.684e9f6d.js
Normal file
1
public/build/runtime.684e9f6d.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].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))}()}();
|
||||
@@ -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))}()}();
|
||||
@@ -18,6 +18,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -76,6 +77,9 @@ final class InvoiceController extends BaseApiController
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
@@ -90,6 +94,7 @@ final class InvoiceController extends BaseApiController
|
||||
* Fetch invoice
|
||||
*/
|
||||
#[IsGranted('view_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
|
||||
public function getAction(Invoice $invoice): Response
|
||||
|
||||
@@ -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];
|
||||
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.46.0';
|
||||
public const VERSION = '2.52.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 24600;
|
||||
public const VERSION_ID = 25200;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -108,10 +108,30 @@ final class DoctorController extends AbstractController
|
||||
'logLines' => $logLines,
|
||||
'logSize' => $this->getLogSize(),
|
||||
'composer' => $this->getComposerPackages(),
|
||||
'release' => $latestRelease
|
||||
'release' => $latestRelease,
|
||||
'opcache' => $this->getOpcacheConfiguration()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled: bool, status: false|array<mixed>}
|
||||
*/
|
||||
private function getOpcacheConfiguration(): array
|
||||
{
|
||||
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
|
||||
|
||||
$enabled = \is_array($status) && $status['opcache_enabled'];
|
||||
|
||||
if ($enabled && \array_key_exists('scripts', $status)) {
|
||||
unset($status['scripts']);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -264,7 +284,12 @@ final class DoctorController extends AbstractController
|
||||
'sys_temp_dir',
|
||||
'date.timezone',
|
||||
'session.gc_maxlifetime',
|
||||
'disable_functions'
|
||||
'disable_functions',
|
||||
'opcache.enable',
|
||||
'opcache.memory_consumption',
|
||||
'opcache.interned_strings_buffer',
|
||||
'opcache.max_accelerated_files',
|
||||
'opcache.validate_timestamps',
|
||||
];
|
||||
|
||||
$settings = [];
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Model\DateStatisticInterface;
|
||||
use App\Model\Statistic\StatisticDate;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\TimesheetStatisticQuery;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use DateTimeInterface;
|
||||
|
||||
@@ -37,7 +38,7 @@ abstract class AbstractUserReportController extends AbstractController
|
||||
|
||||
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
|
||||
return $this->statisticService->getDailyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Model\DateStatisticInterface;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\YearByUser\YearByUser;
|
||||
use App\Reporting\YearByUser\YearByUserForm;
|
||||
use App\Repository\Query\TimesheetStatisticQuery;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
@@ -125,7 +126,7 @@ final class UserYearController extends AbstractUserReportController
|
||||
|
||||
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
|
||||
return $this->statisticService->getMonthlyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface
|
||||
|
||||
@@ -256,6 +256,18 @@ class ExportTemplate
|
||||
return \is_string($font) ? $font : null;
|
||||
}
|
||||
|
||||
public function isAvailableForAll(): bool
|
||||
{
|
||||
$isAllowed = $this->getOption('user_access', false);
|
||||
|
||||
return \is_bool($isAllowed) ? $isAllowed : false;
|
||||
}
|
||||
|
||||
public function setAvailableForAll(bool $userAccess): void
|
||||
{
|
||||
$this->setOption('user_access', $userAccess);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->title ?? 'New';
|
||||
|
||||
@@ -301,7 +301,7 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
* @return Collection<int, InvoiceTemplateMeta>
|
||||
*/
|
||||
public function getMetaFields(): Collection
|
||||
{
|
||||
|
||||
@@ -376,14 +376,24 @@ 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) {
|
||||
$this->addPreference($preference);
|
||||
if (($name = $preference->getName()) === null) {
|
||||
continue;
|
||||
}
|
||||
$p = $this->getPreference($name);
|
||||
if ($p === null) {
|
||||
$this->addPreference($preference);
|
||||
} else {
|
||||
$p->setValue($preference->getValue());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -36,6 +36,7 @@ class WorkingTime
|
||||
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private int $expectedTime = 0;
|
||||
private ?int $originalExpectedTime = null;
|
||||
#[ORM\Column(name: 'actual', type: Types::INTEGER, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private int $actualTime = 0;
|
||||
@@ -75,6 +76,39 @@ class WorkingTime
|
||||
public function setExpectedTime(int $expectedTime): void
|
||||
{
|
||||
$this->expectedTime = $expectedTime;
|
||||
$this->storeOriginalExpectedTime();
|
||||
}
|
||||
|
||||
public function storeOriginalExpectedTime(): void
|
||||
{
|
||||
if ($this->originalExpectedTime === null) {
|
||||
$this->originalExpectedTime = $this->expectedTime;
|
||||
}
|
||||
}
|
||||
|
||||
public function getOriginalExpectedTime(): int
|
||||
{
|
||||
$this->storeOriginalExpectedTime();
|
||||
|
||||
return $this->originalExpectedTime ?? 0;
|
||||
}
|
||||
|
||||
public function halveExpectedTime(): int
|
||||
{
|
||||
$this->storeOriginalExpectedTime();
|
||||
|
||||
$reduceBy = ($this->getOriginalExpectedTime() / 2);
|
||||
|
||||
$this->expectedTime = $this->expectedTime - $reduceBy;
|
||||
|
||||
return $reduceBy;
|
||||
}
|
||||
|
||||
public function emptyExpectedTime(): void
|
||||
{
|
||||
$this->storeOriginalExpectedTime();
|
||||
|
||||
$this->expectedTime = 0;
|
||||
}
|
||||
|
||||
public function getActualTime(): int
|
||||
|
||||
25
src/Event/TimesheetStatisticsQueryEvent.php
Normal file
25
src/Event/TimesheetStatisticsQueryEvent.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class TimesheetStatisticsQueryEvent extends Event
|
||||
{
|
||||
public function __construct(private readonly QueryBuilder $queryBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
public function getQueryBuilder(): QueryBuilder
|
||||
{
|
||||
return $this->queryBuilder;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ final class TimesheetsSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('export_own_timesheet')) {
|
||||
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
|
||||
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
|
||||
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('export_other_timesheet')) {
|
||||
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
|
||||
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
|
||||
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\ExportPolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -100,9 +100,12 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
@@ -103,9 +103,12 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
@@ -116,6 +119,8 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
'pdfContext' => $context
|
||||
], $this->getOptions($query)));
|
||||
|
||||
$sandbox->disableSandbox();
|
||||
|
||||
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
|
||||
|
||||
$content = $this->converter->convertToPdf($content, $pdfOptions);
|
||||
|
||||
@@ -35,7 +35,7 @@ final class CsvRendererFactory
|
||||
|
||||
public function createDefault(): CsvRenderer
|
||||
{
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'csv');
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'csv', 'en', 'csv');
|
||||
|
||||
return new CsvRenderer($this->converter, $this->translator, $template);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ final class HtmlRendererFactory
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(string $id, string $template): HtmlRenderer
|
||||
public function create(string $id, string $template, string $title = 'print'): HtmlRenderer
|
||||
{
|
||||
return new HtmlRenderer(
|
||||
$this->twig,
|
||||
@@ -33,7 +33,7 @@ final class HtmlRendererFactory
|
||||
$this->projectStatisticService,
|
||||
$this->activityStatisticService,
|
||||
$id,
|
||||
'print',
|
||||
$title,
|
||||
$template
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ final class XlsxRendererFactory
|
||||
|
||||
public function createDefault(): XlsxRenderer
|
||||
{
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx');
|
||||
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx', 'en', 'xlsx');
|
||||
|
||||
return new XlsxRenderer($this->converter, $this->translator, $template);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Export;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Event\ExportItemsQueryEvent;
|
||||
use App\Export\Renderer\CsvRendererFactory;
|
||||
use App\Export\Renderer\HtmlRendererFactory;
|
||||
@@ -74,6 +75,15 @@ final class ServiceExport
|
||||
$this->renderer[] = $renderer;
|
||||
}
|
||||
|
||||
private function filenameToTitle(string $title): string
|
||||
{
|
||||
if (str_contains($title, '.')) {
|
||||
$title = explode('.', $title)[0];
|
||||
}
|
||||
|
||||
return str_replace(['-', '_'], ' ', $title);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ExportRendererInterface[]
|
||||
*/
|
||||
@@ -82,32 +92,15 @@ final class ServiceExport
|
||||
$renderer = [
|
||||
$this->csvRendererFactory->createDefault(),
|
||||
$this->xlsxRendererFactory->createDefault(),
|
||||
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'),
|
||||
$this->htmlRendererFactory->create('html', 'export/print.html.twig'),
|
||||
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'pdf'),
|
||||
$this->htmlRendererFactory->create('print', 'export/print.html.twig'),
|
||||
];
|
||||
|
||||
foreach ($this->exportTemplateRepository->findAll() as $template) {
|
||||
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
|
||||
$tpl->setColumns($template->getColumns());
|
||||
$tpl->setLocale($template->getLanguage());
|
||||
$tpl->setOptions($template->getOptions());
|
||||
|
||||
switch ($template->getRenderer()) {
|
||||
case 'csv':
|
||||
$renderer[] = $this->csvRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
case 'xlsx':
|
||||
$renderer[] = $this->xlsxRendererFactory->create($tpl);
|
||||
break;
|
||||
|
||||
case 'pdf':
|
||||
$renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
break;
|
||||
try {
|
||||
$renderer[] = $this->createTemplateFromExportTemplate($template);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +117,7 @@ final class ServiceExport
|
||||
continue;
|
||||
}
|
||||
|
||||
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName);
|
||||
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +129,7 @@ final class ServiceExport
|
||||
continue;
|
||||
}
|
||||
|
||||
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName);
|
||||
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +137,28 @@ final class ServiceExport
|
||||
return array_merge($this->renderer, $renderer);
|
||||
}
|
||||
|
||||
private function createTemplateFromExportTemplate(ExportTemplate $template): ExportRendererInterface
|
||||
{
|
||||
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
|
||||
$tpl->setColumns($template->getColumns());
|
||||
$tpl->setLocale($template->getLanguage());
|
||||
$tpl->setOptions($template->getOptions());
|
||||
|
||||
switch ($template->getRenderer()) {
|
||||
case 'csv':
|
||||
return $this->csvRendererFactory->create($tpl);
|
||||
|
||||
case 'xlsx':
|
||||
return $this->xlsxRendererFactory->create($tpl);
|
||||
|
||||
case 'pdf':
|
||||
return $this->pdfRendererFactory->createFromTemplate($tpl);
|
||||
|
||||
default:
|
||||
throw new \Exception('Unknown export template type: ' . $template->getRenderer());
|
||||
}
|
||||
}
|
||||
|
||||
public function getRendererById(string $id): ?ExportRendererInterface
|
||||
{
|
||||
foreach ($this->getRenderer() as $renderer) {
|
||||
@@ -165,6 +180,7 @@ final class ServiceExport
|
||||
*/
|
||||
public function getTimesheetExporter(): array
|
||||
{
|
||||
// TODO 3.0 cache the result, as this is one extra database query on the timesheet pages
|
||||
$exporter = [
|
||||
$this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'),
|
||||
$this->xlsxRendererFactory->createDefault(),
|
||||
@@ -172,6 +188,17 @@ final class ServiceExport
|
||||
$this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'),
|
||||
];
|
||||
|
||||
foreach ($this->exportTemplateRepository->findAll() as $template) {
|
||||
if (!$template->isAvailableForAll()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$exporter[] = $this->createTemplateFromExportTemplate($template);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($this->timesheetExporter, $exporter);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Form\Type\ExportRendererType;
|
||||
use App\Form\Type\ExportSummaryColumnsType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use App\Form\Type\PdfFontType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -22,6 +23,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
/**
|
||||
* TODO rename with 3.0 to ExportTemplateForm
|
||||
*/
|
||||
class ExportTemplateSpreadsheetForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
@@ -77,6 +81,11 @@ class ExportTemplateSpreadsheetForm extends AbstractType
|
||||
'row_attr' => ['data-type' => 'pdf'],
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('availableForAll', YesNoType::class, [
|
||||
'label' => 'user_access_all',
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -19,9 +19,9 @@ final class ExportRendererType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => [
|
||||
'button.csv' => 'csv',
|
||||
'button.xlsx' => 'xlsx',
|
||||
'button.pdf' => 'pdf'
|
||||
'csv' => 'csv',
|
||||
'xlsx' => 'xlsx',
|
||||
'pdf' => 'pdf'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ final class QuickEntryTimesheetType extends AbstractType
|
||||
$duration = $data->getDuration(false);
|
||||
try {
|
||||
if (null !== $duration) {
|
||||
$duration += $data->getBreak();
|
||||
$end = clone $data->getBegin();
|
||||
$end->modify('+ ' . abs($duration) . ' seconds');
|
||||
$data->setEnd($end);
|
||||
|
||||
@@ -33,8 +33,9 @@ final class TeamType extends AbstractType
|
||||
return $team->getName();
|
||||
},
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Team ID',
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'integer', 'description' => 'Team IDs'],
|
||||
'description' => 'Array of Team IDs',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -30,11 +30,10 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event): void {
|
||||
/** @var ArrayCollection<UserPreference> $collection */
|
||||
/** @var ArrayCollection<int, UserPreference> $collection */
|
||||
$collection = $event->getData();
|
||||
$preferences = new ArrayCollection();
|
||||
foreach ($collection as $collectionItem) {
|
||||
$collection->removeElement($collectionItem);
|
||||
|
||||
if (!($collectionItem instanceof UserPreference)) {
|
||||
continue;
|
||||
}
|
||||
@@ -44,8 +43,9 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
continue;
|
||||
}
|
||||
|
||||
$collection->set($collectionItem->getName(), $collectionItem);
|
||||
$preferences->set($collectionItem->getName(), clone $collectionItem);
|
||||
}
|
||||
$event->setData($preferences);
|
||||
},
|
||||
// must be a higher priority then the listener in UserPreferenceType
|
||||
100
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Model\InvoiceDocument;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use App\Twig\SecurityPolicy\InvoicePolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
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 InvoicePolicy()));
|
||||
$twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $twig->getExtension(SandboxExtension::class);
|
||||
|
||||
@@ -10,15 +10,19 @@
|
||||
namespace App\Timesheet;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\TimesheetStatisticsQueryEvent;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Repository\Query\TimesheetStatisticQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use DateTimeInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class TimesheetStatisticService
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
public function __construct(
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly EventDispatcherInterface $eventDispatcher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -72,6 +76,8 @@ final class TimesheetStatisticService
|
||||
;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -96,13 +102,14 @@ final class TimesheetStatisticService
|
||||
|
||||
/**
|
||||
* @internal only for core development
|
||||
* @param DateTimeInterface $begin
|
||||
* @param DateTimeInterface $end
|
||||
* @param User[] $users
|
||||
* @return array
|
||||
*/
|
||||
public function getDailyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
|
||||
public function getDailyStatisticsGrouped(TimesheetStatisticQuery $query): array
|
||||
{
|
||||
$begin = $query->getBegin();
|
||||
$end = $query->getEnd();
|
||||
$users = $query->getUsers();
|
||||
|
||||
$stats = [];
|
||||
$usersById = [];
|
||||
|
||||
@@ -137,6 +144,8 @@ final class TimesheetStatisticService
|
||||
->addGroupBy('billable')
|
||||
;
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -173,11 +182,14 @@ final class TimesheetStatisticService
|
||||
|
||||
/**
|
||||
* @internal only for core development
|
||||
* @param User[] $users
|
||||
* @return array
|
||||
*/
|
||||
public function getMonthlyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
|
||||
public function getMonthlyStatisticsGrouped(TimesheetStatisticQuery $query): array
|
||||
{
|
||||
$begin = $query->getBegin();
|
||||
$end = $query->getEnd();
|
||||
$users = $query->getUsers();
|
||||
|
||||
$stats = [];
|
||||
$usersById = [];
|
||||
|
||||
@@ -214,6 +226,8 @@ final class TimesheetStatisticService
|
||||
->addGroupBy('billable')
|
||||
;
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
@@ -311,6 +325,8 @@ final class TimesheetStatisticService
|
||||
;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
foreach ($results as $row) {
|
||||
|
||||
@@ -44,7 +44,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
// cannot be deleted right now, needs to be kept for invoice and export templates
|
||||
new TwigFilter('date_full', [$this, 'dateTime'], ['deprecation_info' => new DeprecatedCallableInfo('Kimai', '2.0', 'date_time')]),
|
||||
new TwigFilter('date_format', [$this, 'dateFormat']),
|
||||
new TwigFilter('date_weekday', [$this, 'dateWeekday']),
|
||||
new TwigFilter('date_weekday', [$this, 'dateWeekday'], ['is_safe' => ['html']]),
|
||||
new TwigFilter('time', [$this, 'time']),
|
||||
new TwigFilter('duration', [$this, 'duration']),
|
||||
new TwigFilter('chart_duration', [$this, 'durationChart']),
|
||||
@@ -144,9 +144,9 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
return (string) $this->getFormatter()->dateFormat($date, $format);
|
||||
}
|
||||
|
||||
public function dateWeekday(\DateTimeInterface $date): string
|
||||
public function dateWeekday(\DateTimeInterface $date, string $spacer = ' '): string
|
||||
{
|
||||
return $this->dayName($date, true) . ' ' . $this->getFormatter()->dateFormat($date, 'd');
|
||||
return $this->dayName($date, true) . $spacer . $this->getFormatter()->dateFormat($date, 'd');
|
||||
}
|
||||
|
||||
public function time(\DateTimeInterface|string|null $date): string
|
||||
|
||||
@@ -1,44 +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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,72 +9,34 @@
|
||||
|
||||
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 Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class DefaultPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,33 +9,34 @@
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig export templates.
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class ExportPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,220 +9,34 @@
|
||||
|
||||
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 Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig invoice templates.
|
||||
* @deprecated since 2.47.0 - use StrictPolicy instead
|
||||
*/
|
||||
#[Exclude]
|
||||
final class InvoicePolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private SecurityPolicyInterface $default;
|
||||
private SecurityPolicyInterface $security;
|
||||
private SecurityPolicyInterface $securityPolicy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->default = new DefaultPolicy();
|
||||
$this->security = new SecurityPolicy(
|
||||
['block', 'if', 'for', 'set', 'extends', 'import'],
|
||||
[
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
|
||||
// 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',
|
||||
'spaceless',
|
||||
|
||||
// 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',
|
||||
]
|
||||
);
|
||||
$this->securityPolicy = new StrictPolicy();
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->default->checkSecurity($tags, $filters, $functions);
|
||||
$this->security->checkSecurity($tags, $filters, $functions);
|
||||
$this->securityPolicy->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);
|
||||
$this->securityPolicy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->default->checkPropertyAllowed($obj, $property);
|
||||
$this->security->checkPropertyAllowed($obj, $property);
|
||||
$this->securityPolicy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
|
||||
165
src/Twig/SecurityPolicy/StrictPolicy.php
Normal file
165
src/Twig/SecurityPolicy/StrictPolicy.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,17 @@ class Parsedown extends \Parsedown
|
||||
{
|
||||
$block = parent::blockHeader($Line);
|
||||
|
||||
$text = $block['element']['text'];
|
||||
if (isset($block['element']['handler']['argument'])) {
|
||||
$text = $block['element']['handler']['argument'];
|
||||
|
||||
if (\is_string($text) && $text !== '') {
|
||||
$id = $this->getIDfromText($text);
|
||||
if (\is_string($text) && $text !== '') {
|
||||
$id = $this->getIDfromText($text);
|
||||
|
||||
// add id-attribute
|
||||
$block['element']['attributes'] = [
|
||||
'id' => $id
|
||||
];
|
||||
// add id-attribute
|
||||
$block['element']['attributes'] = [
|
||||
'id' => $id
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $block;
|
||||
|
||||
@@ -44,55 +44,17 @@ final class ParsedownExtension extends Parsedown
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten to add support for file:///
|
||||
*/
|
||||
protected $safeLinksWhitelist = [
|
||||
'file:///',
|
||||
'http://',
|
||||
'https://',
|
||||
'ftp://',
|
||||
'ftps://',
|
||||
'mailto:',
|
||||
'data:image/png;base64,',
|
||||
'data:image/gif;base64,',
|
||||
'data:image/jpeg;base64,',
|
||||
'irc:',
|
||||
'ircs:',
|
||||
'git:',
|
||||
'ssh:',
|
||||
'news:',
|
||||
'steam:',
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten:
|
||||
* - added support for file:///
|
||||
* - open links in new windows
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array
|
||||
{
|
||||
if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
|
||||
return null;
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
if (preg_match('/\b(https?:[\/]{2}|file:[\/]{3})[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$url = $matches[0][0];
|
||||
|
||||
return [
|
||||
'extent' => \strlen($matches[0][0]),
|
||||
'position' => $matches[0][1],
|
||||
'element' => [
|
||||
'name' => 'a',
|
||||
'text' => $url,
|
||||
'attributes' => [
|
||||
'href' => $url,
|
||||
'target' => '_blank'
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
|
||||
|
||||
@@ -21,10 +21,7 @@ final class ReleaseVersion
|
||||
/**
|
||||
* Get all releases from GitHub.
|
||||
*
|
||||
* @throws \Exception
|
||||
* @return array|null
|
||||
* @return array<string, array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}>
|
||||
* @return array
|
||||
* @return array<string, array{'version': non-empty-string, 'date': \DateTimeInterface, 'url': non-empty-string, 'download': non-empty-string, 'content': string}>
|
||||
*/
|
||||
private function getReleasesFromGithub(): array
|
||||
{
|
||||
@@ -41,19 +38,24 @@ final class ReleaseVersion
|
||||
$context = stream_context_create($opts);
|
||||
|
||||
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
|
||||
$releases = json_decode($releases);
|
||||
|
||||
if (!isset($releases[0])) {
|
||||
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
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);
|
||||
|
||||
if ($releases === false) {
|
||||
throw new \Exception('Failed parsing 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;
|
||||
}
|
||||
@@ -62,19 +64,19 @@ final class ReleaseVersion
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $release->published_at;
|
||||
$date = $release['published_at'];
|
||||
try {
|
||||
$date = new \DateTimeImmutable($date);
|
||||
} catch (\Exception $ex) {
|
||||
// can be ignored, we return a string
|
||||
continue;
|
||||
}
|
||||
|
||||
$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));
|
||||
|
||||
@@ -59,6 +59,6 @@ final class UserDurationYear extends AbstractCounterYear
|
||||
|
||||
protected function getFinancialYearTitle(): string
|
||||
{
|
||||
return 'stats.durationFinancialYear';
|
||||
return 'stats.userDurationYear';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ final class DayAddon
|
||||
{
|
||||
private bool $billable = true;
|
||||
|
||||
public function __construct(private string $title, private int $duration, private int $visibleDuration)
|
||||
public function __construct(
|
||||
private readonly string $title,
|
||||
private readonly int $duration,
|
||||
private readonly int $visibleDuration,
|
||||
private readonly ?string $type = null
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -41,4 +46,9 @@ final class DayAddon
|
||||
{
|
||||
return $this->visibleDuration;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,6 @@
|
||||
"psr/event-dispatcher": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"psr/http-client": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/http-factory": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/http-message": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% set currency = activity.project.customer.currency %}
|
||||
{% endif %}
|
||||
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "customer/actions.html.twig" as customerActions %}
|
||||
{% import "project/actions.html.twig" as projectActions %}
|
||||
@@ -21,7 +21,6 @@
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_activity_edit', {'id': activity.id})}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
{% if activity.comment is not empty %}
|
||||
<div class="comment p-3">
|
||||
|
||||
@@ -26,19 +26,21 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content_start %}
|
||||
{% if app.session and app.session.started and app.session.flashbag.peekAll|length > 0 %}
|
||||
{% set flashes = app.flashes %}
|
||||
{% if flashes|length > 0 %}
|
||||
{% set domain = 'flashmessages' %}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function(options) {
|
||||
const ALERT = options.detail.kimai.getPlugin('alert');
|
||||
{% for type, messages in app.session.flashbag.all %}
|
||||
{% for type, messages in flashes %}
|
||||
{% for message in messages %}
|
||||
{% if type == 'error' %}
|
||||
ALERT.error('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'warning' %}
|
||||
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'success' %}
|
||||
ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
// success is expected so we do not need to show messages like "saved successfully"
|
||||
// ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% else %}
|
||||
ALERT.info('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% endif %}
|
||||
|
||||
@@ -55,8 +55,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="col-xs-12 {% if hasTwoColumns %}col-md-8 col-lg-9 col-print-12{% endif %}">
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% block box_body %}
|
||||
<div id="timesheet_calendar"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -158,28 +158,35 @@
|
||||
</td>
|
||||
{% set dayCount = 0 %}
|
||||
{% for day in month.days %}
|
||||
{% set class = 'text-end contractDay text-nowrap' %}
|
||||
{% if day.workingTime.expectedTime == 0 %}
|
||||
{% set class = class ~ ' weekend' %}
|
||||
{% endif %}
|
||||
{% set style = null %}
|
||||
{% if day.workingTime.expectedTime > 0 and day.workingTime.actualTime == 0 and now > day.day %}
|
||||
{% set class = class ~ ' bg-orange-lt' %}
|
||||
{% set style = 'missing' %}
|
||||
{% elseif day.workingTime.expectedTime == 0 and day.workingTime.actualTime > 0 and now > day.day %}
|
||||
{% set class = class ~ ' bg-yellow-lt' %}
|
||||
{% set style = 'unexpected' %}
|
||||
{% endif %}
|
||||
<td class="{{ class }}">
|
||||
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0) and (now > day.day or day.workingTime.actualTime > 0) %}
|
||||
{% if day.hasAddons() %}
|
||||
{% set statusTitle = '' %}
|
||||
{% for addon in day.getAddons() %}
|
||||
{% set statusTitle = statusTitle ~ (addon.title|trans) ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
|
||||
{% if not loop.last %}
|
||||
{% set statusTitle = statusTitle ~ ', ' %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="status-dot status-azure" data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"></span>
|
||||
{% set statusTitle = null %}
|
||||
{% if day.hasAddons() %}
|
||||
{% for addon in day.getAddons() %}
|
||||
{% set style = addon.type ?? style %}
|
||||
{% set statusTitle = (statusTitle ?? '') ~ (addon.title|trans) %}
|
||||
{% if addon.visibleDuration > 0 %}
|
||||
{% set statusTitle = statusTitle ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
|
||||
{% endif %}
|
||||
{% if not loop.last %}
|
||||
{% set statusTitle = statusTitle ~ ', ' %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<td class="text-end contractDay text-nowrap{% if style is not null %} bg-{{ style }}{% endif %}"{% if statusTitle is not null %} data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"{% endif %}>
|
||||
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0 or day.hasAddons()) and (now > day.day or day.workingTime.actualTime > 0) %}
|
||||
{% if style is not null and statusTitle is not null %}
|
||||
<i class="{{ style|icon }} fa-xs"></i>
|
||||
{% endif %}
|
||||
{% if day.workingTime.expectedTime == 0 and day.workingTime.actualTime == 0 %}
|
||||
--:--
|
||||
{% else %}
|
||||
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
|
||||
{% endif %}
|
||||
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% set dayCount = dayCount + 1 %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block main %}
|
||||
{% set can_edit = is_granted('edit', customer) %}
|
||||
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_attributes %}id="customer_details_box"{% endblock %}
|
||||
{% block box_title %}
|
||||
@@ -16,7 +16,6 @@
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_customer_edit', {'id': customer.id})}) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
{% if customer.comment is not empty %}
|
||||
<div class="comment p-3">
|
||||
|
||||
@@ -162,4 +162,72 @@
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% set opcache_warning = not opcache['enabled'] %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? 'danger' : 'success')} %}
|
||||
{% block title %}OPCache{% endblock %}
|
||||
{% block body_class %}p-0{% endblock %}
|
||||
{% block body %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% if opcache_warning %}
|
||||
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
|
||||
{% endif %}
|
||||
{% if opcache['status'] is iterable %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Opcache Enabled</th>
|
||||
<td class="text-break">{{ widgets.label_boolean(opcache['enabled']) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Opcache Full</th>
|
||||
<td class="text-break">{{ widgets.label_boolean(opcache['status']['cache_full']) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Memory Used</th>
|
||||
<td class="text-break">{{ (opcache['status']['memory_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Memory Free</th>
|
||||
<td class="text-break">{{ (opcache['status']['memory_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Interned-Strings Used</th>
|
||||
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Interned-Strings Free</th>
|
||||
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Cached Scripts</th>
|
||||
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_scripts'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Cached Keys</th>
|
||||
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_keys'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Max Keys</th>
|
||||
<td class="text-break">{{ opcache['status']['opcache_statistics']['max_cached_keys'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Hit Rate</th>
|
||||
<td class="text-break">{{ opcache['status']['opcache_statistics']['opcache_hit_rate']|number_format(2) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Jit enabled</th>
|
||||
<td class="text-break">{{ widgets.label_boolean(opcache['status']['jit']['enabled']) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Buffer Size</th>
|
||||
<td class="text-break">{{ (opcache['status']['jit']['buffer_size'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-nowrap w-min">Buffer Free</th>
|
||||
<td class="text-break">{{ (opcache['status']['jit']['buffer_free'] / 1024 / 1024)|number_format(2) }} MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{% set timeBudgetVisible = is_granted('time', entity) %}
|
||||
{% set durationTrans = entity.isMonthlyBudget() ? 'stats.durationMonth' : 'stats.workingTime' %}
|
||||
{% if timeBudgetVisible %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/progressbar.html.twig" as progress %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}
|
||||
{{ 'timeBudget'|trans }} {% if entity.isMonthlyBudget() %}({{ 'budgetType_month'|trans }}){% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_attributes %}id="time_budget_box"{% endblock %}
|
||||
{% block box_body %}
|
||||
<div class="row">
|
||||
@@ -59,13 +58,12 @@
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
{% if is_granted('budget', entity) %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/progressbar.html.twig" as progress %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}
|
||||
{{ 'budget'|trans }} {% if entity.isMonthlyBudget() %}({{ 'budgetType_month'|trans }}){% endif %}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_attributes %}id="budget_box"{% endblock %}
|
||||
{% block box_body %}
|
||||
<div class="row">
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<td class="actions">
|
||||
<div class="btn-group">
|
||||
<a href="{{ path(edit_route, {'id': entity.id, 'rate': rate.id}) }}" class="modal-ajax-form btn btn-icon">{{ icon('edit', true) }}</a>
|
||||
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete', true) }}</a>
|
||||
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -104,13 +104,12 @@
|
||||
{% if entries is empty %}
|
||||
{{ widgets.nothing_found() }}
|
||||
{% else %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}
|
||||
{{ 'preview'|trans }}
|
||||
{% endblock %}
|
||||
{% block box_attributes %}id="preview_export"{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_footer %}
|
||||
{% if showMarkAsExportedButton %}
|
||||
<div class="d-flex">
|
||||
@@ -124,17 +123,13 @@
|
||||
<div class="btn-group me-auto" id="export-buttons" role="group">
|
||||
{% set allowCreate = is_granted('create_export_template') %}
|
||||
{% for type, group in buttons %}
|
||||
{% set btnTitle = ('button.' ~ type)|trans %}
|
||||
{% if btnTitle == ('button.' ~ type) %}
|
||||
{% set btnTitle = type|trans %}
|
||||
{% if type == 'html' %}
|
||||
{% set type = 'print' %}
|
||||
{% endif %}
|
||||
{% set btnTitle = type|trans %}
|
||||
{% if group|length == 1 %}
|
||||
{% for id, cfg in group %}
|
||||
{% set title = cfg.title %}
|
||||
{% set btnTitle2 = ('button.' ~ title)|trans %}
|
||||
{% if btnTitle2 != ('button.' ~ title) %}
|
||||
{% set btnTitle = btnTitle2 %}
|
||||
{% endif %}
|
||||
<button type="button" id="export-{{ id }}-button" class="btn btn-primary startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
</button>
|
||||
@@ -146,19 +141,15 @@
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{% for id, cfg in group %}
|
||||
{% set title = cfg.title %}
|
||||
{% set btnTitle = id|trans({}, 'export') %}
|
||||
{% if btnTitle == id %}
|
||||
{% set btnTitle = title|trans %}
|
||||
{% endif %}
|
||||
{% if btnTitle == id %}
|
||||
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% set btnTitle = (cfg.title)|trans %}
|
||||
{% if loop.first %}
|
||||
{% set btnTitle = 'default'|trans %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
|
||||
{{ btnTitle }}
|
||||
{% if allowCreate and cfg.internal %}
|
||||
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error">{{ icon('delete') }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app.request.locale }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<link rel="stylesheet" href="{{ asset('bundles/tabler/tabler.css') }}">
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{% block styles %}{% endblock %}
|
||||
{% if block('document_title') is defined %}
|
||||
<title>{{ block('document_title') }}</title>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="pad export_print">
|
||||
<div class="wrapper">
|
||||
<section class="export">
|
||||
{% block export %}{% endblock %}
|
||||
</section>
|
||||
</div>
|
||||
{% block javascripts %}
|
||||
<script src="{{ asset('bundles/tabler/tabler.js') }}"></script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
{% set showCustomerSummary = false %}
|
||||
{% set decimal = true %}
|
||||
{% set showDateTimeShort = true %}
|
||||
{% block title %}{{ 'timesheet.pdf.twig'|trans({}, 'export') }}{% endblock %}
|
||||
{% block title %}{{ 'timesheet'|trans }}{% endblock %}
|
||||
{% block description %}
|
||||
{% if entry.description is not empty %}
|
||||
{{ entry.description|desc2html }}
|
||||
|
||||
@@ -35,14 +35,13 @@
|
||||
{% endif %}
|
||||
|
||||
{% if documents|length > 0 %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {'documents': documents} %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {'documents': documents, fullsize: true} %}
|
||||
{% import "invoice/actions.html.twig" as actions %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'invoice_renderer'|trans({}, 'invoice-renderer') }}{% endblock %}
|
||||
{% block box_attributes %}
|
||||
id="invoice_document_list"
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
<table class="table table-hover dataTable">
|
||||
<thead>
|
||||
|
||||
@@ -90,14 +90,13 @@
|
||||
{% endif %}
|
||||
|
||||
{% if models|length > 0 %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% block box_title %}
|
||||
{{ 'preview'|trans }}
|
||||
{% endblock %}
|
||||
{% block box_attributes %}id="invoice_customer_forms"{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
<table class="table dataTable">
|
||||
<thead>
|
||||
|
||||
44
templates/macros/meta_fields.html.twig
Normal file
44
templates/macros/meta_fields.html.twig
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
{% macro meta_field_value(entity, field) %}
|
||||
{% set metaField = entity.metaField(field.name) %}
|
||||
{% if not metaField is null %}
|
||||
{% set metaField = metaField.merge(field) %}
|
||||
{{ _self.form_type_value(metaField.type, metaField.value, entity) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro form_type_value(type, value, entity) %}
|
||||
{% if 'DurationType' in type %}
|
||||
{{ value|duration }}
|
||||
{% elseif 'YesNoType' in type or 'CheckboxType' in type %}
|
||||
{{ value ? ('yes'|trans) : ('no'|trans) }}
|
||||
{% elseif 'DatePickerType' in type %}
|
||||
{{ value|date_short }}
|
||||
{% elseif 'DateTimePickerType' in type %}
|
||||
{{ value|date_time }}
|
||||
{% elseif 'CountryType' in type %}
|
||||
{{ value|country_name }}
|
||||
{% elseif 'CurrencyType' in type %}
|
||||
{{ value|currency_name }}
|
||||
{% elseif 'LanguageType' in type %}
|
||||
{{ value|locale_name }}
|
||||
{% elseif 'MoneyType' in type %}
|
||||
{% set classname = class_name(entity) %}
|
||||
{% if classname == 'App\\Entity\\Timesheet' %}
|
||||
{{ value|money(entity.project.customer.currency) }}
|
||||
{% elseif classname == 'App\\Entity\\Customer' %}
|
||||
{{ value|money(entity.currency) }}
|
||||
{% elseif classname == 'App\\Entity\\Project' %}
|
||||
{{ value|money(entity.customer.currency) }}
|
||||
{% elseif classname == 'App\\Entity\\Activity' and entity.project is not null %}
|
||||
{{ value|money(entity.project.customer.currency) }}
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
{% elseif 'TextareaType' in type %}
|
||||
{{ value|nl2br }}
|
||||
{% else %}
|
||||
{# EmailType, UrlType, TagsType, ColorPickerType, ColorChoiceType #}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -314,9 +314,7 @@
|
||||
{% macro form_type_value(type, value, entity) %}
|
||||
{% if 'ColorPickerType' in type or 'ColorChoiceType' in type %}
|
||||
{% if value is not empty %}
|
||||
<span class="label-color" data-toggle="tooltip" data-placement="top" title="{{ value }}">
|
||||
<i class="dot {{ 'dot'|icon(true) }} fa-fw" style="color:{{ value }}"></i>
|
||||
</span>
|
||||
{{ _self.label_color_dot('color', true, value, null, value) }}
|
||||
{% endif %}
|
||||
{% elseif 'DurationType' in type %}
|
||||
{{ value|duration }}
|
||||
@@ -340,7 +338,7 @@
|
||||
{% endfor %}
|
||||
{% elseif 'MoneyType' in type %}
|
||||
{% set classname = class_name(entity) %}
|
||||
{% if entity is null %}
|
||||
{% if classname is null %}
|
||||
{{ value }}
|
||||
{% elseif classname == 'App\\Entity\\Timesheet' %}
|
||||
{{ value|money(entity.project.customer.currency) }}
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
{% if plugins|length == 0 %}
|
||||
{{ widgets.callout('warning', 'plugin.none_installed'|trans({}, 'plugins')) }}
|
||||
{% else %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% block box_title %}{{ 'plugin.installed'|trans }}{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
{% set columns = {
|
||||
'name': {'class': 'alwaysVisible w-25'},
|
||||
@@ -46,10 +45,9 @@
|
||||
{% endif %}
|
||||
|
||||
{% if extensions|length > 0 %}
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% from "macros/widgets.html.twig" import card_tool_button %}
|
||||
{% block box_title %}{{ 'plugin.marketplace'|trans({}, 'plugins') }}{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_tools %}
|
||||
{{ card_tool_button('shop', {'title': 'shop', 'translation_domain': 'plugins', 'target': '_blank', 'url': constant('App\\Constants::HOMEPAGE') ~ '/store/', 'icon': false, 'class': 'btn-primary'}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block main %}
|
||||
{% set can_edit = is_granted('edit', project) %}
|
||||
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "customer/actions.html.twig" as customerActions %}
|
||||
{% block box_attributes %}id="project_details_box"{% endblock %}
|
||||
@@ -16,7 +16,6 @@
|
||||
{% block box_title %}
|
||||
{{ widgets.label_dot(project.name, project.color) }}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}p-0{% endblock %}
|
||||
{% block box_body %}
|
||||
{% if project.comment is not empty %}
|
||||
<div class="comment p-3">
|
||||
|
||||
@@ -20,16 +20,14 @@
|
||||
|
||||
{% block main %}
|
||||
{% if form is defined %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
{% block report_form %}
|
||||
{{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }}
|
||||
<div class="{% block report_form_container_class %}btn-list w-100{% endblock %}">
|
||||
{% block report_form_layout %}{{ form_rest(form) }}{% endblock %}
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{% block report_form %}
|
||||
{{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }}
|
||||
<div class="{% block report_form_container_class %}btn-list w-100{% endblock %}">
|
||||
{% block report_form_layout %}{{ form_rest(form) }}{% endblock %}
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
{% block column_classes_total -%}
|
||||
{% if column is weekend %} weekend{% endif %}
|
||||
{%- endblock %}
|
||||
{% block column_classes_customer_total -%}
|
||||
{% if column is weekend %} weekend{% endif %}
|
||||
{%- endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user