Compare commits

...

19 Commits

Author SHA1 Message Date
Kevin Papst
cad9f58703 Release 2.52 (#5874) 2026-03-16 17:44:12 +01:00
Weblate (bot)
70b4fbcae8 Translated using Weblate (#5848)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Christopher Picón <ntrpc.tech@users.noreply.hosted.weblate.org>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Kamborio <Kamborio15@users.noreply.hosted.weblate.org>
Co-authored-by: PizzaPoot <pizzapoot@users.noreply.hosted.weblate.org>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: clearstripe <sakurasan000510@gmail.com>
2026-03-16 17:39:50 +01:00
Kevin Papst
a0601c8cb2 check customer permissions on invoice api access (#5849) 2026-03-01 16:53:49 +01:00
Kevin Papst
5b320bf2ea Release 2.51 (#5847)
* bump packages
2026-03-01 16:21:07 +01:00
Kevin Papst
15110f78d8 fix sticky calendar popup (#5846) 2026-02-28 19:25:06 +01:00
Kevin Papst
4154de6bd1 Release 2.50 (#5835)
* replace p-0 class with fullsize embed option
* bump parsedown package
* remove support for file:// urls
* fix missing macro in export print template
* fix weekly hours with breaks
2026-02-25 21:07:40 +01:00
Weblate (bot)
8094fcf5b5 Translated using Weblate (#5831)
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Marat Ismailov <klippygames@gmail.com>
Co-authored-by: Sean Young <assanges@users.noreply.hosted.weblate.org>
2026-02-25 21:06:48 +01:00
Kevin Papst
77afa207e0 Allow to customize statistic queries (#5827)
* use TimesheetStatisticsQUery for all repository calls
* send event to customize statistics query
2026-02-24 13:27:48 +01:00
Kevin Papst
d9ef6dfcad Update database requirements in README (#5825) 2026-02-15 21:42:25 +01:00
Kevin Papst
ff6918fcab Release 2.49 (#5820)
* bump packages
* add column summarization per customer (#5808)
* clarify database requirements
* added helper methods to fetch original expected time

Co-authored-by: GregorB54321 <34287148+GregorB54321@users.noreply.github.com>
2026-02-15 21:31:06 +01:00
Weblate (bot)
f376b5c8a1 Translated using Weblate (#5804)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Artem <artemkozhin80@gmail.com>
Co-authored-by: Eleni Diamantopoulou <elenidiama00@gmail.com>
Co-authored-by: Florent Berthelot <florentius.b@gmail.com>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Levente Déri <derilevi@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Paul <snide-slum-partake@duck.com>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: irooniline <mart.styff@protonmail.com>
Co-authored-by: leonidovmob <leonidovmob@gmail.com>
2026-02-15 21:28:53 +01:00
Kevin Papst
b00fbd2516 Delete .opensourcefinder-verify 2026-02-14 18:56:38 +01:00
Kevin Papst
d97abc3f57 Add .opensourcefinder-verify file with claims (#5824) 2026-02-14 08:19:51 +01:00
Kevin Papst
3f184e42c8 missing translations 2026-01-31 10:28:30 +01:00
Kevin Papst
4a31411d69 Release 2.48 (#5789) 2026-01-30 16:44:26 +01:00
Weblate (bot)
3925eacf9f Translated using Weblate (#5748)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Fabio Gomes de lima <fabiogomesdelima598@gmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Kolappan N <kolappan@users.noreply.hosted.weblate.org>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mauro F. T. <maurofroeltani@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: m45ked <m45ked@users.noreply.hosted.weblate.org>
2026-01-30 13:14:21 +01:00
Kevin Papst
394f377890 Update header (#5799) 2026-01-28 20:10:33 +01:00
Milo Ivir
e8b0dc4969 Add translation links to README (#5798)
Co-authored-by: Kevin Papst <kevinpapst@users.noreply.github.com>
2026-01-28 19:58:12 +01:00
Kevin Papst
d429c56687 Release 2.47 (#5784) 2026-01-25 09:51:22 +01:00
282 changed files with 7976 additions and 5541 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
@import "variables";
@import "layout";
@import "error-page";
@import "print";

View File

@@ -12,6 +12,6 @@
width: 100%;
height: 100%;
z-index: 1021;
background-color: var(--tblr-backdrop-bg);
/*background-color: var(--tblr-backdrop-bg);*/
opacity: $modal-backdrop-opacity;
}

View File

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

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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

View File

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +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
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -508,7 +508,7 @@ final class TranslationCommand extends Command
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}

View File

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

View File

@@ -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 = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\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,

View File

@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use App\Twig\LocaleFormatExtensions;
use App\Twig\SecurityPolicy\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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,6 @@ final class UserDurationYear extends AbstractCounterYear
protected function getFinancialYearTitle(): string
{
return 'stats.durationFinancialYear';
return 'stats.userDurationYear';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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